crates\spt-daemon\tests\digest.rs:8://! `spt digest` **snapshot pull** ([`pull_snapshot`]) and a **delta-stream**
crates\spt-daemon\tests\digest.rs:27:    follow, pull_snapshot, serve_digest_control, Brain, Broker, DigestEngine, DigestHub,
crates\spt-daemon\tests\digest.rs:54:fn seam() -> PtyDigest {
crates\spt-daemon\tests\digest.rs:55:    PtyDigest {
crates\spt-daemon\tests\digest.rs:149:    let (digest, version) = pull_snapshot(&digest_name, "doyle")
crates\spt-term\src\digest.rs:133:pub struct Digest {
crates\spt-term\src\digest.rs:234:    pub fn digest(&self) -> Digest {
crates\spt-term\src\digest.rs:278:        Digest { turns }
crates\spt-term\src\projection.rs:112:pub fn project(records: &[DigestRecord], config: &DigestConfig) -> Digest {
crates\spt-term\src\projection.rs:162:    Digest { turns }
crates\spt-daemon\src\brain.rs:760:    pub fn run_digest_feed(
crates\spt-daemon\src\daemon.rs:28:use crate::endpoint::{broker_socket_name, daemon_pid_path, digest_socket_name, seed_socket_name};
crates\spt-daemon\src\daemon.rs:213:                let _ = digesthub::serve_digest_control(&digest_socket_name(), hub);
crates\spt-daemon\src\digest.rs:134:    pub fn snapshot(&self) -> Digest {
crates\spt-daemon\src\digest.rs:219:/// ([`crate::brain::Brain::run_digest_feed`]) and any in-process daemon feed
crates\spt-daemon\src\digest.rs:248:    fn seam() -> PtyDigest {
crates\spt-daemon\src\digest.rs:249:        PtyDigest {
crates\spt-daemon\src\digesthub.rs:9://! - **snapshot pull** — `spt digest <id>` ([`pull_snapshot`]) returns the current
crates\spt-daemon\src\digesthub.rs:223:pub fn pull_snapshot(name: &str, id: &str) -> io::Result<Option<(Digest, u64)>> {
crates\spt-daemon\src\digesthub.rs:373:        let snap = Digest {
crates\spt-daemon\src\digesthub.rs:392:            Digest {
crates\spt-daemon\src\digesthub.rs:403:            Digest {
crates\spt-daemon\src\digesthub.rs:418:            Digest {
crates\spt-daemon\src\digesthub.rs:432:        let digest = Digest {
crates\spt-daemon\src\endpoint.rs:43:pub fn digest_socket_name() -> String {
crates\spt-daemon\src\lib.rs:180:    digest_to_json, follow, pull_snapshot, render_digest, render_update, serve_digest_control,
crates\spt-daemon\src\lib.rs:185:    broker_socket_name, daemon_pid_path, digest_socket_name, pump_heartbeat_path,
crates\spt\src\cli.rs:292:    Digest {
crates\spt\src\cli.rs:888:            EndpointCmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
crates\spt\src\cli.rs:927:fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
crates\spt\src\cli.rs:938:    let name = spt_daemon::digest_socket_name();
crates\spt\src\cli.rs:959:    match spt_daemon::pull_snapshot(&name, id) {
crates\spt\src\cli.rs:1245:fn cmd_endpoint_list(local: bool, subnet: Option<String>, detail: bool) -> i32 {
crates\spt\src\cli.rs:5910:                action: Some(EndpointCmd::Digest {
crates\spt\src\cli.rs:7379:                action: Some(EndpointCmd::Digest { id, follow, json }),
crates\spt\src\cli.rs:7385:            _ => panic!("expected endpoint digest"),
crates\spt\src\api\reporting.rs:265:pub fn cmd_digest_entry(id: &str) -> i32 {
crates\spt\src\api\reporting.rs:272:/// The testable core of [`cmd_digest_entry`] (stdin split out): validate `record`
crates\spt\src\api\mod.rs:400:        ApiCmd::DigestEntry { id, auth } => gated(&id, &auth, reporting::cmd_digest_entry),
crates\spt-runtime\src\manifest.rs:526:pub struct PtyDigest {


Critical discovery: `run_digest_feed` (the PTY byte-feed driver) has **no production caller** — only its definition, a doc-ref, and the daemon-half E2E. The daemon serves the digest control channel (pull/subscribe) but nothing ever fed the hub in production. So the byte-feed was a built-but-never-live-wired primitive (B8 shipped engine+hub+control+test, never the live session→engine feed). This changes T7b: it's **retire + fresh log-sourced wire**, not replace-a-live-path — and raises a scope question for the delta-stream nudge. This is a hurdle for doyle's judgment.

Let me record status + the discovery in the plan, then flag doyle.


Edit

## 4. T7a — projection engine (new code)

1. **Digest-record schema** (RULED §2):

## Execution status (live)

- **commit 0** ✅ `714f6b8` — this plan (doyle-vetted).
- **T7a commit 1** ✅ `82ead73` — `spt-term::projection`: published digest-record
  contract + `project`/`project_lines` + 8 units. Additive; parser still green.
- **T7a commit 2** ✅ `6033326` — `api digest-entry` push door +
  `spt_store::history::append_digest_entry` (separate `digest.log`) + units.
- **T7b** ⏳ — the atomic re-wire + retire + REQ re-point. **Blocked on a scope
  ruling (see Discovery).**

### Discovery (affects T7b scope — surfaced to doyle 2026-06-13)

`Brain::run_digest_feed` (the PTY byte-feed driver, `brain.rs:760`) has **no
production caller** — only its definition, a doc cross-ref, and the daemon-half
integration test (`spt-daemon/tests/digest.rs`) drive it. The daemon serves the
digest **control channel** (`daemon.rs:213` → `serve_digest_control`, the
pull/subscribe surface) but **nothing ever fed the hub in production**: B8
shipped the engine + hub + control channel + a manually-driven E2E, never the
live session→engine feed. So in production today `spt endpoint digest <id>`
always resolves "no live digest" (the hub is never published to).

**Consequence:** T7b is **retire + a fresh log-sourced wire**, not "replace a
live path." The byte-feed primitive can be removed cleanly (no live integration
depends on it). The open scope question (doyle): for G3, does the **delta-stream
live-nudge** (file-watch → publish, brand-new work that never existed) belong in
scope, or is a **topology-independent snapshot-pull** — the daemon projecting
from the endpoint's records on demand at the snapshot handler, matching the
pre-M9 wiring level + proven by the CC-fixture E2E — the G3 bar, with live
nudging deferred to the frontend milestone that actually consumes the stream?

## 4. T7a — projection engine (new code) — ✅ DONE

1. **Digest-record schema** (RULED §2):


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

## 4. T7a — projection engine (new code)

1. **Digest-record schema** (RULED §2):

## Execution status (live)

- **commit 0** ✅ `714f6b8` — this plan (doyle-vetted).
- **T7a commit 1** ✅ `82ead73` — `spt-term::projection`: published digest-record
  contract + `project`/`project_lines` + 8 units. Additive; parser still green.
- **T7a commit 2** ✅ `6033326` — `api digest-entry` push door +
  `spt_store::history::append_digest_entry` (separate `digest.log`) + units.
- **T7b** ⏳ — the atomic re-wire + retire + REQ re-point. **Blocked on a scope
  ruling (see Discovery).**

### Discovery (affects T7b scope — surfaced to doyle 2026-06-13)

`Brain::run_digest_feed` (the PTY byte-feed driver, `brain.rs:760`) has **no
production caller** — only its definition, a doc cross-ref, and the daemon-half
integration test (`spt-daemon/tests/digest.rs`) drive it. The daemon serves the
digest **control channel** (`daemon.rs:213` → `serve_digest_control`, the
pull/subscribe surface) but **nothing ever fed the hub in production**: B8
shipped the engine + hub + control channel + a manually-driven E2E, never the
live session→engine feed. So in production today `spt endpoint digest <id>`
always resolves "no live digest" (the hub is never published to).

**Consequence:** T7b is **retire + a fresh log-sourced wire**, not "replace a
live path." The byte-feed primitive can be removed cleanly (no live integration
depends on it). The open scope question (doyle): for G3, does the **delta-stream
live-nudge** (file-watch → publish, brand-new work that never existed) belong in
scope, or is a **topology-independent snapshot-pull** — the daemon projecting
from the endpoint's records on demand at the snapshot handler, matching the
pre-M9 wiring level + proven by the CC-fixture E2E — the G3 bar, with live
nudging deferred to the frontend milestone that actually consumes the stream?

## 4. T7a — projection engine (new code) — ✅ DONE

1. **Digest-record schema** (RULED §2):

-## 4. T7a — projection engine (new code)

+- **commit 0** ✅ `714f6b8` — this plan (doyle-vetted).

+- **T7a commit 1** ✅ `82ead73` — `spt-term::projection`: published digest-record

+  contract + `project`/`project_lines` + 8 units. Additive; parser still green.

+- **T7a commit 2** ✅ `6033326` — `api digest-entry` push door +

+- **T7b** ⏳ — the atomic re-wire + retire + REQ re-point. **Blocked on a scope

+### Discovery (affects T7b scope — surfaced to doyle 2026-06-13)

+`Brain::run_digest_feed` (the PTY byte-feed driver, `brain.rs:760`) has **no

+production caller** — only its definition, a doc cross-ref, and the daemon-half

+integration test (`spt-daemon/tests/digest.rs`) drive it. The daemon serves the

+digest **control channel** (`daemon.rs:213` → `serve_digest_control`, the

+pull/subscribe surface) but **nothing ever fed the hub in production**: B8

+shipped the engine + hub + control channel + a manually-driven E2E, never the

+live session→engine feed. So in production today `spt endpoint digest <id>`

+always resolves "no live digest" (the hub is never published to).

+**Consequence:** T7b is **retire + a fresh log-sourced wire**, not "replace a

+live path." The byte-feed primitive can be removed cleanly (no live integration

+depends on it). The open scope question (doyle): for G3, does the **delta-stream

+live-nudge** (file-watch → publish, brand-new work that never existed) belong in

+scope, or is a **topology-independent snapshot-pull** — the daemon projecting

+from the endpoint's records on demand at the snapshot handler, matching the

+pre-M9 wiring level + proven by the CC-fixture E2E — the G3 bar, with live

+nudging deferred to the frontend milestone that actually consumes the stream?

+## 4. T7a — projection engine (new code) — ✅ DONE

1. **Digest-record schema** (RULED §2): a small parse fn

`record_to_tagged(raw: &str) -> Option<TaggedRecord>` reading the published

contract (`role ∈ {input,agent,tool}`, `text`, optional `tool{name,arg}`).


Bash

cd C:/Users/decid/Documents/projects/spt-core && $OWL send doyle todlando <<'EOF'
[T7a LANDED + T7b scope question] T7a done on m9-digest-logs, all green (traceable EXIT=0):
- 82ead73 spt-term::projection — published digest-record contract (role∈{input,agent,tool}/text/optional tool{name,arg}) + project/project_lines + 8 units. Forward-compat: unknown fields tolerated, malformed/unknown-role/toolless-tool skipped. Additive — parser still green.
- 6033326 api digest-entry door + spt_store::append_digest_entry → SEPARATE digest.log (never pollutes the opaque transcript history.log). Validates at the door via record_to_tagged; auth-gated.

DISCOVERY affecting T7b scope (your judgment): Brain::run_digest_feed (the PTY byte-feed driver) has NO production caller — only its def, a doc-ref, and the daemon-half E2E drive it. The daemon serves the digest CONTROL channel (pull/subscribe) but NOTHING ever fed the hub in production. B8 shipped engine+hub+control+manually-driven E2E, never the live session→engine feed. So today `spt endpoint digest` always resolves "no live digest".

So T7b = retire + a FRESH log-sourced wire, not replace-a-live-path (byte-feed removes cleanly, nothing live depends on it). QUESTION: for G3, is the bar a TOPOLOGY-INDEPENDENT SNAPSHOT-PULL (daemon projects from the endpoint's records on-demand at the snapshot handler — proven by the CC-fixture E2E, matching the pre-M9 wiring level), with the delta-stream LIVE-NUDGE (file-watch→publish, brand-new work that never existed) deferred to the frontend milestone that actually consumes the stream? Or do you want the live nudge in M9?

My lean: snapshot-pull on-demand projection for G3 (it's the topology-independence proof + REQ-TERM-4 int evidence); defer live-nudge to the consuming milestone — keeps Wave3 a retire+re-found, not a new-realtime-subsystem build. Your call. Holding T7b on your ruling. (Also: still owe the T5-twohost-logged-on-REQ-MSG-6 side-check — will do in-tree.)
EOF

Flag doyle T7a done + T7b scope question


➜ SENT:doyle


Commit the status update, then report:


cd C:/Users/decid/Documents/projects/spt-core && git add M9-WAVE3-JIT.md && git commit -q -m "$(cat <<'EOF'
docs(m9): Wave3 status — T7a landed; T7b scope discovery (byte-feed never live-wired)

Records T7a commits (projection primitive + digest-entry door, both green) and
the discovery that gates T7b: Brain::run_digest_feed has no production caller —
the PTY byte-feed was a built-but-never-live-wired primitive (B8 shipped
engine+hub+control+E2E, never the live session feed). T7b is therefore a retire
plus a fresh log-sourced wire, not a replace-a-live-path. Open scope question
flagged for doyle: snapshot-pull on-demand projection as the G3 bar vs. wiring
the delta-stream live-nudge in M9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EOF
)" && echo done && git log --oneline -5

Commit Wave3 status + discovery


warning: in the working copy of 'M9-WAVE3-JIT.md', LF will be replaced by CRLF the next time Git touches it
done
76ff4a3 docs(m9): Wave3 status — T7a landed; T7b scope discovery (byte-feed never live-wired)
6033326 feat(api): digest-entry push door for log-less adapters (T7a)
82ead73 feat(term): session-digest log projection primitive + published record contract (T7a)
714f6b8 docs(m9): Wave 3 / G3 JIT plan — digest log-projection (doyle-vetted)
58f034f docs(m9): log T5 twohost honored-path int as required-before-lecturn (gate condition)


Status report:

**Plan → doyle-vetted → T7a landed.** Branch `m9-digest-logs` (off M9 stack tip).

Commits (all green, traceable EXIT=0):
- `714f6b8` plan (doyle GREENLIT, §2 ruled)
- `82ead73` **T7a** — `spt-term::projection`: published digest-record contract + `project_lines` + 8 units
- `6033326` **T7a** — `api digest-entry` door + separate `digest.log` store + units
- `76ff4a3` status/discovery

**doyle's §2 sharpening folded in:** digest-record contract is a *published* surface (one schema, no mapping layer); history stays opaque; stray fixture migrates.

**Discovery (flagged doyle, holding T7b):** the PTY byte-feed (`run_digest_feed`) has **no production caller** — never live-wired. So `spt endpoint digest` resolves "no live digest" today. T7b = retire + fresh log-sourced wire, not replace-a-live-path.

**Scope question to doyle (his judgment, per your steer):** G3 bar = topology-independent snapshot-pull (on-demand projection, proven by CC-fixture E2E) with delta-stream live-nudge deferred to the consuming frontend milestone? My lean: yes — keeps Wave3 a re-foundation, not a new realtime subsystem. Awaiting his ruling, then execute T7b.

T7b is one atomic commit (engine re-core + retire + REQ-TERM-4 re-point + CC E2E + MANIFEST/CONTEXT). Standing by for doyle.


<task-notification>
<task-id>bmby1p3hm</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/9" id="029cae80" type="msg" from="doyle">[T7b scope — doyle ruling] Your lean APPROVED: snapshot-pull on-demand projection IS the G3 bar. Defer the autonomous live-nudge. Reasoning + guardrails:

VERIFIED your discovery independently: `run_digest_feed` grep = def (brain.rs:760) + one doc-ref (digest.rs:219), zero production callers. So byte-feed retire</EVENT-PART>
<EVENT-PART seq="2/9" id="029cae80">s cleanly, nothing live regresses. Confirmed, not just trusted.

WHY snapshot-pull is the right G3 bar:
- It's CONTEXT-CONSISTENT, not a scope cut. CONTEXT.md:338 already says near-realtime tailing &quot;needs an incremental normalize story (design-phase concern)&quot; — the staging is ANTICIPATED in the normative model. Deferring t</EVENT-PART>
<EVENT-PART seq="3/9" id="029cae80">he file-watch nudge is honoring CONTEXT, not deviating from it.
- The delta-stream CONTRACT still ships: the control channel + DigestUpdate + common_prefix_len + follow/subscribe machinery all SURVIVE (§3 keep). A subscriber can follow and receive structured deltas — they're driven by pull/digest-entry-push publishes rather than autonomo</EVENT-PART>
<EVENT-PART seq="4/9" id="029cae80">us file-watch. The stream is real; only its autonomous freshness source is deferred.
- The deferred piece has NO CONSUMER yet (Shell=M10, GUI/frontend pane, Gateway agent-window — none exist). Building file-watch→publish realtime infra now = speculative subsystem with nothing reading it. That's exactly your §9 &quot;don't expand scope </EVENT-PART>
<EVENT-PART seq="5/9" id="029cae80">— no new realtime subsystem&quot; watch-item. YAGNI until the consuming milestone activates it.
- Snapshot-pull is the actual FIX to the gap you found: on-demand projection at the handler means `spt endpoint digest` returns real projected content instead of &quot;no live digest.&quot; Functional, not a stub. And it's the REQ-TERM-4 int ev</EVENT-PART>
<EVENT-PART seq="6/9" id="029cae80">idence (CC-fixture E2E, topology-independent). Sufficient.

GUARDRAILS (so REQ-TERM-4 stays honest):
1. RETITLE must NOT overclaim near-realtime live tailing. Honest scope = &quot;projection of normalized session logs, snapshot-pull (`spt endpoint digest`) + structured-delta-stream contract + `api digest-entry` push, opt-in Path-B log</EVENT-PART>
<EVENT-PART seq="7/9" id="029cae80">.&quot; The delta-stream is the CONTRACT/machinery, present and exercised — fine to name. Just don't claim autonomous near-realtime freshness you didn't wire.
2. The deferred autonomous file-watch→publish nudge = TRACKED, NOT DELETED. Log it like the T5 honored-path pattern: a CONTEXT.md:338 implementation-note + a ROADMAP/DEFERRED line</EVENT-PART>
<EVENT-PART seq="8/9" id="029cae80"> naming the consuming milestone that activates it. Don't let it evaporate.
3. Name the deferred thing PRECISELY in the G3 note: &quot;autonomous file-watch freshness deferred to consuming-frontend milestone; snapsh
...(truncated)</event>
</task-notification>


<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>


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


doyle ruled: **snapshot-pull on-demand projection IS the G3 bar; defer autonomous live-nudge.** Got parts 1-8 (part 9 truncated — guardrail 3 wording). Guardrails captured:
1. Retitle must NOT overclaim near-realtime; delta-stream = contract/machinery (present, exercisable) — fine to name, but no autonomous-freshness claim.
2. Deferred file-watch nudge = TRACKED not deleted (CONTEXT.md:338 impl-note + ROADMAP/DEFERRED line naming the consuming milestone).
3. Name the deferred thing precisely in the G3 note.

Now the decisive T7b architecture question: where does on-demand projection live? It needs `fetch_history` (spt-live) + the perch's `digest.log`. Dep direction decides. Ack doyle + check.


cd C:/Users/decid/Documents/projects/spt-core && $OWL send doyle todlando <<'EOF'
[ACK T7b ruling] Locked: snapshot-pull on-demand projection = G3 bar; autonomous live-nudge DEFERRED + TRACKED (CONTEXT.md:338 impl-note + ROADMAP/DEFERRED line naming consuming-frontend milestone — same pattern as T5 honored-path). Retitle stays honest: "projection of normalized session logs, snapshot-pull + structured-delta-stream CONTRACT + api digest-entry push, opt-in Path-B log" — names the delta machinery (present+exercised) without claiming autonomous near-realtime freshness. Got your parts 1-8; part 9 truncated mid-guardrail-3 ("snapsh...") — I have the intent (name the deferred piece precisely in the G3 note); resend only if there's more substance past the wording. Starting T7b now.
EOF

Ack doyle T7b ruling


cd C:/Users/decid/Documents/projects/spt-core && echo "===spt-daemon deps===" && grep -E "spt-(live|term|runtime|store)" crates/spt-daemon/Cargo.toml && echo "===spt-live deps===" && grep -E "spt-(daemon|term|runtime|store)" crates/spt-live/Cargo.toml

Check dep direction daemon vs live


===spt-daemon deps===
description = "spt-core broker/brain daemon (M3b): the per-machine supervisor process. A stable broker kernel holds the un-transferable resources (PTY masters from spt-term, harness children, sockets) while a restartable brain holds all logic; a versioned local IPC lets a newer brain talk to an older broker so a self-update swaps the brain with zero endpoint interruption (ADR-0004). Internal supervisor (NOT public SDK, R-ARCH-2) — the `spt` binary is a thin CLI over it. B0 lands the crate + the versioned local-IPC transport."
# Layer-below set (R-ARCH-1 acyclic): …→spt-live→spt-daemon→spt. The broker
# re-hosts spt-term PTYs + the spt-live lifecycle seams; these edges fix the
spt-store = { path = "../spt-store" }
spt-runtime = { path = "../spt-runtime" }
spt-term = { path = "../spt-term" }
spt-live = { path = "../spt-live" }
# mirroring how spt-term abstracts ConPTY vs forkpty behind SessionSurface.
# workspace's existing libc usage (spt, spt-msg, spt-store); the Windows half
spt-runtime = { path = "../spt-runtime" }
spt-store = { path = "../spt-store" }
===spt-live deps===
description = "spt-core live-agent lifecycle executor (M2b): the Psyche spawn/resume + history + echo-commune + drop-file ingest + signoff/boundary ordering seams that M2a's manifest only declared. Fifth layer; daemon-bound (NOT public SDK) — M3 consolidates it into spt-daemon."
spt-store = { path = "../spt-store" }
spt-runtime = { path = "../spt-runtime" }


Dep direction confirmed: `…→spt-live→spt-daemon→spt`. **spt-daemon deps spt-live** — so the daemon's snapshot handler CAN call `fetch_history` and project on-demand. doyle's model fits.

T7b is one big atomic commit (engine re-core + on-demand projector + retire + CC E2E + REQ re-point + docs). Build incrementally, commit atomically. First, mirror how `fetch_history` callers assemble the manifest + keys.


<owl_messages>
<owl_message from="doyle" priority="high">[Confirm — go] Part 9 carried NO substance past what you have. Full guardrail 3 was just: G3 note says "autonomous file-watch freshness deferred to consuming-frontend milestone; snapshot-pull + push-driven deltas delivered" — NOT "delta-stream deferred" (the contract ships). You captured the intent exactly. No resend.

Retitle wording is honest and locked. Execute T7b. Ping at G3 CI-green.

(Owl chunking truncated my last two sends mid-tail — noting it; not blocking. If a future msg looks cut, assume the tail was wording-polish unless I flag otherwise.)</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.


crates\spt-live\src\history.rs:1://! History subsystem (M2b T1) — transcript access behind the `[history]`
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-2-//! manifest section. Feeds the echo-commune (T3) and the resume briefs (T7).
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-3-//!
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-57-        match self {
crates\spt-live\src\history.rs-58-            HistoryError::MissingField { strategy, field } => {
crates\spt-live\src\history.rs:59:                write!(f, "[history] strategy {strategy:?} requires `{field}`")
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-60-            }
crates\spt-live\src\history.rs-61-            HistoryError::Runtime(e) => write!(f, "history command failed: {e}"),
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-76-}
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-77-
crates\spt-live\src\history.rs:78:/// Fetch + normalize the perch's history via the manifest's `[history]`
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-79-/// strategy. `keys` supplies the substitution values (`session_id`, etc.) for
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-80-/// the `fetcher` / `locate_template` / `normalize_command` templates; `id` +
--
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-82-/// killed (never an indefinite block); a missing native log yields *no* records
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-83-/// (a fresh perch), not an error.
crates\spt-live\src\history.rs:84:pub fn fetch_history(
crates\spt-live\src\history.rs-85-    history: &History,
crates\spt-live\src\history.rs-86-    keys: &BTreeMap<String, String>,
--
crates\spt-live\src\history.rs-89-    timeout: Duration,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-90-) -> Result<Vec<HistoryRecord>, HistoryError> {
crates\spt-live\src\history.rs:91:    match history.strategy {
crates\spt-live\src\history.rs-92-        HistoryStrategy::Fetcher => {
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-93-            let cmd = history
--
crates\spt-live\src\history.rs-205-            normalize_command: None,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-206-        };
crates\spt-live\src\history.rs:207:        let recs = fetch_history(
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-208-            &h,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-209-            &no_keys(),
--
crates\spt-live\src\history.rs-230-            normalize_command: Some(passthrough().to_string()),
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-231-        };
crates\spt-live\src\history.rs:232:        let recs = fetch_history(
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-233-            &h,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-234-            &no_keys(),
--
crates\spt-live\src\history.rs-254-                normalize_command: None,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-255-            };
crates\spt-live\src\history.rs:256:            let recs = fetch_history(
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-257-                &h,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-258-                &no_keys(),
--
crates\spt-live\src\history.rs-276-                normalize_command: None,
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-277-            };
crates\spt-live\src\history.rs:278:            let recs = fetch_history(
C:\Users\decid\Documents\projects\spt-core\crates\spt-live\src\history.rs-279-                &h,

[Showing results with pagination = limit: 50]


doyle high-priority ACK: part 9 had no extra substance; G3 note wording locked — *"autonomous file-watch freshness deferred to consuming-frontend milestone; snapshot-pull + push-driven deltas delivered"* (NOT "delta-stream deferred" — the contract ships). Retitle locked. Owl truncation noted. Acknowledging before continuing.


cd C:/Users/decid/Documents/projects/spt-core && $OWL send doyle todlando <<'EOF'
[ACK — go] Got it, exact G3-note wording locked: "autonomous file-watch freshness deferred to consuming-frontend milestone; snapshot-pull + push-driven deltas delivered" (delta-stream CONTRACT ships, not deferred). Retitle locked. Noted the owl chunk-truncation pattern — I'll assume cut tails are wording-polish unless you flag. Executing T7b; ping at G3 CI-green.
EOF

Ack doyle go


crates\spt-live\src\lib.rs:44:pub use history::{fetch_history, HistoryError, HistoryRecord};
crates\spt-daemon\src\lifecycle.rs:24:    fetch_history, graceful_signoff, ingest_drops, run_echo_commune, spawn_psyche,
crates\spt-daemon\src\lifecycle.rs:308:        fetch_history(


`spt-daemon/src/lifecycle.rs:308` already calls `fetch_history` (echo-commune path) — the daemon already resolves manifest + keys. Read it to mirror exactly.


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());
300	        }
301	    }
302
303	    /// Fetch history per the manifest `[history]` strategy (empty if none).
304	    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
305	        let Some(h) = &self.manifest.history else {
306	            return Vec::new();
307	        };
308	        fetch_history(
309	            h,
310	            &self.base_keys(session_id),
311	            &self.id,
312	            ParentHint::Infer,
313	            LIVE_BOUNDED_TIMEOUT,
314	        )
315	        .unwrap_or_default()
316	    }
317
318	    /// Apply a resting-state event for this endpoint (D9-2, REQ-INST-3) — the
319	    /// production transition host wiring [`crate::resting::apply_event`]'s two
320	    /// effect seams to the real machinery:
321	    ///
322	    /// - **active → (dormant|suspended)**: the transition echo commune
323	    ///   ([`fire_echo`](Self::fire_echo) → [`run_echo_commune`]) capturing the
324	    ///   outgoing instance's final context delta, BEFORE the flip persists
325	    ///   (KH 3.3) — it syncs to whichever instance activates next.
326	    /// - **wake (→ active)**: re-activation in place catches up immediately —
327	    ///   [`crate::notif::resurface_at_boundary`] at the Wake boundary (the D8
328	    ///   deferral, real now that the edge exists) + the freshness-pull marker
329	    ///   the peer pump consumes (the D9-1 seam: pull the latest mind NOW, not
330	    ///   at the next anti-entropy cadence).
331	    ///
332	    /// Registry advertisement follows on the pump's next `advertise_local`
333	    /// scan — the persisted record is what it reads, epoch-bumped per row.
334	    // [impl->REQ-INST-3]
335	    // [impl->REQ-INST-4]
336	    pub fn rest_event(
337	        &self,
338	        session_id: Option<&str>,
339	        event: crate::resting::RestEvent,
340	    ) -> Result<Option<crate::resting::EdgeReport>, String> {
341	        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
342	        let report = crate::resting::apply_event(
343	            &perch_path,
344	            event,
345	            self.cfg.auto_suspend_after_ms,
346	            now_ms(),
347	            || self.fire_echo(session_id),
348	            || self.fire_wake_effects(),
349	        )?;
350	        // The shell leg of the edge (M5-D4a): suspend closes online shells,
351	        // wake relaunches persistent ones — same cascade as the daemon-side
352	        // host, so the two transition hosts can never drift.
353	        // [impl->REQ-SHELL-2]
354	        if let Some(r) = report {
355	            crate::resting::cascade_shells_on_edge(&self.id, &r);
356	            // A real rest edge changes the advertised state — wake the
357	            // pump's registry leg (M8 decision 15, REQ-CONV-2; the same
358	            // drop as the daemon-side host, so the two hosts never drift).
359	            // [impl->REQ-CONV-2]

        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());
        }
    }

    /// Fetch history per the manifest `[history]` strategy (empty if none).
    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
        let Some(h) = &self.manifest.history else {
            return Vec::new();
        };
        fetch_history(
            h,
            &self.base_keys(session_id),
            &self.id,
            ParentHint::Infer,
            LIVE_BOUNDED_TIMEOUT,
        )
        .unwrap_or_default()
    }

    /// Apply a resting-state event for this endpoint (D9-2, REQ-INST-3) — the
    /// production transition host wiring [`crate::resting::apply_event`]'s two
    /// effect seams to the real machinery:
    ///
    /// - **active → (dormant|suspended)**: the transition echo commune
    ///   ([`fire_echo`](Self::fire_echo) → [`run_echo_commune`]) capturing the
    ///   outgoing instance's final context delta, BEFORE the flip persists
    ///   (KH 3.3) — it syncs to whichever instance activates next.
    /// - **wake (→ active)**: re-activation in place catches up immediately —
    ///   [`crate::notif::resurface_at_boundary`] at the Wake boundary (the D8
    ///   deferral, real now that the edge exists) + the freshness-pull marker
    ///   the peer pump consumes (the D9-1 seam: pull the latest mind NOW, not
    ///   at the next anti-entropy cadence).
    ///
    /// Registry advertisement follows on the pump's next `advertise_local`
    /// scan — the persisted record is what it reads, epoch-bumped per row.
    // [impl->REQ-INST-3]
    // [impl->REQ-INST-4]
    pub fn rest_event(
        &self,
        session_id: Option<&str>,
        event: crate::resting::RestEvent,
    ) -> Result<Option<crate::resting::EdgeReport>, String> {
        let perch_path = perch::resolve_perch_path(&self.id, ParentHint::Infer);
        let report = crate::resting::apply_event(
            &perch_path,
            event,
            self.cfg.auto_suspend_after_ms,
            now_ms(),
            || self.fire_echo(session_id),
            || self.fire_wake_effects(),
        )?;
        // The shell leg of the edge (M5-D4a): suspend closes online shells,
        // wake relaunches persistent ones — same cascade as the daemon-side
        // host, so the two transition hosts can never drift.
        // [impl->REQ-SHELL-2]
        if let Some(r) = report {
            crate::resting::cascade_shells_on_edge(&self.id, &r);
            // A real rest edge changes the advertised state — wake the
            // pump's registry leg (M8 decision 15, REQ-CONV-2; the same
            // drop as the daemon-side host, so the two hosts never drift).
            // [impl->REQ-CONV-2]


Precedent found: `LiveInstance::history()` resolves `manifest.history` + `base_keys` + id → `fetch_history`. The daemon snapshot handler needs the same from just an `id`. Let me see `base_keys` + how a `LiveInstance`/manifest is resolved from an id, to replicate the on-demand projector faithfully.


Grep

fn base_keys|struct LiveInstance|impl LiveInstance|fn new\(|manifest:|pub fn resolve.*Instance|fn for_endpoint|fn load.*manifest|session_id|adapter_name


69:    manifest: Manifest,
79:    pub fn new_opt(manifest: Option<&Manifest>, id: &str) -> Option<BrainLifecycle> {
85:    pub fn with_config(manifest: &Manifest, id: &str, cfg: DaemonConfig) -> Option<BrainLifecycle> {
103:            manifest: manifest.clone(),
114:    fn base_keys(&self, session_id: Option<&str>) -> BTreeMap<String, String> {
117:        if let Some(sid) = session_id {
118:            k.insert("session_id".to_string(), sid.to_string());
155:        session_id: Option<&str>,
161:            &self.base_keys(session_id),
195:    pub fn pulse_tick(&self, session_id: Option<&str>) -> Result<TickReport, String> {
210:                    session_id,
229:            self.fire_echo(session_id)?;
262:        session_id: Option<&str>,
269:        let key = session_id.unwrap_or("pulse");
290:            match self.pulse_tick(session_id) {
304:    fn history(&self, session_id: Option<&str>) -> Vec<HistoryRecord> {
310:            &self.base_keys(session_id),
338:        session_id: Option<&str>,
347:            || self.fire_echo(session_id),
374:    fn fire_echo(&self, session_id: Option<&str>) -> Result<(), String> {
378:        let history = self.history(session_id);
382:            &self.base_keys(session_id),
397:    pub fn graceful_signoff(&self, session_id: Option<&str>) -> Result<SignoffOutcome, String> {
398:        self.signoff_with(session_id, Duration::ZERO, || true)
419:        session_id: Option<&str>,
422:        self.signoff_with(session_id, self.cfg.crash_grace, still_gone)
433:        session_id: Option<&str>,
440:            session_id,
443:                if let Err(e) = self.fire_echo(session_id) {
505:        session_id: Option<&str>,
518:                    self.supervised_crash_signoff(session_id, || self.harness_owner_gone()),
549:    // A live-capable manifest: a Psyche init template + a commune dir to watch.


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) {
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).

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) {
                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).


`base_keys` = `{id, session_id}` — simple. The crux: the daemon snapshot handler has only `id`; to resolve `[history]` it needs `adapter_name` → manifest. Does `info.json` carry `adapter_name` + `session_id`?


pub struct InfoJson|adapter_name|pub session_id|pub id|pub state|pub fn read_info|fn new\(


38:pub struct InfoJson {
40:    pub id: String,
46:    pub session_id: String,
48:    pub state: String,
128:    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
258:pub fn read_info(perch_path: &Path) -> Option<InfoJson> {


38	pub struct InfoJson {
39	    /// The perch / agent id.
40	    pub id: String,
41	    /// Creation timestamp (opaque string; producer's format).
42	    pub started: String,
43	    /// Owning process pid, or the `"BUSY"` sentinel.
44	    pub pid: PidValue,
45	    /// Harness session id bound to this perch.
46	    pub session_id: String,
47	    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
48	    pub state: String,
49	    #[serde(skip_serializing_if = "Option::is_none")]
50	    pub parent_pid: Option<u32>,
51	    /// Working directory the perch was created in (forward-slash normalized).
52	    #[serde(skip_serializing_if = "Option::is_none")]
53	    pub cwd: Option<String>,
54	    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
55	    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
56	    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
57	    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
58	    /// the per-pid probe. The single liveness resolver
59	    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
60	    #[serde(skip_serializing_if = "Option::is_none")]
61	    pub status: Option<String>,
62	    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
63	    /// endpoint's activity (pulse tick / input / bind). The recency signal the
64	    /// consent gate resolves "most-recently-active live session" against
65	    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
66	    /// stamped (an interim perch, or one with no activity yet); a resolver treats
67	    /// that as least-recent.
68	    #[serde(skip_serializing_if = "Option::is_none")]
69	    pub last_active_ms: Option<u64>,
70	    /// The node currently remote-driving this endpoint's session (hex node id),
71	    /// stamped by the daemon when it serves a net-attached surface and cleared
72	    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
73	    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
74	    /// so a pre-D5c record (no field) still parses.
75	    #[serde(default, skip_serializing_if = "Option::is_none")]
76	    pub driven_by: Option<String>,
77	    /// The daemon-owned resting state (D9-2, REQ-INST-3): `"active"` /
78	    /// `"dormant"` / `"suspended"` — the explicit instance state machine's
79	    /// durable record (`spt_daemon::resting` owns the transitions; this field
80	    /// is only ever written through [`set_rest_state`]). Distinct from
81	    /// `status`: `status` is daemon **liveness** (online/offline, KH 2.5);
82	    /// `rest_state` is the *resting model* of a live instance. Absent ⇒ a
83	    /// pre-D9-2 / interim record (readers fall back to liveness-derived
84	    /// status). `default` so older records still parse.
85	    #[serde(default, skip_serializing_if = "Option::is_none")]
86	    pub rest_state: Option<String>,
87	    /// When the instance entered dormancy (epoch ms) — the anchor the opt-in
88	    /// `auto-suspend-after` threshold counts from (REQ-INST-3: counted from
89	    /// dormancy onset, never from last activity). Present iff `rest_state`
90	    /// is `"dormant"`; cleared on every other transition.
91	    #[serde(default, skip_serializing_if = "Option::is_none")]
92	    pub dormant_since_ms: Option<u64>,
93	    /// Per-endpoint `auto-suspend-after` override (ms) — the endpoint leg of
94	    /// the global → node → endpoint knob chain (REQ-INST-3). Absent ⇒ inherit
95	    /// the node knob (`daemon.json`); `0` ⇒ explicitly OFF for this endpoint
96	    /// even when the node enables it.
97	    #[serde(default, skip_serializing_if = "Option::is_none")]
98	    pub auto_suspend_after_ms: Option<u64>,
99	    /// The endpoint-authored resource-advertisement blurb (D9-4,
100	    /// REQ-INST-14): the agent's own "what I can serve" line, written via
101	    /// `spt resources set` ([`set_resources`]) and advertised into every
102	    /// visible subnet by the daemon's registry scan. Absent ⇒ the node-level
103	    /// `daemon.json` seed (if any) advertises instead — config seeds the
104	    /// default, the agent refines its own at runtime (CONTEXT §resource
105	    /// advertisement, both-authored).
106	    #[serde(default, skip_serializing_if = "Option::is_none")]
107	    pub resources: Option<String>,
108	    /// The endpoint's **immutable home subnet** (D9-5, REQ-INST-15 —
109	    /// ADR-0010): assigned at creation ([`crate::home::assign_home`]) or by
110	    /// first-join adoption ([`crate::home::adopt_for_unset`]), then never
111	    /// changed — **no setter exists**; `spt fork` is the cross-subnet move.
112	    /// Absent ⇒ local-only (unpaired at creation, awaiting first join) or a
113	    /// pre-D9-5 record. Creation seams must **carry this forward** on a
114	    /// re-bind (a revive rewrites `info.json`; home survives by contract).
115	    #[serde(default, skip_serializing_if = "Option::is_none")]
116	    pub home_subnet: Option<String>,
117	    /// The adapter chosen at creation (D9-5, REQ-INST-15 — the
118	    /// adapter-at-creation rule): recorded when a creation/bind path knows
119	    /// it, changed **only** via launch/resume-under-new (the bind paths are
120	    /// the sole writers; no standalone setter). Absent ⇒ created by an
121	    /// adapterless surface (`spt ready`) or a pre-D9-5 record.
122	    #[serde(default, skip_serializing_if = "Option::is_none")]
123	    pub adapter: Option<String>,
124	}
125
126	impl InfoJson {
127	    /// Construct a numeric-pid record.

pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,
    /// Working directory the perch was created in (forward-slash normalized).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Daemon-managed liveness status (`online`/`offline`) for **daemon-hosted**
    /// perches (Psyche, spt-hosted Self). Present ⇒ the daemon owns this perch's
    /// liveness and it is read authoritatively, **never** `is_process_alive(pid)`
    /// (KNOWN-HAZARDS 2.5). Absent ⇒ an external/interim perch whose liveness is
    /// the per-pid probe. The single liveness resolver
    /// ([`crate::liveness::is_perch_alive`]) is the only consumer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,
    /// Last-active wall-clock stamp (epoch ms), refreshed by the daemon on this
    /// endpoint's activity (pulse tick / input / bind). The recency signal the
    /// consent gate resolves "most-recently-active live session" against
    /// (REQ-UPD-4) — a v1 precursor to PresenceChannel dispatch. Absent ⇒ never
    /// stamped (an interim perch, or one with no activity yet); a resolver treats
    /// that as least-recent.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_active_ms: Option<u64>,
    /// The node currently remote-driving this endpoint's session (hex node id),
    /// stamped by the daemon when it serves a net-attached surface and cleared
    /// when the viewport ends (REQ-REACH-1: detection is a daemon fact, not an
    /// agent guess). Absent ⇒ locally driven (or never net-attached). `default`
    /// so a pre-D5c record (no field) still parses.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub driven_by: Option<String>,
    /// The daemon-owned resting state (D9-2, REQ-INST-3): `"active"` /
    /// `"dormant"` / `"suspended"` — the explicit instance state machine's
    /// durable record (`spt_daemon::resting` owns the transitions; this field
    /// is only ever written through [`set_rest_state`]). Distinct from
    /// `status`: `status` is daemon **liveness** (online/offline, KH 2.5);
    /// `rest_state` is the *resting model* of a live instance. Absent ⇒ a
    /// pre-D9-2 / interim record (readers fall back to liveness-derived
    /// status). `default` so older records still parse.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rest_state: Option<String>,
    /// When the instance entered dormancy (epoch ms) — the anchor the opt-in
    /// `auto-suspend-after` threshold counts from (REQ-INST-3: counted from
    /// dormancy onset, never from last activity). Present iff `rest_state`
    /// is `"dormant"`; cleared on every other transition.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dormant_since_ms: Option<u64>,
    /// Per-endpoint `auto-suspend-after` override (ms) — the endpoint leg of
    /// the global → node → endpoint knob chain (REQ-INST-3). Absent ⇒ inherit
    /// the node knob (`daemon.json`); `0` ⇒ explicitly OFF for this endpoint
    /// even when the node enables it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auto_suspend_after_ms: Option<u64>,
    /// The endpoint-authored resource-advertisement blurb (D9-4,
    /// REQ-INST-14): the agent's own "what I can serve" line, written via
    /// `spt resources set` ([`set_resources`]) and advertised into every
    /// visible subnet by the daemon's registry scan. Absent ⇒ the node-level
    /// `daemon.json` seed (if any) advertises instead — config seeds the
    /// default, the agent refines its own at runtime (CONTEXT §resource
    /// advertisement, both-authored).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<String>,
    /// The endpoint's **immutable home subnet** (D9-5, REQ-INST-15 —
    /// ADR-0010): assigned at creation ([`crate::home::assign_home`]) or by
    /// first-join adoption ([`crate::home::adopt_for_unset`]), then never
    /// changed — **no setter exists**; `spt fork` is the cross-subnet move.
    /// Absent ⇒ local-only (unpaired at creation, awaiting first join) or a
    /// pre-D9-5 record. Creation seams must **carry this forward** on a
    /// re-bind (a revive rewrites `info.json`; home survives by contract).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub home_subnet: Option<String>,
    /// The adapter chosen at creation (D9-5, REQ-INST-15 — the
    /// adapter-at-creation rule): recorded when a creation/bind path knows
    /// it, changed **only** via launch/resume-under-new (the bind paths are
    /// the sole writers; no standalone setter). Absent ⇒ created by an
    /// adapterless surface (`spt ready`) or a pre-D9-5 record.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub adapter: Option<String>,
}

impl InfoJson {
    /// Construct a numeric-pid record.


All feasibility confirmed: `info.json` carries `adapter` + `session_id`, spt-daemon deps spt-live (`fetch_history`). The on-demand projector is buildable from an `id` alone.

I'll stage T7b as two safe commits (each green, never un-evidenced):
- **Commit 3 (core):** re-core engine → on-demand projector, hub `project_and_publish` + reproject verb, handlers project on-demand, remove `run_digest_feed`, wire `digest-entry`→reproject, new CC-fixture E2E. Adds REQ-TERM-4 evidence; parser/PtyDigest/title untouched.
- **Commit 4 (retire+re-point):** delete parser + PtyDigest + `[pty_digest]`, retitle REQ-TERM-4, migrate fixture, docs. New evidence already in place → atomic, never un-evidenced.

Let me record this staging in the plan, then build. First the on-demand projector + engine re-core in `digest.rs`.


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 {

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 {


Now rewrite `spt-daemon/src/digest.rs` — retire the byte-feed engine, keep `DigestUpdate` + `common_prefix_len`, add the on-demand projector:


Write

//! The session-digest **log projection** — the daemon-side, on-demand projection
//! of an endpoint's live activity buffer from its normalized session logs
//! (ADR-0008 amendment, 2026-06-12; REQ-TERM-4 log-source re-foundation).
//!
//! ## On-demand projection, not a byte feed
//!
//! The superseded shape folded broker PTY bytes through an adapter-pattern parser
//! on every output chunk. The digest is now a **projection of the same normalized
//! history the echo-commune consumes** — so there is nothing to feed: a snapshot
//! pull (or a `digest-entry` push) **projects on demand** from the endpoint's
//! records via [`project_endpoint_digest`], and the hub publishes the result.
//! Render surfaces read the PTY (the raw byte ring, unchanged); content surfaces
//! read the logs.
//!
//! ## Sources (one per endpoint)
//!
//! - An adapter that declares `[history]` (Path A locate+normalize / fetcher, or
//!   Path B native store) → the projection tails those normalized records. This
//!   is the **topology-independent** path: a Claude-Code-hosted endpoint's
//!   per-session JSONL is Path A's worked example.
//! - A **log-less** adapter pushes digest-records directly via `api digest-entry`
//!   into the perch's `digest.log` → the projection reads that.
//!
//! The records carry the published digest-record contract
//! ([`spt_term::DigestRecord`]); the projection folds them into the structured
//! [`Digest`] (N-turn window, collapsed tool sprints, ~25-char arg truncation).
//!
//! ## Snapshot vs delta
//!
//! A projection is diffed against the hub's prior snapshot
//! ([`crate::digesthub::DigestHub::project_and_publish`]) to produce a
//! [`DigestUpdate`]: `from` is where the changed turns begin, and the subscriber
//! applies it by **truncating its view to `from`, then appending `turns`**. The
//! delta-stream **contract** ships; its deltas are driven by pull / digest-entry
//! publishes (autonomous file-watch freshness is deferred to the consuming
//! frontend milestone — CONTEXT.md "session digest").
// [impl->REQ-TERM-4]

use std::time::Duration;

use serde::{Deserialize, Serialize};

use spt_live::fetch_history;
use spt_store::perch::{self, ParentHint};
use spt_term::{Digest, DigestConfig, Turn};

/// Bound for the projection's history fetch (a `[history]` fetcher/normalize is a
/// subprocess — REQ-HAZARD-SUBPROCESS-TIMEOUT; a hang is killed, never blocks).
const DIGEST_FETCH_TIMEOUT: Duration = Duration::from_secs(30);

/// A structured digest change to push to a subscriber. Sent only when the digest
/// actually changed (so the stream carries deltas, not heartbeats).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DigestUpdate {
    /// Monotonic version of the digest after this change (starts at 1).
    pub version: u64,
    /// The window index where `turns` begins. Apply: truncate the view to `from`,
    /// then append `turns`. `from == 0` is a full replace (the base snapshot, or a
    /// window slide that invalidated old indices).
    pub from: usize,
    /// The changed turns starting at `from` (the whole window when `from == 0`).
    pub turns: Vec<Turn>,
}

/// Length of the equal-by-value prefix shared by two turn slices — the diff base
/// for a [`DigestUpdate`] (`from`). A pure in-place/extend keeps the head intact →
/// `from` points at the changed tail (a minimal delta); a window slide evicts the
/// oldest turn → the heads differ from index 0 → `from == 0` (a full refresh).
pub fn common_prefix_len(a: &[Turn], b: &[Turn]) -> usize {
    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}

/// Project an endpoint's current session digest on demand from its normalized
/// records. Resolves the endpoint's source (the `[history]` strategy by its
/// recorded adapter, else the perch's `digest.log`), reads the records, and folds
/// them through the published-contract projection. An endpoint with no records
/// (or no resolvable source) projects an empty [`Digest`] — never an error, never
/// a block.
// [impl->REQ-TERM-4]
pub fn project_endpoint_digest(id: &str, config: &DigestConfig) -> Digest {
    let lines = endpoint_digest_records(id);
    spt_term::project_lines(lines.iter().map(String::as_str), config)
}

/// The endpoint's current normalized digest-records as raw lines. Source order:
/// the adapter's `[history]` strategy when declared (the topology-independent
/// path — fetch + normalize), otherwise the perch's `digest.log` (the log-less
/// `api digest-entry` push sink). A fetch failure or a missing log yields no
/// records (a fresh / source-less endpoint), not an error.
fn endpoint_digest_records(id: &str) -> Vec<String> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    let info = spt_store::info::read_info(&perch_path);
    let session_id = info.as_ref().map(|i| i.session_id.clone());

    // Resolve the adapter's [history] strategy by the perch's recorded adapter.
    let history = info
        .as_ref()
        .and_then(|i| i.adapter.as_deref())
        .and_then(|adapter| {
            spt_runtime::registry::resolve_option(&perch::adapters_dir(), adapter)
                .ok()
                .and_then(|(_, m)| m.history)
        });

    if let Some(h) = history {
        let mut keys = std::collections::BTreeMap::new();
        keys.insert("id".to_string(), id.to_string());
        if let Some(sid) = session_id {
            keys.insert("session_id".to_string(), sid);
        }
        return fetch_history(&h, &keys, id, ParentHint::Infer, DIGEST_FETCH_TIMEOUT)
            .map(|recs| recs.into_iter().map(|r| r.raw).collect())
            .unwrap_or_default();
    }

    // Log-less adapter: the `api digest-entry` push sink.
    match std::fs::read_to_string(perch_path.join("digest.log")) {
        Ok(s) => s.lines().map(str::to_string).collect(),
        Err(_) => Vec::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use spt_term::DigestEntry;

    fn turn(input: &str) -> Turn {
        Turn {
            input: Some(input.to_string()),
            entries: Vec::new(),
        }
    }

    // [unit->REQ-TERM-4] the diff base: a shared head yields its length (a tail
    // delta); a divergent front yields 0 (a full refresh).
    #[test]
    fn common_prefix_len_finds_the_unchanged_head() {
        let a = vec![turn("a"), turn("b")];
        let grown = vec![turn("a"), turn("b"), turn("c")];
        assert_eq!(common_prefix_len(&a, &grown), 2, "shared head of two");
        let slid = vec![turn("b"), turn("c"), turn("d")];
        assert_eq!(common_prefix_len(&a, &slid), 0, "front eviction → 0");
    }

    // [unit->REQ-TERM-4] a source-less endpoint (no perch, no records) projects an
    // empty digest — never an error, never a block.
    #[test]
    fn projects_empty_for_a_sourceless_endpoint() {
        let _h = crate::test_support::isolated_home();
        let d = project_endpoint_digest("nobody", &DigestConfig::default());
        assert!(d.turns.is_empty());
    }

    // [unit->REQ-TERM-4] a log-less endpoint projects from its `digest.log` push
    // sink (the `api digest-entry` source) — input opens a turn, agent appends,
    // consecutive tools collapse.
    #[test]
    fn projects_a_log_less_endpoint_from_digest_log() {
        let _h = crate::test_support::isolated_home();
        let perch = perch::resolve_perch_path("solo", ParentHint::Infer);
        std::fs::create_dir_all(&perch).unwrap();
        // No adapter recorded ⇒ the digest.log path.
        let rec = spt_store::info::InfoJson::new("solo", "0", std::process::id(), "sid", "live_agent");
        spt_store::info::write_info(&perch, &rec).unwrap();
        for line in [
            r#"{"role":"input","text":"build it"}"#,
            r#"{"role":"agent","text":"on it"}"#,
            r#"{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}"#,
        ] {
            spt_store::history::append_digest_entry(&perch, line).unwrap();
        }
        let d = project_endpoint_digest("solo", &DigestConfig::default());
        assert_eq!(d.turns.len(), 1);
        assert_eq!(d.turns[0].input.as_deref(), Some("build it"));
        assert!(d.turns[0]
            .entries
            .iter()
            .any(|e| matches!(e, DigestEntry::ToolSprint(_))));
    }
}