1	# v0.13.0 P2 — `[session.resume]` template + per-session cwd (resume-blank fix; doyle 2026-06-19)
2
3	**Operator HITL:** picker/`--resume` brings up a BLANK session even when the resumed session has known conversation history. Builder: todlando (spt-core + public docs). Adapter follow-on: perri (spt-claude-code). Gate: doyle.
4
5	## Root (doyle, code-grounded + CONTEXT — case-3 missing feature, NOT a perri docs-miss)
6	1. **CONTEXT.md L127-129** already defines the *resume-session seam*, incl. *"continue-existing: resume an existing harness session under the adapter (**its native resume**)."* The design exists; the impl doesn't.
7	2. **The resume-variant pattern already exists in the manifest** — `Session` (manifest.rs:217-219) has BOTH `psyche_init` AND `psyche_resume`. But the agent's own session has only `self_` (`[session.self]`, manifest.rs:214) — **no resume sibling.**
8	3. `cmd_endpoint_run` (cli.rs:1304) re-passes the session_id through `[session.self]` on resume (`resume.unwrap_or_else(mint_session_id)`), so the adapter's FRESH command (`claude --session-id …`) runs again instead of CC's native resume (`claude -r …`) → CC starts a fresh transcript → blank. spt-core forwards session_id + cwd faithfully; it simply has no way to express the native-resume invocation.
9	4. CC resolves a transcript by **session_id + cwd**; the session ledger records only `{ts, session_id, trigger}` (no cwd), so picker Resume-from-history (cross-project rows) can't restore the right cwd even with the right id.
10
11	## Fix (mirrors the existing `psyche_init`→`psyche_resume` precedent exactly)
12
13	### A. `[session.resume]` manifest role (spt-core)
14	- Add `resume: Option<SessionRole>` to `Session` (manifest.rs) + its `roles()` enumeration + `is_empty()`, exactly like `psyche_resume`.
15	- `cmd_endpoint_run` / `prepare_harness_spawn`: when `--resume <session>` is set AND `[session.resume]` is declared → select the **resume** template (filled `{session_id}` = the resumed id, `{id}`, `{session_name}`, the resume cwd); else **fall back to `[session.self]`** (full back-compat — an adapter with no `[session.resume]` behaves exactly as today). Fresh bringup (no `--resume`) always uses `[session.self]`.
16	- Keys spt-core guarantees for `[session.resume]`: `{id}`, `{session_id}` (the resumed id), `{session_name}` (same catalog as `self`).
17
18	### B. Per-session cwd in the ledger (operator ruling: record cwd per row)
19	- Extend the session ledger row `{ts, session_id, trigger}` → `{ts, session_id, trigger, cwd}` (`spt_store::sessions`). Additive serde-default field (an old row missing `cwd` deserializes with `cwd: None`).
20	- Write cwd at each boundary append (Boot/Clear/Compact) from the session's project cwd (the `info.cwd` W3 set at bind, or the spawn cwd).
21	- **Resume cwd resolution (fallback chain):** the resumed ledger row's `cwd` → else the perch `info.cwd` → else `std::env::current_dir()` (today's behavior). So old (pre-migration) rows + single-project endpoints still work; multi-project endpoints now resume in the exact historical dir.
22	- Picker Resume-from-history (`picker/data.rs`, `picker/model.rs resume_outcome`): thread the selected row's `cwd` through `Outcome::Run` → `cmd_endpoint_run` (today it passes only session_id; add the cwd).
23
24	### C. Public docs (spt-core — so perri builds the adapter side BLIND)
25	- `docs/MANIFEST.md`: document `[session.resume]` (when it fires — `--resume`/picker Resume; the `{key}` catalog; the `[session.self]` fallback; cwd semantics). Mirror the `psyche_resume` doc.
26	- `docs/` harness-contract / patterns page: a short "resuming an existing harness session" note (native-resume command, e.g. a `-r <session_id>` form, launched from the session's cwd).
27	- `[doc->REQ-SESSION-RESUME-TEMPLATE]`.
28
29	## Adapter follow-on (perri, AFTER spt-core ships + docs)
30	Declare `[session.resume] command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"` (the operator's full-fat example), cwd from the resume context. perri builds this from the published docs once spt-core lands. This is a KNOWN spt-core gap (case-3) — NOT a perri miss; relay so she logs it and waits for the doc.
31
32	## D. Picker resume-row title reformat (operator, folds into this surface)
33	`ResumeRow::title()` (picker/model.rs:150-162) currently renders `{head} @ {ts} (…{id5})` with the RAW RFC3339-UTC `ts`. Change to a LOCAL-time, human format:
34	- **New:** `{head} - {local_time} (…{id5})` — separator ` @ ` → ` - `.
35	- `{local_time}` = `HH:MM<AM|PM> <TZ> MM-DD-YYYY` (12-hour, AM/PM, local tz abbreviation, then the date). Example: `myproject - 03:45PM PST 06-19-2026 (…7fb10)`.
36	- Impl: parse `self.ts` via `chrono::DateTime::parse_from_rfc3339` → `.with_timezone(&chrono::Local)` → `format("%I:%M%p")` + tz + `format("%m-%d-%Y")` (chrono `clock` feature already on in `crates/spt/Cargo.toml`).
37	- **TZ caveat (todlando resolve):** chrono `%Z` on Windows can emit a long name ("Pacific Standard Time"), not "PST". Prefer a short abbreviation; acceptable fallback = the numeric offset (`%z` → `-0800`) if no clean abbreviation is available. Surface the chosen form at HITL for operator confirm.
38	- Parse-failure fallback: keep the raw `ts` (best-effort, never panic — the picker degrades, never crashes).
39	- Update the existing title unit (view.rs:581-599 / the model unit): assert the new shape (local-time fields + `…id5` + ` - ` separator), not the old `@ <rfc3339>`.
40
41	## REQ + gates
42	- **Mint `REQ-SESSION-RESUME-TEMPLATE`** (registry-first), stages **doc, impl, unit, int**. References REQ-READY-AGENT-RESUME / REQ-RUN-PICKER (the resume-from-history offer) which it completes.
43	- unit: manifest parse of `[session.resume]` (+ roundtrip, is_empty/roles); the resume-vs-self SELECTION (resume set + declared ⇒ resume template; absent ⇒ self fallback); the cwd fallback chain (row cwd → info.cwd → current_dir); ledger row serde back-compat (old row, no cwd, deserializes).
44	- int: a dummy-harness whose `[session.resume]` command differs from `[session.self]` — a `--resume <id>` bringup runs the RESUME command (assert via a marker the resume command emits) in the recorded cwd; a fresh bringup runs `[session.self]`. (Real spawn path; the dummy harness echoes which template + cwd it got.)
45	- clippy --workspace -D warnings = 0; traceable EXIT=0; docs-drift xtask check (MANIFEST/contract regen).
46
47	## doyle gate criteria
48	`[session.resume]` parsed + selected only on resume + `[session.self]` fallback intact · cwd recorded per ledger row + the fallback chain · picker threads the row cwd · back-compat (no-`[session.resume]` adapter + old ledger rows unchanged) · public docs teach the seam (perri-buildable) · clippy/traceable/docs-drift. Then operator HITL: picker Resume of a session with history reloads the transcript in its project dir.
49

# v0.13.0 P2 — `[session.resume]` template + per-session cwd (resume-blank fix; doyle 2026-06-19)

**Operator HITL:** picker/`--resume` brings up a BLANK session even when the resumed session has known conversation history. Builder: todlando (spt-core + public docs). Adapter follow-on: perri (spt-claude-code). Gate: doyle.

## Root (doyle, code-grounded + CONTEXT — case-3 missing feature, NOT a perri docs-miss)
1. **CONTEXT.md L127-129** already defines the *resume-session seam*, incl. *"continue-existing: resume an existing harness session under the adapter (**its native resume**)."* The design exists; the impl doesn't.
2. **The resume-variant pattern already exists in the manifest** — `Session` (manifest.rs:217-219) has BOTH `psyche_init` AND `psyche_resume`. But the agent's own session has only `self_` (`[session.self]`, manifest.rs:214) — **no resume sibling.**
3. `cmd_endpoint_run` (cli.rs:1304) re-passes the session_id through `[session.self]` on resume (`resume.unwrap_or_else(mint_session_id)`), so the adapter's FRESH command (`claude --session-id …`) runs again instead of CC's native resume (`claude -r …`) → CC starts a fresh transcript → blank. spt-core forwards session_id + cwd faithfully; it simply has no way to express the native-resume invocation.
4. CC resolves a transcript by **session_id + cwd**; the session ledger records only `{ts, session_id, trigger}` (no cwd), so picker Resume-from-history (cross-project rows) can't restore the right cwd even with the right id.

## Fix (mirrors the existing `psyche_init`→`psyche_resume` precedent exactly)

### A. `[session.resume]` manifest role (spt-core)
- Add `resume: Option<SessionRole>` to `Session` (manifest.rs) + its `roles()` enumeration + `is_empty()`, exactly like `psyche_resume`.
- `cmd_endpoint_run` / `prepare_harness_spawn`: when `--resume <session>` is set AND `[session.resume]` is declared → select the **resume** template (filled `{session_id}` = the resumed id, `{id}`, `{session_name}`, the resume cwd); else **fall back to `[session.self]`** (full back-compat — an adapter with no `[session.resume]` behaves exactly as today). Fresh bringup (no `--resume`) always uses `[session.self]`.
- Keys spt-core guarantees for `[session.resume]`: `{id}`, `{session_id}` (the resumed id), `{session_name}` (same catalog as `self`).

### B. Per-session cwd in the ledger (operator ruling: record cwd per row)
- Extend the session ledger row `{ts, session_id, trigger}` → `{ts, session_id, trigger, cwd}` (`spt_store::sessions`). Additive serde-default field (an old row missing `cwd` deserializes with `cwd: None`).
- Write cwd at each boundary append (Boot/Clear/Compact) from the session's project cwd (the `info.cwd` W3 set at bind, or the spawn cwd).
- **Resume cwd resolution (fallback chain):** the resumed ledger row's `cwd` → else the perch `info.cwd` → else `std::env::current_dir()` (today's behavior). So old (pre-migration) rows + single-project endpoints still work; multi-project endpoints now resume in the exact historical dir.
- Picker Resume-from-history (`picker/data.rs`, `picker/model.rs resume_outcome`): thread the selected row's `cwd` through `Outcome::Run` → `cmd_endpoint_run` (today it passes only session_id; add the cwd).

### C. Public docs (spt-core — so perri builds the adapter side BLIND)
- `docs/MANIFEST.md`: document `[session.resume]` (when it fires — `--resume`/picker Resume; the `{key}` catalog; the `[session.self]` fallback; cwd semantics). Mirror the `psyche_resume` doc.
- `docs/` harness-contract / patterns page: a short "resuming an existing harness session" note (native-resume command, e.g. a `-r <session_id>` form, launched from the session's cwd).
- `[doc->REQ-SESSION-RESUME-TEMPLATE]`.

## Adapter follow-on (perri, AFTER spt-core ships + docs)
Declare `[session.resume] command = "claude -r {session_id} --remote-control {id} --dangerously-skip-permissions"` (the operator's full-fat example), cwd from the resume context. perri builds this from the published docs once spt-core lands. This is a KNOWN spt-core gap (case-3) — NOT a perri miss; relay so she logs it and waits for the doc.

## D. Picker resume-row title reformat (operator, folds into this surface)
`ResumeRow::title()` (picker/model.rs:150-162) currently renders `{head} @ {ts} (…{id5})` with the RAW RFC3339-UTC `ts`. Change to a LOCAL-time, human format:
- **New:** `{head} - {local_time} (…{id5})` — separator ` @ ` → ` - `.
- `{local_time}` = `HH:MM<AM|PM> <TZ> MM-DD-YYYY` (12-hour, AM/PM, local tz abbreviation, then the date). Example: `myproject - 03:45PM PST 06-19-2026 (…7fb10)`.
- Impl: parse `self.ts` via `chrono::DateTime::parse_from_rfc3339` → `.with_timezone(&chrono::Local)` → `format("%I:%M%p")` + tz + `format("%m-%d-%Y")` (chrono `clock` feature already on in `crates/spt/Cargo.toml`).
- **TZ caveat (todlando resolve):** chrono `%Z` on Windows can emit a long name ("Pacific Standard Time"), not "PST". Prefer a short abbreviation; acceptable fallback = the numeric offset (`%z` → `-0800`) if no clean abbreviation is available. Surface the chosen form at HITL for operator confirm.
- Parse-failure fallback: keep the raw `ts` (best-effort, never panic — the picker degrades, never crashes).
- Update the existing title unit (view.rs:581-599 / the model unit): assert the new shape (local-time fields + `…id5` + ` - ` separator), not the old `@ <rfc3339>`.

## REQ + gates
- **Mint `REQ-SESSION-RESUME-TEMPLATE`** (registry-first), stages **doc, impl, unit, int**. References REQ-READY-AGENT-RESUME / REQ-RUN-PICKER (the resume-from-history offer) which it completes.
- unit: manifest parse of `[session.resume]` (+ roundtrip, is_empty/roles); the resume-vs-self SELECTION (resume set + declared ⇒ resume template; absent ⇒ self fallback); the cwd fallback chain (row cwd → info.cwd → current_dir); ledger row serde back-compat (old row, no cwd, deserializes).
- int: a dummy-harness whose `[session.resume]` command differs from `[session.self]` — a `--resume <id>` bringup runs the RESUME command (assert via a marker the resume command emits) in the recorded cwd; a fresh bringup runs `[session.self]`. (Real spawn path; the dummy harness echoes which template + cwd it got.)
- clippy --workspace -D warnings = 0; traceable EXIT=0; docs-drift xtask check (MANIFEST/contract regen).

## doyle gate criteria
`[session.resume]` parsed + selected only on resume + `[session.self]` fallback intact · cwd recorded per ledger row + the fallback chain · picker threads the row cwd · back-compat (no-`[session.resume]` adapter + old ledger rows unchanged) · public docs teach the seam (perri-buildable) · clippy/traceable/docs-drift. Then operator HITL: picker Resume of a session with history reloads the transcript in its project dir.


Strong design. Reading source surfaces I'll touch — parallel.


Grep

psyche_resume|psyche_init|self_|struct Session\b|fn roles|fn is_empty


crates\spt-runtime\src\manifest.rs:204:pub struct Session {
crates\spt-runtime\src\manifest.rs:215:    pub self_: Option<SessionRole>,
crates\spt-runtime\src\manifest.rs:217:    pub psyche_init: Option<SessionRole>,
crates\spt-runtime\src\manifest.rs:219:    pub psyche_resume: Option<SessionRole>,
crates\spt-runtime\src\manifest.rs:237:    pub fn is_empty(&self) -> bool {
crates\spt-runtime\src\manifest.rs:240:            && self.self_.is_none()
crates\spt-runtime\src\manifest.rs:241:            && self.psyche_init.is_none()
crates\spt-runtime\src\manifest.rs:242:            && self.psyche_resume.is_none()
crates\spt-runtime\src\manifest.rs:250:    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
crates\spt-runtime\src\manifest.rs:253:            ("self", &self.self_),
crates\spt-runtime\src\manifest.rs:254:            ("psyche_init", &self.psyche_init),
crates\spt-runtime\src\manifest.rs:255:            ("psyche_resume", &self.psyche_resume),
crates\spt-runtime\src\manifest.rs:469:    pub self_verifies: bool,
crates\spt-runtime\src\manifest.rs:586:    pub fn is_empty(&self) -> bool {
crates\spt-runtime\src\manifest.rs:602:    pub fn is_empty(&self) -> bool {
crates\spt-runtime\src\manifest.rs:957:[session.psyche_init]
crates\spt-runtime\src\manifest.rs:1033:        let role = m.session.psyche_init.as_ref().unwrap();
crates\spt-runtime\src\manifest.rs:1062:    // `delegated` avenue carries the `self_verifies` attest instead. Both shapes
crates\spt-runtime\src\manifest.rs:1082:        assert!(!u.self_verifies);
crates\spt-runtime\src\manifest.rs:1086:        // delegated with self_verifies attest → ok (no signing_key needed).
crates\spt-runtime\src\manifest.rs:1088:                     [update]\navenue=\"delegated\"\ncommand=\"claude plugin update\"\nself_verifies=true\n";
crates\spt-runtime\src\manifest.rs:1091:            md.update.unwrap().self_verifies,


495:id = "REQ-RUN-PICKER"
613:id = "REQ-READY-AGENT-RESUME"
1209:[Omitted long matching line]
1244:[Omitted long matching line]
1249:id = "REQ-HAZARD-PTY-INPUT-WRITER-WEDGE"
1255:id = "REQ-SESSION-RESUME-TEMPLATE"
1256:[Omitted long matching line]
1261:id = "REQ-RC-WIN-PASTE"
1263:[Omitted long matching line]


200
201	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
202	/// with the fixed set of `[session.<role>]` command templates.
203	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
204	pub struct Session {
205	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
206	    // only the directory is adapter-declared (MANIFEST §watched dirs).
207	    #[serde(default, skip_serializing_if = "Option::is_none")]
208	    pub commune_dir: Option<String>,
209	    #[serde(default, skip_serializing_if = "Option::is_none")]
210	    pub signoff_dir: Option<String>,
211	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
212	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
213	    // *executed* in M2b.
214	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
215	    pub self_: Option<SessionRole>,
216	    #[serde(default, skip_serializing_if = "Option::is_none")]
217	    pub psyche_init: Option<SessionRole>,
218	    #[serde(default, skip_serializing_if = "Option::is_none")]
219	    pub psyche_resume: Option<SessionRole>,
220	    #[serde(default, skip_serializing_if = "Option::is_none")]
221	    pub echo_commune: Option<SessionRole>,
222	    #[serde(default, skip_serializing_if = "Option::is_none")]
223	    pub signoff: Option<SessionRole>,
224	    /// `[session.notif]` — the endpoint-native notification render
225	    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
226	    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
227	    /// detached when a notif surfaces at this endpoint, combinable with the
228	    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
229	    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
230	    // [impl->REQ-NOTIF-2]
231	    #[serde(default, skip_serializing_if = "Option::is_none")]
232	    pub notif: Option<SessionRole>,
233	}
234
235	impl Session {
236	    /// True when no dirs and no roles are declared (skip-serialize helper).
237	    pub fn is_empty(&self) -> bool {
238	        self.commune_dir.is_none()
239	            && self.signoff_dir.is_none()
240	            && self.self_.is_none()
241	            && self.psyche_init.is_none()
242	            && self.psyche_resume.is_none()
243	            && self.echo_commune.is_none()
244	            && self.signoff.is_none()
245	            && self.notif.is_none()
246	    }
247
248	    /// All declared role templates, paired with their role name. Used by the
249	    /// runtime to enumerate spawnable roles.
250	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
251	        let mut out = Vec::new();
252	        for (name, role) in [
253	            ("self", &self.self_),
254	            ("psyche_init", &self.psyche_init),
255	            ("psyche_resume", &self.psyche_resume),
256	            ("echo_commune", &self.echo_commune),
257	            ("signoff", &self.signoff),
258	            ("notif", &self.notif),
259	        ] {
260	            if let Some(r) = role {
261	                out.push((name, r));
262	            }
263	        }
264	        out
265	    }
266	}
267
268	/// `[session.<role>]` — one opaque outbound command template plus its spawn
269	/// context. Model/tools/flags all live inside `command`, never as fields
270	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
271	/// emission scalar-before-table clean).
272	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
273	pub struct SessionRole {
274	    /// Opaque command line, with `{key}` substitution placeholders.

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
    /// `[session.notif]` — the endpoint-native notification render
    /// (ADR-0007's `notif_command` seam, REQ-NOTIF-2): an OS toast, a
    /// GameRobot `alert-symbol`, anything the adapter can run. Spawned
    /// detached when a notif surfaces at this endpoint, combinable with the
    /// agent-surface delivery. Keys spt-core fills: `{notif_id}`,
    /// `{notif_from}`, `{notif_subnet}`, `{notif_body}`.
    // [impl->REQ-NOTIF-2]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notif: Option<SessionRole>,
}

impl Session {
    /// True when no dirs and no roles are declared (skip-serialize helper).
    pub fn is_empty(&self) -> bool {
        self.commune_dir.is_none()
            && self.signoff_dir.is_none()
            && self.self_.is_none()
            && self.psyche_init.is_none()
            && self.psyche_resume.is_none()
            && self.echo_commune.is_none()
            && self.signoff.is_none()
            && self.notif.is_none()
    }

    /// All declared role templates, paired with their role name. Used by the
    /// runtime to enumerate spawnable roles.
    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
        let mut out = Vec::new();
        for (name, role) in [
            ("self", &self.self_),
            ("psyche_init", &self.psyche_init),
            ("psyche_resume", &self.psyche_resume),
            ("echo_commune", &self.echo_commune),
            ("signoff", &self.signoff),
            ("notif", &self.notif),
        ] {
            if let Some(r) = role {
                out.push((name, r));
            }
        }
        out
    }
}

/// `[session.<role>]` — one opaque outbound command template plus its spawn
/// context. Model/tools/flags all live inside `command`, never as fields
/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
/// emission scalar-before-table clean).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SessionRole {
    /// Opaque command line, with `{key}` substitution placeholders.


1245	required_stages = ["doc", "impl", "unit"]  # ACTIVATED v0.13.0 bug 2 (todlando, operator-ruled proper translator, ship-blocker; doyle gates). doc = KNOWN-HAZARDS + CONTEXT note (Windows rc translates console key events to standard xterm VT; Unix passes through). impl = rc.rs translate_key_event (pure xterm-VT table) + spawn_stdin_reader Windows cfg-split (tty→crossterm events / non-tty→byte fallback / Unix byte-pump unchanged) + event-sourced ctrl-b+'d' detach SM (Option B) + normalize_key_byte removed (Backspace/Ctrl+Backspace mapping relocated into translate_key_event). unit = exhaustive translate_key_event mapping (every arrow/Home/End/PgUp/PgDn/Del/Insert/F-key/modifier-combo/Ctrl+char/Backspace/Ctrl+Backspace asserts exact xterm bytes) + the event-detach SM (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated). HITL acceptance (operator) = real CC rc session: arrows/Home/End/PgUp/PgDn/Del/F-keys work, Backspace=char, Ctrl+Backspace=word, ctrl-b d detach works, typing smooth (W1b).
1246
1247	# ── P0 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): pasting into rc WEDGES the broker ──
1248	[[requirements]]
1249	id = "REQ-HAZARD-PTY-INPUT-WRITER-WEDGE"
1250	title = "Pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type AND can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). ROOT (doyle /diagnose, code-grounded): the operator-keystroke path rc -> net-stream Input -> serve_attach (attach.rs:197 brain.send_effect) -> KIND_INPUT -> broker dispatch loop (broker.rs:1091) -> dispatch_input (broker.rs:1459) -> session.write_input(&bytes) runs SYNCHRONOUSLY on the broker request-handling thread. W1b (REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE) released the journal lock across the effect (fix 1) + made PtyWrite ephemeral/no-fsync (fix 3) but EXPLICITLY DEFERRED fix (2) — bound/fail-fast the PtyWrite itself. A single keystroke never fills the ConPTY input buffer; a PASTE BURST does -> write_input blocks -> the dispatch thread cannot service the next frame (a re-attach subscribe, a become_controller restore-write, an inject-floor flush) -> wedge. Not a bug-2 regression (the byte path funnels to the same write_input; paste just reliably fills the buffer). FIX (doyle design, V0.13.0-P0-PTY-INPUT-WRITER-DESIGN.md, CONTEXT L33 broker-owns-PTY/minimal + L435 SessionSurface + single-writer pattern): one dedicated per-session INPUT-WRITER THREAD = the SOLE caller of the blocking write_input, fed by a BOUNDED FIFO channel; every caller (dispatch_input, serve_attach->send_effect, inject-floor flush) ENQUEUES + returns immediately, never blocks. A blocked/slow harness blocks ONLY its own writer thread, never the broker dispatch. Backpressure (operator ruling): queue full => DROP excess input + stamp the session INPUT_BACKPRESSURE (visible health signal); the daemon NEVER wedges; a merely-slow harness self-heals as the writer drains. Exactly-once preserved (PtyWrite ephemeral: apply_once effect = the non-blocking enqueue => Applied; ack now means accepted+ordered, benign — rc does not gate on landing); order preserved (single FIFO + single writer); inject-floor (W2 Layer C) choreography moves to the lone writer. Completes the W1b-deferred fix (2), cross-platform (cfg(unix) forkpty park folds in). (v0.13.0)"
1251	required_stages = []  # SEED — doyle design committed (V0.13.0-P0-PTY-INPUT-WRITER-DESIGN.md); todlando ACTIVATES [impl,unit,int] registry-first as build step 1 (the active ship-blocker). PLANNED stages: impl = per-session input-writer thread (sole write_input caller) + bounded FIFO + try-enqueue at dispatch_input/serve_attach send_effect path/inject-floor flush + DROP+INPUT_BACKPRESSURE stamp on saturation + apply_once PtyWrite effect=enqueue. unit = writer drain order + bounded-queue drop/heal decision (injected channel, no wall-clock) + exactly-once enqueue (reserve->enqueue->Applied, dedup). int = REPRO-FIRST (W1b's int ESCAPED this — it relied on ConPTY absorbing the write, never an indefinite block): a real PTY consumer that STOPS draining so write_input genuinely parks (RED pre-fix: a concurrent rc attach gets brain IPC read deadline / no output) + GREEN post-fix (concurrent attach OPENS + RECEIVES output, operator keystroke still accepted, DROP+INPUT_BACKPRESSURE on saturation + heal on resume). Real broker+PTY+rc, NO mocks. cfg(unix) forkpty park leg covered. doc = KNOWN-HAZARDS 7.17 + CONTEXT note (PTY input single-writer; callers enqueue never block; drop+signal on a wedged harness). BLOCKS v0.13.0 ship."
1252
1253	# ── P2 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): resume brings up a BLANK session ──
1254	[[requirements]]
1255	id = "REQ-SESSION-RESUME-TEMPLATE"
1256	title = "Resuming an endpoint session that HAS conversation history brings up a BLANK session. ROOT (doyle, code-grounded + CONTEXT — case-3 spt-core MISSING feature, NOT a perri docs-miss): CONTEXT L127-129 already defines the resume-session seam ('continue-existing: resume an existing harness session under the adapter — its NATIVE resume'), and the manifest already has the resume-variant pattern (Session has BOTH psyche_init AND psyche_resume, manifest.rs:217-219) — but the agent's own session has ONLY self_ (`[session.self]`, no resume sibling). cmd_endpoint_run (cli.rs:1304) re-passes the session_id through `[session.self]` on resume (resume.unwrap_or_else(mint_session_id)), so the adapter's FRESH command (e.g. `claude --session-id ..`) runs again instead of the harness NATIVE resume (`claude -r ..`) -> CC starts a fresh transcript -> blank. spt-core forwards session_id + cwd faithfully; it just has no way to express the native-resume invocation. SECOND GAP: CC resolves a transcript by session_id + cwd, but the session ledger records only {ts, session_id, trigger} (no cwd), so picker Resume-from-history (cross-project rows) can't restore the right cwd. FIX (doyle design, V0.13.0-P2-SESSION-RESUME-DESIGN.md, mirrors psyche_init->psyche_resume exactly): (A) add a `[session.resume]` role (resume: Option<SessionRole> on Session + roles()/is_empty()); cmd_endpoint_run selects it when --resume is set AND it's declared (fill {id}/{session_id}=resumed id/{session_name} + the resume cwd), else FALL BACK to `[session.self]` (full back-compat). (B) record cwd PER ledger row (operator ruling): {ts, session_id, trigger, cwd} additive serde-default; resume cwd = resumed row cwd -> else perch info.cwd -> else current_dir (back-compat for old rows + single-project endpoints); picker threads the selected row's cwd through Outcome::Run -> cmd_endpoint_run. (C) public docs (MANIFEST + harness-contract) teach `[session.resume]` so perri builds the adapter side BLIND. Adapter follow-on (perri, AFTER spt-core ships+docs): declare `[session.resume] command = claude -r {session_id} --remote-control {id} --dangerously-skip-permissions` from the resume cwd. Completes REQ-READY-AGENT-RESUME / REQ-RUN-PICKER resume-from-history. (v0.13.0)"
1257	required_stages = []  # SEED — doyle design committed (V0.13.0-P2-SESSION-RESUME-DESIGN.md); todlando ACTIVATES [doc,impl,unit,int] registry-first when starting. PLANNED: doc = MANIFEST + harness-contract `[session.resume]` note (perri-buildable). impl = Session.resume role + roles()/is_empty() + cmd_endpoint_run resume-vs-self selection (+self fallback) + per-row ledger cwd (additive serde-default) + cwd fallback chain (row->info.cwd->current_dir) + picker threads row cwd. unit = manifest parse/roundtrip of `[session.resume]` + resume-vs-self selection + cwd fallback chain + ledger row back-compat (old row no cwd deserializes). int = dummy-harness whose resume command differs from self: --resume runs the RESUME command in the recorded cwd (marker assert), fresh runs self. clippy/traceable/docs-drift. doyle gates; then operator HITL (picker Resume reloads transcript in its project dir)."
1258
1259	# ── P1 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): rc paste broken (ctrl+V + right-click) ──
1260	[[requirements]]
1261	id = "REQ-RC-WIN-PASTE"
1262	title = "In an `spt rc` session neither ctrl+V nor right-click pastes (CC explicitly supports ctrl+V). ROOT (doyle /diagnose): RawGuard does only enable_raw_mode (no bracketed paste / no mouse capture / no clipboard interception); the Windows console delivers a paste as synthetic per-char KEY EVENTs (no crossterm Event::Paste), and ctrl+V translates to bare ^V forwarded to CC — but CC runs DAEMON-SIDE with NO access to the operator's LOCAL clipboard, so remote paste is fundamentally CLIENT-ORIGINATED. A multi-line paste-as-keys also becomes a \\r submit-storm. FIX (doyle design, V0.13.0-P1-RC-PASTE-DESIGN.md, cfg(windows), folds into the bug-2 event path): on a paste gesture rc reads the LOCAL clipboard + forwards a BRACKETED PASTE (ESC[200~ + content + ESC[201~); CC has bracketed-paste mode on (its TUI sets ESC[?2004h) so it treats it as a paste — content intact, no submit-storm, harness-AGNOSTIC. ctrl+V: intercept Char('v')+CONTROL in the event loop -> read_clipboard -> bracketed paste. Right-click: RawGuard also EnableMouseCapture (disables console QuickEdit + enables ENABLE_MOUSE_INPUT so right-click surfaces as Event::Mouse on legacy cmd/powershell) -> right-button -> read_clipboard -> bracketed paste; DROP all other mouse (CC has no mouse features, operator-confirmed, so capture costs nothing). read_clipboard = clipboard-win crate (cfg(windows), minimal); empty/failed = clean no-op. Content forwarded VERBATIM (literal pasted text, no per-char translation). Unix UNCHANGED (its terminal pastes natively through the byte pump). DEPENDS ON P0 (a paste chunk must not wedge the broker). (v0.13.0)"
1263	required_stages = []  # SEED — doyle design committed (V0.13.0-P1-RC-PASTE-DESIGN.md); todlando ACTIVATES [doc,impl,unit] registry-first (NO int — live clipboard+console mouse = HITL, REQ-RUN-PICKER/RC-1 precedent). PLANNED: impl = RawGuard EnableMouseCapture (+restore on drop) + ctrl+V/right-mouse clipboard intercept in the bug-2 event path + bracketed-paste wrap + clipboard-win read. unit = bracketed-paste framing (exact ESC[200~/ESC[201~) + gesture->paste decision (ctrl+V/right-mouse=>clipboard-wrap, other mouse=>drop, injected reader) + content verbatim. doc = KNOWN-HAZARDS + CONTEXT note (rc Windows paste client-originated; Unix rides the terminal byte stream). DEPENDS ON P0. doyle gates; operator HITL (ctrl+V AND right-click paste, multi-line, no submit-storm, no wedge)."
1264

required_stages = ["doc", "impl", "unit"]  # ACTIVATED v0.13.0 bug 2 (todlando, operator-ruled proper translator, ship-blocker; doyle gates). doc = KNOWN-HAZARDS + CONTEXT note (Windows rc translates console key events to standard xterm VT; Unix passes through). impl = rc.rs translate_key_event (pure xterm-VT table) + spawn_stdin_reader Windows cfg-split (tty→crossterm events / non-tty→byte fallback / Unix byte-pump unchanged) + event-sourced ctrl-b+'d' detach SM (Option B) + normalize_key_byte removed (Backspace/Ctrl+Backspace mapping relocated into translate_key_event). unit = exhaustive translate_key_event mapping (every arrow/Home/End/PgUp/PgDn/Del/Insert/F-key/modifier-combo/Ctrl+char/Backspace/Ctrl+Backspace asserts exact xterm bytes) + the event-detach SM (armed+'d'⇒Detach; armed+Ctrl+B⇒0x02; armed+other⇒0x02+translated). HITL acceptance (operator) = real CC rc session: arrows/Home/End/PgUp/PgDn/Del/F-keys work, Backspace=char, Ctrl+Backspace=word, ctrl-b d detach works, typing smooth (W1b).

# ── P0 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): pasting into rc WEDGES the broker ──
[[requirements]]
id = "REQ-HAZARD-PTY-INPUT-WRITER-WEDGE"
title = "Pasting into an `spt rc` session WEDGES the broker — after a paste the operator can no longer type AND can no longer attach to NEW or EXISTING sessions (`brain IPC read deadline`). ROOT (doyle /diagnose, code-grounded): the operator-keystroke path rc -> net-stream Input -> serve_attach (attach.rs:197 brain.send_effect) -> KIND_INPUT -> broker dispatch loop (broker.rs:1091) -> dispatch_input (broker.rs:1459) -> session.write_input(&bytes) runs SYNCHRONOUSLY on the broker request-handling thread. W1b (REQ-HAZARD-EFFECT-JOURNAL-PTY-WEDGE) released the journal lock across the effect (fix 1) + made PtyWrite ephemeral/no-fsync (fix 3) but EXPLICITLY DEFERRED fix (2) — bound/fail-fast the PtyWrite itself. A single keystroke never fills the ConPTY input buffer; a PASTE BURST does -> write_input blocks -> the dispatch thread cannot service the next frame (a re-attach subscribe, a become_controller restore-write, an inject-floor flush) -> wedge. Not a bug-2 regression (the byte path funnels to the same write_input; paste just reliably fills the buffer). FIX (doyle design, V0.13.0-P0-PTY-INPUT-WRITER-DESIGN.md, CONTEXT L33 broker-owns-PTY/minimal + L435 SessionSurface + single-writer pattern): one dedicated per-session INPUT-WRITER THREAD = the SOLE caller of the blocking write_input, fed by a BOUNDED FIFO channel; every caller (dispatch_input, serve_attach->send_effect, inject-floor flush) ENQUEUES + returns immediately, never blocks. A blocked/slow harness blocks ONLY its own writer thread, never the broker dispatch. Backpressure (operator ruling): queue full => DROP excess input + stamp the session INPUT_BACKPRESSURE (visible health signal); the daemon NEVER wedges; a merely-slow harness self-heals as the writer drains. Exactly-once preserved (PtyWrite ephemeral: apply_once effect = the non-blocking enqueue => Applied; ack now means accepted+ordered, benign — rc does not gate on landing); order preserved (single FIFO + single writer); inject-floor (W2 Layer C) choreography moves to the lone writer. Completes the W1b-deferred fix (2), cross-platform (cfg(unix) forkpty park folds in). (v0.13.0)"
required_stages = []  # SEED — doyle design committed (V0.13.0-P0-PTY-INPUT-WRITER-DESIGN.md); todlando ACTIVATES [impl,unit,int] registry-first as build step 1 (the active ship-blocker). PLANNED stages: impl = per-session input-writer thread (sole write_input caller) + bounded FIFO + try-enqueue at dispatch_input/serve_attach send_effect path/inject-floor flush + DROP+INPUT_BACKPRESSURE stamp on saturation + apply_once PtyWrite effect=enqueue. unit = writer drain order + bounded-queue drop/heal decision (injected channel, no wall-clock) + exactly-once enqueue (reserve->enqueue->Applied, dedup). int = REPRO-FIRST (W1b's int ESCAPED this — it relied on ConPTY absorbing the write, never an indefinite block): a real PTY consumer that STOPS draining so write_input genuinely parks (RED pre-fix: a concurrent rc attach gets brain IPC read deadline / no output) + GREEN post-fix (concurrent attach OPENS + RECEIVES output, operator keystroke still accepted, DROP+INPUT_BACKPRESSURE on saturation + heal on resume). Real broker+PTY+rc, NO mocks. cfg(unix) forkpty park leg covered. doc = KNOWN-HAZARDS 7.17 + CONTEXT note (PTY input single-writer; callers enqueue never block; drop+signal on a wedged harness). BLOCKS v0.13.0 ship."

# ── P2 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): resume brings up a BLANK session ──
[[requirements]]
id = "REQ-SESSION-RESUME-TEMPLATE"
title = "Resuming an endpoint session that HAS conversation history brings up a BLANK session. ROOT (doyle, code-grounded + CONTEXT — case-3 spt-core MISSING feature, NOT a perri docs-miss): CONTEXT L127-129 already defines the resume-session seam ('continue-existing: resume an existing harness session under the adapter — its NATIVE resume'), and the manifest already has the resume-variant pattern (Session has BOTH psyche_init AND psyche_resume, manifest.rs:217-219) — but the agent's own session has ONLY self_ (`[session.self]`, no resume sibling). cmd_endpoint_run (cli.rs:1304) re-passes the session_id through `[session.self]` on resume (resume.unwrap_or_else(mint_session_id)), so the adapter's FRESH command (e.g. `claude --session-id ..`) runs again instead of the harness NATIVE resume (`claude -r ..`) -> CC starts a fresh transcript -> blank. spt-core forwards session_id + cwd faithfully; it just has no way to express the native-resume invocation. SECOND GAP: CC resolves a transcript by session_id + cwd, but the session ledger records only {ts, session_id, trigger} (no cwd), so picker Resume-from-history (cross-project rows) can't restore the right cwd. FIX (doyle design, V0.13.0-P2-SESSION-RESUME-DESIGN.md, mirrors psyche_init->psyche_resume exactly): (A) add a `[session.resume]` role (resume: Option<SessionRole> on Session + roles()/is_empty()); cmd_endpoint_run selects it when --resume is set AND it's declared (fill {id}/{session_id}=resumed id/{session_name} + the resume cwd), else FALL BACK to `[session.self]` (full back-compat). (B) record cwd PER ledger row (operator ruling): {ts, session_id, trigger, cwd} additive serde-default; resume cwd = resumed row cwd -> else perch info.cwd -> else current_dir (back-compat for old rows + single-project endpoints); picker threads the selected row's cwd through Outcome::Run -> cmd_endpoint_run. (C) public docs (MANIFEST + harness-contract) teach `[session.resume]` so perri builds the adapter side BLIND. Adapter follow-on (perri, AFTER spt-core ships+docs): declare `[session.resume] command = claude -r {session_id} --remote-control {id} --dangerously-skip-permissions` from the resume cwd. Completes REQ-READY-AGENT-RESUME / REQ-RUN-PICKER resume-from-history. (v0.13.0)"
required_stages = []  # SEED — doyle design committed (V0.13.0-P2-SESSION-RESUME-DESIGN.md); todlando ACTIVATES [doc,impl,unit,int] registry-first when starting. PLANNED: doc = MANIFEST + harness-contract `[session.resume]` note (perri-buildable). impl = Session.resume role + roles()/is_empty() + cmd_endpoint_run resume-vs-self selection (+self fallback) + per-row ledger cwd (additive serde-default) + cwd fallback chain (row->info.cwd->current_dir) + picker threads row cwd. unit = manifest parse/roundtrip of `[session.resume]` + resume-vs-self selection + cwd fallback chain + ledger row back-compat (old row no cwd deserializes). int = dummy-harness whose resume command differs from self: --resume runs the RESUME command in the recorded cwd (marker assert), fresh runs self. clippy/traceable/docs-drift. doyle gates; then operator HITL (picker Resume reloads transcript in its project dir)."

# ── P1 (operator HITL 2026-06-19, post-deploy dogfood; doyle /diagnose + design): rc paste broken (ctrl+V + right-click) ──
[[requirements]]
id = "REQ-RC-WIN-PASTE"
title = "In an `spt rc` session neither ctrl+V nor right-click pastes (CC explicitly supports ctrl+V). ROOT (doyle /diagnose): RawGuard does only enable_raw_mode (no bracketed paste / no mouse capture / no clipboard interception); the Windows console delivers a paste as synthetic per-char KEY EVENTs (no crossterm Event::Paste), and ctrl+V translates to bare ^V forwarded to CC — but CC runs DAEMON-SIDE with NO access to the operator's LOCAL clipboard, so remote paste is fundamentally CLIENT-ORIGINATED. A multi-line paste-as-keys also becomes a \\r submit-storm. FIX (doyle design, V0.13.0-P1-RC-PASTE-DESIGN.md, cfg(windows), folds into the bug-2 event path): on a paste gesture rc reads the LOCAL clipboard + forwards a BRACKETED PASTE (ESC[200~ + content + ESC[201~); CC has bracketed-paste mode on (its TUI sets ESC[?2004h) so it treats it as a paste — content intact, no submit-storm, harness-AGNOSTIC. ctrl+V: intercept Char('v')+CONTROL in the event loop -> read_clipboard -> bracketed paste. Right-click: RawGuard also EnableMouseCapture (disables console QuickEdit + enables ENABLE_MOUSE_INPUT so right-click surfaces as Event::Mouse on legacy cmd/powershell) -> right-button -> read_clipboard -> bracketed paste; DROP all other mouse (CC has no mouse features, operator-confirmed, so capture costs nothing). read_clipboard = clipboard-win crate (cfg(windows), minimal); empty/failed = clean no-op. Content forwarded VERBATIM (literal pasted text, no per-char translation). Unix UNCHANGED (its terminal pastes natively through the byte pump). DEPENDS ON P0 (a paste chunk must not wedge the broker). (v0.13.0)"
required_stages = []  # SEED — doyle design committed (V0.13.0-P1-RC-PASTE-DESIGN.md); todlando ACTIVATES [doc,impl,unit] registry-first (NO int — live clipboard+console mouse = HITL, REQ-RUN-PICKER/RC-1 precedent). PLANNED: impl = RawGuard EnableMouseCapture (+restore on drop) + ctrl+V/right-mouse clipboard intercept in the bug-2 event path + bracketed-paste wrap + clipboard-win read. unit = bracketed-paste framing (exact ESC[200~/ESC[201~) + gesture->paste decision (ctrl+V/right-mouse=>clipboard-wrap, other mouse=>drop, injected reader) + content verbatim. doc = KNOWN-HAZARDS + CONTEXT note (rc Windows paste client-originated; Unix rides the terminal byte stream). DEPENDS ON P0. doyle gates; operator HITL (ctrl+V AND right-click paste, multi-line, no submit-storm, no wedge)."


REQ seeded with `required_stages = []` + full planned stages. Reading impl surfaces: cmd_endpoint_run, picker, sessions store.


crates\spt\src\cli.rs:1265:pub(crate) fn cmd_endpoint_run(
crates\spt\src\cli.rs:1304:    let session_id = resume.unwrap_or_else(spt_daemon::harnesshost::mint_session_id);


fn title|fn resume_outcome|enum Outcome|Outcome::Run|struct ResumeRow|fn cwd|head|id5


crates\spt\src\picker\model.rs:138:pub struct ResumeRow {
crates\spt\src\picker\model.rs:149:    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
crates\spt\src\picker\model.rs:150:    pub fn title(&self) -> String {
crates\spt\src\picker\model.rs:151:        let head = if self.project.is_empty() {
crates\spt\src\picker\model.rs:156:        let id5: String = {
crates\spt\src\picker\model.rs:161:        format!("{head} @ {} (…{id5})", self.ts)
crates\spt\src\picker\model.rs:312:pub enum Outcome {
crates\spt\src\picker\model.rs:505:        Some(Outcome::Run {
crates\spt\src\picker\model.rs:670:        let bringup = |mode| Outcome::Run {
crates\spt\src\picker\model.rs:681:            // the headless escape, see [`start_headless_outcome`](Self::start_headless_outcome)).
crates\spt\src\picker\model.rs:719:    pub fn start_headless_outcome(&self) -> Option<Outcome> {
crates\spt\src\picker\model.rs:724:        Some(Outcome::Run {
crates\spt\src\picker\model.rs:752:    /// prior session id (`cmd_endpoint_run --resume`). `headless` (the `h` keybind,
crates\spt\src\picker\model.rs:756:    pub fn resume_outcome(&self, headless: bool) -> Option<Outcome> {
crates\spt\src\picker\model.rs:759:        Some(Outcome::Run {
crates\spt\src\picker\model.rs:763:            mode: if headless { RunMode::Start } else { RunMode::Attach },
crates\spt\src\picker\model.rs:908:    // [unit->REQ-RUN-PICKER] resume titles render `<project|trigger> @ ts (…id5)`.
crates\spt\src\picker\model.rs:1070:            Outcome::Run { adapter, id, resume, mode } => {
crates\spt\src\picker\model.rs:1130:            Some(Outcome::Run {
crates\spt\src\picker\model.rs:1138:        // the `h` headless escape brings the SAME offline endpoint up detached.
crates\spt\src\picker\model.rs:1140:            m2.start_headless_outcome(),
crates\spt\src\picker\model.rs:1141:            Some(Outcome::Run {
crates\spt\src\picker\model.rs:1147:            "h → headless (Start) bringup"
crates\spt\src\picker\model.rs:1158:        assert_eq!(m3.start_headless_outcome(), None, "online ⇒ h is a no-op");
crates\spt\src\picker\model.rs:1163:    fn resume_outcome_bakes_session() {
crates\spt\src\picker\model.rs:1176:        // Default resume ATTACHES (W4 UX); `h` resumes headless (detached).
crates\spt\src\picker\model.rs:1179:            Some(Outcome::Run {
crates\spt\src\picker\model.rs:1188:            Some(Outcome::Run {
crates\spt\src\picker\model.rs:1194:            "h → headless resume"


struct SessionRow|struct .*Row|pub cwd|pub ts|pub session_id|pub trigger|fn append|fn record


crates\spt-store\src\sessions.rs:61:    pub ts: String,
crates\spt-store\src\sessions.rs:63:    pub session_id: String,
crates\spt-store\src\sessions.rs:65:    pub trigger: SessionTrigger,
crates\spt-store\src\sessions.rs:97:pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
crates\spt-store\src\sessions.rs:128:    fn appends_and_reads_in_order() {


1265	pub(crate) fn cmd_endpoint_run(
1266	    adapter: &str,
1267	    id: &str,
1268	    resume: Option<String>,
1269	    start: bool,
1270	    // `--attach` is the default terminal action; the bool is accepted for flag
1271	    // symmetry but `start`/`view` are what branch (attach = neither).
1272	    _attach: bool,
1273	    view: bool,
1274	) -> i32 {
1275	    // id charset (the harness binds to exactly this id via argv).
1276	    // [impl->REQ-HAZARD-ID-CHARSET]
1277	    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
1278	        eprintln!("INVALID_ID:{id}: {e}");
1279	        return 2;
1280	    }
1281	    // Resolve the harness adapter option (`<adapter>[:profile]`) through the
1282	    // merged view (split → parent lookup → overlay), exactly like shell spawn.
1283	    let adapters_dir = spt_store::perch::adapters_dir();
1284	    let manifest = match spt_runtime::registry::resolve_option(&adapters_dir, adapter) {
1285	        Ok((_, m)) if m.adapter.kind == spt_runtime::manifest::AdapterKind::Harness => m,
1286	        Ok(_) => {
1287	            eprintln!(
1288	                "ENDPOINT_RUN_NOT_HARNESS:{adapter}: not a kind=\"harness\" adapter — \
1289	                 `endpoint run` brings up harness endpoints (shells use `shell spawn`)"
1290	            );
1291	            return 1;
1292	        }
1293	        Err(e) => {
1294	            eprintln!(
1295	                "ENDPOINT_RUN_ADAPTER_UNREGISTERED:{adapter}: not an active registered \
1296	                 harness adapter / valid profile on this node ({e}; spt adapter list)"
1297	            );
1298	            return 1;
1299	        }
1300	    };
1301
1302	    // A fresh bringup mints a provisional spawn-time session id; --resume carries
1303	    // a prior one (the harness reports the real id post-spawn either way).
1304	    let session_id = resume.unwrap_or_else(spt_daemon::harnesshost::mint_session_id);
1305
1306	    if let Err(e) = spt_daemon::daemon::ensure_running() {
1307	        eprintln!("ENDPOINT_RUN_DAEMON_FAIL:{id}: {e}");
1308	        return 1;
1309	    }
1310	    // REQ-HOST-RUN-2: land the harness PTY in the PROJECT cwd — where the user
1311	    // invoked `spt endpoint run` (`cc <id>` at a project root) — NOT the
1312	    // daemon's cwd. A project-scoped harness (Claude Code) reads its `.claude`,
1313	    // session history, and digest source relative to cwd. A failure to read the
1314	    // cwd falls back to the broker-inherited cwd (None) rather than aborting.
1315	    let project_cwd = std::env::current_dir()
1316	        .ok()
1317	        .map(|p| p.to_string_lossy().into_owned());
1318	    match spt_daemon::harnesshost::launch_harness_brokered_in(
1319	        &spt_daemon::endpoint::broker_socket_name(),
1320	        id,
1321	        adapter,
1322	        &session_id,
1323	        &manifest,
1324	        project_cwd.as_deref(),
1325	    ) {
1326	        Ok(spawned) => {
1327	            // A TERSE machine line (no Rust `Some(..)` Debug leak, no "binds its
1328	            // perch on startup" internals — W4 UX: user-friendly, not a process
1329	            // log). `pid=<n>` stays a parseable machine token (scripts + e2e read
1330	            // the long-lived PTY child off it); a backend without a pid prints `-`.
1331	            let pid_display = spawned
1332	                .pid
1333	                .map(|p| p.to_string())
1334	                .unwrap_or_else(|| "-".to_string());
1335	            eprintln!("ENDPOINT_RUN:{id} adapter={adapter} session={session_id} pid={pid_display}");
1336	        }
1337	        Err(e) => {
1338	            eprintln!("ENDPOINT_RUN_FAIL:{id}: {e}");
1339	            return 1;
1340	        }
1341	    }
1342
1343	    // Terminal action: start (return now) | view (read-only attach) | attach
1344	    // (default). Attach/view route through the SAME rc pump as `spt rc`.
1345	    if start {
1346	        // `ENDPOINT_RUN_STARTED:{id}` is the machine token (no Markdown markers →
1347	        // survives strip intact for a piped adapter); the `spt rc` hint restyles
1348	        // on a tty and strips clean otherwise. [impl->REQ-CLI-OUTPUT-MARKDOWN]
1349	        eprintln!(
1350	            "{}",
1351	            crate::helpfmt::render(
1352	                &format!("ENDPOINT_RUN_STARTED:{id} (attach with `spt rc {id}`)"),
1353	                crate::helpfmt::stderr_color()
1354	            )
1355	        );
1356	        return 0;
1357	    }
1358	    // Await the bringup ONLINE before attaching (REQ-HAZARD-RC-ATTACH-ONLINE-RACE):
1359	    // launch_harness_brokered_in returns once the harness PROCESS is spawned, but the
1360	    // broker-PTY bind (status -> ONLINE + a live session) lands ASYNC. An immediate
1361	    // run_attach loses the race three ways — connects to an empty ring then
1362	    // EOF-detaches, hits the offline status-gate, or finds no live session yet. FAIL
1363	    // LOUD on timeout rather than fall through to a doomed attach. Only the
1364	    // offline-bringup (Run) path reaches here; an already-online endpoint routes
1365	    // Outcome::Attach -> rc::run_attach and never double-counts this wait.
1366	    if !await_endpoint_online(id) {
1367	        eprintln!(
1368	            "ENDPOINT_RUN_ONLINE_TIMEOUT:{id}: harness did not come online within {ONLINE_AWAIT_SECS}s \
1369	             — not attaching (check `spt endpoint list`; retry with `spt rc {id}`)"
1370	        );
1371	        return 1;
1372	    }
1373	    match crate::rc::run_attach(id, rc_intent(view, false)) {
1374	        Ok(()) => 0,
1375	        Err(e) => {
1376	            eprintln!("RC_FAIL:{id}: {e}");
1377	            1
1378	        }
1379	    }
1380	}
1381
1382	/// The harness-boot deadline `cmd_endpoint_run` waits for a freshly-spawned
1383	/// endpoint to come ONLINE before attaching (REQ-HAZARD-RC-ATTACH-ONLINE-RACE).
1384	const ONLINE_AWAIT_SECS: u64 = 25;
1385
1386	/// Poll the freshly-spawned endpoint's perch status to [`STATUS_ONLINE`], bounded
1387	/// by [`ONLINE_AWAIT_SECS`] — the await-online gate before an attach/view bringup.
1388	/// Returns `true` once online, `false` on timeout (the caller then fails loud).
1389	// [impl->REQ-HAZARD-RC-ATTACH-ONLINE-RACE]
1390	fn await_endpoint_online(id: &str) -> bool {
1391	    use std::time::Duration;
1392	    const INTERVAL: Duration = Duration::from_millis(150);
1393	    let attempts = (ONLINE_AWAIT_SECS * 1000 / INTERVAL.as_millis() as u64) as u32;
1394	    let perch_path =

pub(crate) fn cmd_endpoint_run(
    adapter: &str,
    id: &str,
    resume: Option<String>,
    start: bool,
    // `--attach` is the default terminal action; the bool is accepted for flag
    // symmetry but `start`/`view` are what branch (attach = neither).
    _attach: bool,
    view: bool,
) -> i32 {
    // id charset (the harness binds to exactly this id via argv).
    // [impl->REQ-HAZARD-ID-CHARSET]
    if let Err(e) = spt_proto::id::validate_endpoint_id(id) {
        eprintln!("INVALID_ID:{id}: {e}");
        return 2;
    }
    // Resolve the harness adapter option (`<adapter>[:profile]`) through the
    // merged view (split → parent lookup → overlay), exactly like shell spawn.
    let adapters_dir = spt_store::perch::adapters_dir();
    let manifest = match spt_runtime::registry::resolve_option(&adapters_dir, adapter) {
        Ok((_, m)) if m.adapter.kind == spt_runtime::manifest::AdapterKind::Harness => m,
        Ok(_) => {
            eprintln!(
                "ENDPOINT_RUN_NOT_HARNESS:{adapter}: not a kind=\"harness\" adapter — \
                 `endpoint run` brings up harness endpoints (shells use `shell spawn`)"
            );
            return 1;
        }
        Err(e) => {
            eprintln!(
                "ENDPOINT_RUN_ADAPTER_UNREGISTERED:{adapter}: not an active registered \
                 harness adapter / valid profile on this node ({e}; spt adapter list)"
            );
            return 1;
        }
    };

    // A fresh bringup mints a provisional spawn-time session id; --resume carries
    // a prior one (the harness reports the real id post-spawn either way).
    let session_id = resume.unwrap_or_else(spt_daemon::harnesshost::mint_session_id);

    if let Err(e) = spt_daemon::daemon::ensure_running() {
        eprintln!("ENDPOINT_RUN_DAEMON_FAIL:{id}: {e}");
        return 1;
    }
    // REQ-HOST-RUN-2: land the harness PTY in the PROJECT cwd — where the user
    // invoked `spt endpoint run` (`cc <id>` at a project root) — NOT the
    // daemon's cwd. A project-scoped harness (Claude Code) reads its `.claude`,
    // session history, and digest source relative to cwd. A failure to read the
    // cwd falls back to the broker-inherited cwd (None) rather than aborting.
    let project_cwd = std::env::current_dir()
        .ok()
        .map(|p| p.to_string_lossy().into_owned());
    match spt_daemon::harnesshost::launch_harness_brokered_in(
        &spt_daemon::endpoint::broker_socket_name(),
        id,
        adapter,
        &session_id,
        &manifest,
        project_cwd.as_deref(),
    ) {
        Ok(spawned) => {
            // A TERSE machine line (no Rust `Some(..)` Debug leak, no "binds its
            // perch on startup" internals — W4 UX: user-friendly, not a process
            // log). `pid=<n>` stays a parseable machine token (scripts + e2e read
            // the long-lived PTY child off it); a backend without a pid prints `-`.
            let pid_display = spawned
                .pid
                .map(|p| p.to_string())
                .unwrap_or_else(|| "-".to_string());
            eprintln!("ENDPOINT_RUN:{id} adapter={adapter} session={session_id} pid={pid_display}");
        }
        Err(e) => {
            eprintln!("ENDPOINT_RUN_FAIL:{id}: {e}");
            return 1;
        }
    }

    // Terminal action: start (return now) | view (read-only attach) | attach
    // (default). Attach/view route through the SAME rc pump as `spt rc`.
    if start {
        // `ENDPOINT_RUN_STARTED:{id}` is the machine token (no Markdown markers →
        // survives strip intact for a piped adapter); the `spt rc` hint restyles
        // on a tty and strips clean otherwise. [impl->REQ-CLI-OUTPUT-MARKDOWN]
        eprintln!(
            "{}",
            crate::helpfmt::render(
                &format!("ENDPOINT_RUN_STARTED:{id} (attach with `spt rc {id}`)"),
                crate::helpfmt::stderr_color()
            )
        );
        return 0;
    }
    // Await the bringup ONLINE before attaching (REQ-HAZARD-RC-ATTACH-ONLINE-RACE):
    // launch_harness_brokered_in returns once the harness PROCESS is spawned, but the
    // broker-PTY bind (status -> ONLINE + a live session) lands ASYNC. An immediate
    // run_attach loses the race three ways — connects to an empty ring then
    // EOF-detaches, hits the offline status-gate, or finds no live session yet. FAIL
    // LOUD on timeout rather than fall through to a doomed attach. Only the
    // offline-bringup (Run) path reaches here; an already-online endpoint routes
    // Outcome::Attach -> rc::run_attach and never double-counts this wait.
    if !await_endpoint_online(id) {
        eprintln!(
            "ENDPOINT_RUN_ONLINE_TIMEOUT:{id}: harness did not come online within {ONLINE_AWAIT_SECS}s \
             — not attaching (check `spt endpoint list`; retry with `spt rc {id}`)"
        );
        return 1;
    }
    match crate::rc::run_attach(id, rc_intent(view, false)) {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("RC_FAIL:{id}: {e}");
            1
        }
    }
}

/// The harness-boot deadline `cmd_endpoint_run` waits for a freshly-spawned
/// endpoint to come ONLINE before attaching (REQ-HAZARD-RC-ATTACH-ONLINE-RACE).
const ONLINE_AWAIT_SECS: u64 = 25;

/// Poll the freshly-spawned endpoint's perch status to [`STATUS_ONLINE`], bounded
/// by [`ONLINE_AWAIT_SECS`] — the await-online gate before an attach/view bringup.
/// Returns `true` once online, `false` on timeout (the caller then fails loud).
// [impl->REQ-HAZARD-RC-ATTACH-ONLINE-RACE]
fn await_endpoint_online(id: &str) -> bool {
    use std::time::Duration;
    const INTERVAL: Duration = Duration::from_millis(150);
    let attempts = (ONLINE_AWAIT_SECS * 1000 / INTERVAL.as_millis() as u64) as u32;
    let perch_path =


Reading harness spawn template selection + picker model + sessions store.


crates\spt-daemon\src\harnesshost.rs:5://! launcher runs the adapter's `[session.self]` command and the harness binds its
crates\spt-daemon\src\harnesshost.rs:7://! changes *only who owns the PTY*: the daemon spawns `[session.self]` into a
crates\spt-daemon\src\harnesshost.rs:17://! **Id plumbing.** The endpoint id reaches the harness two ways, both filled
crates\spt-daemon\src\harnesshost.rs:18://! from the SAME `{key}` catalog: argv via `{id}` substitution into the opaque
crates\spt-daemon\src\harnesshost.rs:19://! `[session.self]` command (the harness parses `--id`), AND the manifest
crates\spt-daemon\src\harnesshost.rs:20://! `[env.<VAR>]` direction = inject values (e.g. `SPT_ENDPOINT_ID = "{id}"`),
crates\spt-daemon\src\harnesshost.rs:21://! which are `{key}`-substituted and passed in `SpawnReq.env` so the broker sets
crates\spt-daemon\src\harnesshost.rs:23://! `[session.self]` has no argv slot for `{id}` (e.g. bare `claude`) relies on
crates\spt-daemon\src\harnesshost.rs:25://! substituted nor applied), so the harness seeded-by-PPID instead of binding →
crates\spt-daemon\src\harnesshost.rs:27://! but feeds `[session.self]` (not `[shell].spawn`), uses real terminal PTY dims,
crates\spt-daemon\src\harnesshost.rs:56:/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
crates\spt-daemon\src\harnesshost.rs:59:pub fn mint_session_id() -> String {
crates\spt-daemon\src\harnesshost.rs:64:/// Validate the adapter and fill its `[session.self]` command for one endpoint,
crates\spt-daemon\src\harnesshost.rs:67:/// non-harness adapter, a missing `[session.self]`, an unresolved `{placeholder}`,
crates\spt-daemon\src\harnesshost.rs:83:    let role = manifest.session.self_.as_ref().ok_or_else(|| {
crates\spt-daemon\src\harnesshost.rs:84:        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
crates\spt-daemon\src\harnesshost.rs:86:    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
crates\spt-daemon\src\harnesshost.rs:88:    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
crates\spt-daemon\src\harnesshost.rs:95:    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
crates\spt-daemon\src\harnesshost.rs:98:        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
crates\spt-daemon\src\harnesshost.rs:100:        return Err("empty [session.self] command".into());
crates\spt-daemon\src\harnesshost.rs:103:    // {key}-substituted from the SAME catalog and passed to the broker for the
crates\spt-daemon\src\harnesshost.rs:105:    // substitution)" promise, previously unfulfilled (the value arrived empty, so
crates\spt-daemon\src\harnesshost.rs:110:            // Validation guarantees an inject directive has a value; fill it.
crates\spt-daemon\src\harnesshost.rs:112:                let filled = spt_runtime::runtime::fill_template(template, &keys)
crates\spt-daemon\src\harnesshost.rs:114:                env.insert(name.clone(), filled);
crates\spt-daemon\src\harnesshost.rs:132:/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
crates\spt-daemon\src\harnesshost.rs:133:/// `[env]`-inject map (both filled from the same `{key}` catalog —
crates\spt-daemon\src\harnesshost.rs:144:/// Spawn an adapter's `[session.self]` harness into a broker-held PTY (the
crates\spt-daemon\src\harnesshost.rs:149:/// `session_id`: the spawn-time `{session_id}` fill — a provisional [`mint_session_id`]
crates\spt-daemon\src\harnesshost.rs:158:pub fn launch_harness_brokered_in(
crates\spt-daemon\src\harnesshost.rs:193:    fn harness_manifest(self_command: &str) -> Manifest {
crates\spt-daemon\src\harnesshost.rs:196:             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = '{self_command}'\n\
crates\spt-daemon\src\harnesshost.rs:203:    // endpoint id and the spawn-time session id ride argv via {id}/{session_id}
crates\spt-daemon\src\harnesshost.rs:204:    // substitution into [session.self] (so the harness binds to exactly this
crates\spt-daemon\src\harnesshost.rs:207:    fn prepare_fills_id_and_session_into_self_command() {
crates\spt-daemon\src\harnesshost.rs:208:        let m = harness_manifest("mock-session --id {id} --session-id {session_id}");
crates\spt-daemon\src\harnesshost.rs:224:    // is {key}-substituted from the same catalog and returned for the spawn — so a
crates\spt-daemon\src\harnesshost.rs:227:    fn prepare_fills_env_inject_values_with_substitution() {
crates\spt-daemon\src\harnesshost.rs:230:             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
crates\spt-daemon\src\harnesshost.rs:231:             [env.SPT_ENDPOINT_ID]\ndirection = \"inject\"\nvalue = \"{id}\"\n\n\
crates\spt-daemon\src\harnesshost.rs:232:             [env.SPT_SESSION]\ndirection = \"inject\"\nvalue = \"sess-{session_id}\"\n\n\
crates\spt-daemon\src\harnesshost.rs:253:             min_spt_core_version = \"0\"\n\n[session.self]\ncommand = 'claude'\n\n\
crates\spt-daemon\src\harnesshost.rs:270:    // is refused (endpoint run hosts harnesses), a manifest with no [session.self]
crates\spt-daemon\src\harnesshost.rs:285:        // Harness with no [session.self] refused.
crates\spt-daemon\src\harnesshost.rs:293:            .contains("no [session.self]"));
crates\spt-daemon\src\harnesshost.rs:296:        let bad = harness_manifest("mock-session --id {id} --boom {not_a_key}");
crates\spt-daemon\src\harnesshost.rs:303:    // per mint (it only fills the spawn-time {session_id}; the harness reports the
crates\spt-daemon\src\harnesshost.rs:306:    fn mint_session_id_is_fresh_hex() {


56	/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
57	/// command template resolves. A fresh create mints one; `--resume` supplies the
58	/// prior session id instead.
59	pub fn mint_session_id() -> String {
60	    let seed = spt_proto::identity::Identity::generate().seed();
61	    seed[..8].iter().map(|b| format!("{b:02x}")).collect()
62	}
63
64	/// Validate the adapter and fill its `[session.self]` command for one endpoint,
65	/// returning the tokenized `program + args`. Pure (no spawn) — the broker call in
66	/// [`launch_harness_brokered_in`] is the only side effect. Fails closed: a
67	/// non-harness adapter, a missing `[session.self]`, an unresolved `{placeholder}`,
68	/// or an empty command all error before anything is spawned.
69	// [impl->REQ-HOST-RUN-1]
70	pub fn prepare_harness_spawn(
71	    endpoint_id: &str,
72	    adapter_name: &str,
73	    session_id: &str,
74	    manifest: &Manifest,
75	) -> Result<PreparedSpawn, String> {
76	    if manifest.adapter.kind != AdapterKind::Harness {
77	        return Err(format!(
78	            "adapter '{adapter_name}' is kind={:?}, not a harness — \
79	             `spt endpoint run` brings up harness endpoints",
80	            manifest.adapter.kind
81	        ));
82	    }
83	    let role = manifest.session.self_.as_ref().ok_or_else(|| {
84	        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
85	    })?;
86	    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
87	    // binds to exactly this endpoint; `session_name` defaults to the id. The SAME
88	    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
89	    let keys = BTreeMap::from([
90	        ("id".to_string(), endpoint_id.to_string()),
91	        ("adapter_name".to_string(), adapter_name.to_string()),
92	        ("session_id".to_string(), session_id.to_string()),
93	        ("session_name".to_string(), endpoint_id.to_string()),
94	    ]);
95	    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
96	    // token so a multi-word/quote/semicolon {key} value is one argv element.
97	    let tokens =
98	        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
99	    if tokens.is_empty() {
100	        return Err("empty [session.self] command".into());
101	    }
102	    // [impl->REQ-HAZARD-ENV-SUBST] every [env.<VAR>] direction=inject value is
103	    // {key}-substituted from the SAME catalog and passed to the broker for the
104	    // spawned child's environment — the schema's "Value to inject (with
105	    // substitution)" promise, previously unfulfilled (the value arrived empty, so
106	    // a flagless harness seeded-by-PPID instead of binding → zero perch).
107	    let mut env = BTreeMap::new();
108	    for (name, var) in &manifest.env {
109	        if var.direction == EnvDirection::Inject {
110	            // Validation guarantees an inject directive has a value; fill it.
111	            if let Some(template) = &var.value {
112	                let filled = spt_runtime::runtime::fill_template(template, &keys)
113	                    .map_err(|e| e.to_string())?;
114	                env.insert(name.clone(), filled);
115	            }
116	        }
117	    }
118	    // ADR-0022 / W2: carry the adapter's idle-delivery translation-binary path
119	    // (if declared) so the broker can lifecycle-manage it alongside this session.
120	    // The path is opaque (resolved at spawn like every adapter command).
121	    let translation_binary = manifest
122	        .message_idle_translation_binary
123	        .as_ref()
124	        .map(|b| b.path.clone());
125	    Ok(PreparedSpawn {
126	        tokens,
127	        env,
128	        translation_binary,
129	    })
130	}
131
132	/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
133	/// `[env]`-inject map (both filled from the same `{key}` catalog —
134	/// REQ-HAZARD-ENV-SUBST), and the optional idle-delivery translation-binary path
135	/// (ADR-0022) the broker lifecycle-manages.
136	#[derive(Debug, Clone, PartialEq, Eq)]
137	pub struct PreparedSpawn {
138	    pub tokens: Vec<String>,
139	    pub env: BTreeMap<String, String>,
140	    /// `[message-idle-translation-binary].path` if the adapter declares one.
141	    pub translation_binary: Option<String>,
142	}
143
144	/// Spawn an adapter's `[session.self]` harness into a broker-held PTY (the
145	/// spt-hosted bringup). The broker session is labeled `endpoint_id` so
146	/// `spt rc <id>` resolves it; the harness self-registers its perch on bind. The
147	/// broker outlives this call — the session keeps running after it returns.
148	///
149	/// `session_id`: the spawn-time `{session_id}` fill — a provisional [`mint_session_id`]
150	/// for a fresh create, or the prior session for `--resume`.
151	///
152	/// `cwd`: the project-scoped working directory the harness PTY lands in
153	/// (REQ-HOST-RUN-2). `None` inherits the broker's cwd (the W1 bringup shortcut);
154	/// `Some(dir)` is what a project-scoped launcher (`cc <id>` at a project root)
155	/// passes so the harness reads the RIGHT `.claude`/session history/digest source.
156	// [impl->REQ-HOST-RUN-1]
157	// [impl->REQ-HOST-RUN-2]
158	pub fn launch_harness_brokered_in(
159	    broker_name: &str,
160	    endpoint_id: &str,
161	    adapter_name: &str,
162	    session_id: &str,
163	    manifest: &Manifest,
164	    cwd: Option<&str>,
165	) -> Result<HarnessSpawned, String> {
166	    let PreparedSpawn {
167	        tokens,
168	        env,
169	        translation_binary,
170	    } = prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest)?;
171	    let (program, args) = tokens.split_first().expect("non-empty checked");
172	    let mut brain =
173	        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
174	    let (session_id, pid) = brain
175	        .spawn_session_pid(SpawnReq {
176	            program: program.clone(),
177	            args: args.to_vec(),
178	            rows: DEFAULT_HARNESS_ROWS,
179	            cols: DEFAULT_HARNESS_COLS,
180	            endpoint: endpoint_id.to_string(),
181	            cwd: cwd.map(str::to_string),
182	            env,
183	            translation_binary,
184	        })
185	        .map_err(|e| format!("broker spawn: {e}"))?;
186	    Ok(HarnessSpawned { session_id, pid })
187	}
188
189	#[cfg(test)]
190	mod tests {

/// "post_spawn"`); this only fills the `{session_id}` placeholder at spawn so the
/// command template resolves. A fresh create mints one; `--resume` supplies the
/// prior session id instead.
pub fn mint_session_id() -> String {
    let seed = spt_proto::identity::Identity::generate().seed();
    seed[..8].iter().map(|b| format!("{b:02x}")).collect()
}

/// Validate the adapter and fill its `[session.self]` command for one endpoint,
/// returning the tokenized `program + args`. Pure (no spawn) — the broker call in
/// [`launch_harness_brokered_in`] is the only side effect. Fails closed: a
/// non-harness adapter, a missing `[session.self]`, an unresolved `{placeholder}`,
/// or an empty command all error before anything is spawned.
// [impl->REQ-HOST-RUN-1]
pub fn prepare_harness_spawn(
    endpoint_id: &str,
    adapter_name: &str,
    session_id: &str,
    manifest: &Manifest,
) -> Result<PreparedSpawn, String> {
    if manifest.adapter.kind != AdapterKind::Harness {
        return Err(format!(
            "adapter '{adapter_name}' is kind={:?}, not a harness — \
             `spt endpoint run` brings up harness endpoints",
            manifest.adapter.kind
        ));
    }
    let role = manifest.session.self_.as_ref().ok_or_else(|| {
        format!("adapter '{adapter_name}' declares no [session.self] — nothing to bring up")
    })?;
    // The guaranteed-fill catalog for a self-spawn. `id` rides argv so the harness
    // binds to exactly this endpoint; `session_name` defaults to the id. The SAME
    // catalog fills the [env] inject values (REQ-HAZARD-ENV-SUBST).
    let keys = BTreeMap::from([
        ("id".to_string(), endpoint_id.to_string()),
        ("adapter_name".to_string(), adapter_name.to_string()),
        ("session_id".to_string(), session_id.to_string()),
        ("session_name".to_string(), endpoint_id.to_string()),
    ]);
    // [impl->REQ-HAZARD-TEMPLATE-ARGV-FILL] tokenize the template then fill each
    // token so a multi-word/quote/semicolon {key} value is one argv element.
    let tokens =
        spt_runtime::runtime::fill_template_tokens(&role.command, &keys).map_err(|e| e.to_string())?;
    if tokens.is_empty() {
        return Err("empty [session.self] command".into());
    }
    // [impl->REQ-HAZARD-ENV-SUBST] every [env.<VAR>] direction=inject value is
    // {key}-substituted from the SAME catalog and passed to the broker for the
    // spawned child's environment — the schema's "Value to inject (with
    // substitution)" promise, previously unfulfilled (the value arrived empty, so
    // a flagless harness seeded-by-PPID instead of binding → zero perch).
    let mut env = BTreeMap::new();
    for (name, var) in &manifest.env {
        if var.direction == EnvDirection::Inject {
            // Validation guarantees an inject directive has a value; fill it.
            if let Some(template) = &var.value {
                let filled = spt_runtime::runtime::fill_template(template, &keys)
                    .map_err(|e| e.to_string())?;
                env.insert(name.clone(), filled);
            }
        }
    }
    // ADR-0022 / W2: carry the adapter's idle-delivery translation-binary path
    // (if declared) so the broker can lifecycle-manage it alongside this session.
    // The path is opaque (resolved at spawn like every adapter command).
    let translation_binary = manifest
        .message_idle_translation_binary
        .as_ref()
        .map(|b| b.path.clone());
    Ok(PreparedSpawn {
        tokens,
        env,
        translation_binary,
    })
}

/// The filled spawn inputs for an spt-hosted harness: the tokenized argv, the
/// `[env]`-inject map (both filled from the same `{key}` catalog —
/// REQ-HAZARD-ENV-SUBST), and the optional idle-delivery translation-binary path
/// (ADR-0022) the broker lifecycle-manages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedSpawn {
    pub tokens: Vec<String>,
    pub env: BTreeMap<String, String>,
    /// `[message-idle-translation-binary].path` if the adapter declares one.
    pub translation_binary: Option<String>,
}

/// Spawn an adapter's `[session.self]` harness into a broker-held PTY (the
/// spt-hosted bringup). The broker session is labeled `endpoint_id` so
/// `spt rc <id>` resolves it; the harness self-registers its perch on bind. The
/// broker outlives this call — the session keeps running after it returns.
///
/// `session_id`: the spawn-time `{session_id}` fill — a provisional [`mint_session_id`]
/// for a fresh create, or the prior session for `--resume`.
///
/// `cwd`: the project-scoped working directory the harness PTY lands in
/// (REQ-HOST-RUN-2). `None` inherits the broker's cwd (the W1 bringup shortcut);
/// `Some(dir)` is what a project-scoped launcher (`cc <id>` at a project root)
/// passes so the harness reads the RIGHT `.claude`/session history/digest source.
// [impl->REQ-HOST-RUN-1]
// [impl->REQ-HOST-RUN-2]
pub fn launch_harness_brokered_in(
    broker_name: &str,
    endpoint_id: &str,
    adapter_name: &str,
    session_id: &str,
    manifest: &Manifest,
    cwd: Option<&str>,
) -> Result<HarnessSpawned, String> {
    let PreparedSpawn {
        tokens,
        env,
        translation_binary,
    } = prepare_harness_spawn(endpoint_id, adapter_name, session_id, manifest)?;
    let (program, args) = tokens.split_first().expect("non-empty checked");
    let mut brain =
        Brain::cold_start(broker_name, now_ms()).map_err(|e| format!("broker connect: {e}"))?;
    let (session_id, pid) = brain
        .spawn_session_pid(SpawnReq {
            program: program.clone(),
            args: args.to_vec(),
            rows: DEFAULT_HARNESS_ROWS,
            cols: DEFAULT_HARNESS_COLS,
            endpoint: endpoint_id.to_string(),
            cwd: cwd.map(str::to_string),
            env,
            translation_binary,
        })
        .map_err(|e| format!("broker spawn: {e}"))?;
    Ok(HarnessSpawned { session_id, pid })
}

#[cfg(test)]
mod tests {


130	        }
131	    }
132	    rows
133	}
134
135	/// One prior session from the per-endpoint ledger, rendered as a Resume option.
136	/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
137	#[derive(Debug, Clone, PartialEq, Eq)]
138	pub struct ResumeRow {
139	    pub session_id: String,
140	    pub ts: String,
141	    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
142	    /// no project association exists (the ledger row carries no project).
143	    pub trigger: String,
144	    /// Best-effort project name (empty if none derivable).
145	    pub project: String,
146	}
147
148	impl ResumeRow {
149	    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
150	    pub fn title(&self) -> String {
151	        let head = if self.project.is_empty() {
152	            &self.trigger
153	        } else {
154	            &self.project
155	        };
156	        let id5: String = {
157	            let s = &self.session_id;
158	            let n = s.chars().count();
159	            s.chars().skip(n.saturating_sub(5)).collect()
160	        };
161	        format!("{head} @ {} (…{id5})", self.ts)
162	    }
163	}
164
165	/// A pick-existing category. Left/right cycles through these.
166	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167	pub enum Category {
168	    /// Endpoints scoped to the cwd project.
169	    Project,
170	    /// This node's local perches.
171	    Local,
172	    /// Subnet endpoints.
173	    Subnet,
174	}
175
176	impl Category {
177	    /// Display label (the project category carries the cwd project name).
178	    pub fn label(self, project_name: &str) -> String {
179	        match self {
180	            Category::Project => format!("{project_name} project"),
181	            Category::Local => "Local node".to_string(),
182	            Category::Subnet => "Subnet".to_string(),
183	        }
184	    }
185
186	    /// The ordered category ring for left/right navigation.
187	    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
188	}
189

        }
    }
    rows
}

/// One prior session from the per-endpoint ledger, rendered as a Resume option.
/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeRow {
    pub session_id: String,
    pub ts: String,
    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
    /// no project association exists (the ledger row carries no project).
    pub trigger: String,
    /// Best-effort project name (empty if none derivable).
    pub project: String,
}

impl ResumeRow {
    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
    pub fn title(&self) -> String {
        let head = if self.project.is_empty() {
            &self.trigger
        } else {
            &self.project
        };
        let id5: String = {
            let s = &self.session_id;
            let n = s.chars().count();
            s.chars().skip(n.saturating_sub(5)).collect()
        };
        format!("{head} @ {} (…{id5})", self.ts)
    }
}

/// A pick-existing category. Left/right cycles through these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
    /// Endpoints scoped to the cwd project.
    Project,
    /// This node's local perches.
    Local,
    /// Subnet endpoints.
    Subnet,
}

impl Category {
    /// Display label (the project category carries the cwd project name).
    pub fn label(self, project_name: &str) -> String {
        match self {
            Category::Project => format!("{project_name} project"),
            Category::Local => "Local node".to_string(),
            Category::Subnet => "Subnet".to_string(),
        }
    }

    /// The ordered category ring for left/right navigation.
    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
}


1	//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
2	//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
3	//! sessions.log`.
4	//!
5	//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
6	//! rotates its harness `session_id` (a new transcript file). The single
7	//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
8	//! the thread but cannot *span* it. This ledger records each rotation so the
9	//! digest can enumerate the **last K sessions** and bridge a boundary
10	//! (ADR-0019 §thread-spanning).
11	//!
12	//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
13	//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
14	//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
15	//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
16	//! is deduped.
17	// [impl->REQ-TERM-6]
18
19	use std::io;
20	use std::path::Path;
21
22	use serde::{Deserialize, Serialize};
23
24	use crate::atomic::atomic_write_string;
25
26	/// The ledger filename under a perch.
27	pub const LEDGER_FILE: &str = "sessions.log";
28
29	/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
30	/// append so the file can never grow without bound (risk #2).
31	pub const MAX_LEDGER: usize = 64;
32
33	/// Why a session became active.
34	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35	#[serde(rename_all = "lowercase")]
36	pub enum SessionTrigger {
37	    /// First bind of the perch (`api listen` / revive).
38	    Boot,
39	    /// A `/clear` boundary (`api boundary clear`).
40	    Clear,
41	    /// A `/compact` boundary (`api boundary compact`).
42	    Compact,
43	}
44
45	impl SessionTrigger {
46	    /// The lowercase token (matches the serde wire form) — used as the boundary
47	    /// marker `kind` in the spanned digest.
48	    pub fn token(self) -> &'static str {
49	        match self {
50	            SessionTrigger::Boot => "boot",
51	            SessionTrigger::Clear => "clear",
52	            SessionTrigger::Compact => "compact",
53	        }
54	    }
55	}
56
57	/// One ledger row: a `session_id` became active at `ts` via `trigger`.
58	#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59	pub struct SessionEntry {
60	    /// RFC3339-UTC instant (see [`crate::timefmt`]).
61	    pub ts: String,
62	    /// The harness session id that became active.
63	    pub session_id: String,
64	    /// What caused the rotation.
65	    pub trigger: SessionTrigger,
66	}
67
68	/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
69	/// line is skipped, the rest still load (the degrade-don't-crash store stance).
70	pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
71	    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
72	        return Vec::new();
73	    };
74	    s.lines()
75	        .filter(|l| !l.trim().is_empty())
76	        .filter_map(|l| serde_json::from_str(l).ok())
77	        .collect()
78	}
79
80	/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
81	pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
82	    if k == 0 {
83	        return Vec::new();
84	    }
85	    let all = read_all(perch);
86	    let start = all.len().saturating_sub(k);
87	    all[start..].to_vec()
88	}
89
90	/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
91	/// re-bind of the live session is not a new boundary) and bounding the ledger to
92	/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
93	/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
94	/// partial-line corruption). Returns `true` if a row was written, `false` on a
95	/// same-session dedup.
96	// [impl->REQ-TERM-6]
97	pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
98	    let mut entries = read_all(perch);
99	    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
100	        return Ok(false); // same session re-bound — not a new boundary
101	    }
102	    entries.push(entry.clone());
103	    let start = entries.len().saturating_sub(MAX_LEDGER);
104	    let body: String = entries[start..]
105	        .iter()
106	        .map(|e| serde_json::to_string(e).unwrap_or_default())
107	        .map(|l| l + "\n")
108	        .collect();
109	    std::fs::create_dir_all(perch)?;
110	    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
111	    Ok(true)
112	}
113
114	#[cfg(test)]
115	mod tests {
116	    use super::*;
117
118	    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
119	        SessionEntry {
120	            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
121	            session_id: sid.to_string(),
122	            trigger,
123	        }
124	    }
125
126	    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
127	    #[test]
128	    fn appends_and_reads_in_order() {
129	        let dir = tempfile::tempdir().unwrap();
130	        let perch = dir.path().join("perch");
131	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
132	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
133	        let all = read_all(&perch);
134	        assert_eq!(all.len(), 2);
135	        assert_eq!(all[0].session_id, "s1");
136	        assert_eq!(all[0].trigger, SessionTrigger::Boot);
137	        assert_eq!(all[1].session_id, "s2");
138	        assert_eq!(all[1].trigger, SessionTrigger::Clear);
139	    }
140
141	    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
142	    // boundary); a different session appends.
143	    #[test]
144	    fn same_session_rebind_is_deduped() {
145	        let dir = tempfile::tempdir().unwrap();
146	        let perch = dir.path().join("perch");
147	        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
148	        assert!(
149	            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
150	            "same session re-bind → no new row"
151	        );
152	        assert_eq!(read_all(&perch).len(), 1);
153	        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
154	        assert_eq!(read_all(&perch).len(), 2);
155	    }
156
157	    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
158	    // it never grows past MAX_LEDGER.
159	    #[test]
160	    fn last_k_and_bounding() {
161	        let dir = tempfile::tempdir().unwrap();
162	        let perch = dir.path().join("perch");
163	        for i in 0..(MAX_LEDGER + 10) {
164	            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
165	        }
166	        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
167	        let last3 = last_k(&perch, 3);
168	        assert_eq!(last3.len(), 3);
169	        let n = MAX_LEDGER + 10;
170	        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
171	        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
172	        assert!(last_k(&perch, 0).is_empty());
173	    }
174
175	    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
176	    #[test]
177	    fn tolerant_of_garbage_lines() {
178	        let dir = tempfile::tempdir().unwrap();
179	        let perch = dir.path().join("perch");
180	        std::fs::create_dir_all(&perch).unwrap();
181	        std::fs::write(
182	            perch.join(LEDGER_FILE),
183	            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
184	        )
185	        .unwrap();
186	        let all = read_all(&perch);
187	        assert_eq!(all.len(), 1);
188	        assert_eq!(all[0].session_id, "good");
189	    }
190	}
191

//! Per-endpoint **session ledger** — `<perch>/sessions.log` (ADR-0019 /
//! REQ-TERM-6), heir to the sister project's `psyches/tracked/agents/<id>/
//! sessions.log`.
//!
//! A live agent that `/clear`s or `/compact`s keeps its perch identity but
//! rotates its harness `session_id` (a new transcript file). The single
//! `{session_id}` a `[history]`/`[digest]` source resolves therefore *follows*
//! the thread but cannot *span* it. This ledger records each rotation so the
//! digest can enumerate the **last K sessions** and bridge a boundary
//! (ADR-0019 §thread-spanning).
//!
//! One JSONL row per rotation: `{ts, session_id, trigger}`. The ledger is
//! **bounded** ([`MAX_LEDGER`]) — the sister project's seal-on-boot failure grew
//! its equivalent unboundedly (DIGEST-MILESTONE-PLAN risk #2); appending rotates
//! the oldest rows out. A re-bind of the *same* session is not a new boundary and
//! is deduped.
// [impl->REQ-TERM-6]

use std::io;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::atomic::atomic_write_string;

/// The ledger filename under a perch.
pub const LEDGER_FILE: &str = "sessions.log";

/// On-disk row cap. The ledger is a rolling tail — older rows rotate out on
/// append so the file can never grow without bound (risk #2).
pub const MAX_LEDGER: usize = 64;

/// Why a session became active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionTrigger {
    /// First bind of the perch (`api listen` / revive).
    Boot,
    /// A `/clear` boundary (`api boundary clear`).
    Clear,
    /// A `/compact` boundary (`api boundary compact`).
    Compact,
}

impl SessionTrigger {
    /// The lowercase token (matches the serde wire form) — used as the boundary
    /// marker `kind` in the spanned digest.
    pub fn token(self) -> &'static str {
        match self {
            SessionTrigger::Boot => "boot",
            SessionTrigger::Clear => "clear",
            SessionTrigger::Compact => "compact",
        }
    }
}

/// One ledger row: a `session_id` became active at `ts` via `trigger`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEntry {
    /// RFC3339-UTC instant (see [`crate::timefmt`]).
    pub ts: String,
    /// The harness session id that became active.
    pub session_id: String,
    /// What caused the rotation.
    pub trigger: SessionTrigger,
}

/// Every ledger row in order (oldest→newest). Tolerant: a blank or unparseable
/// line is skipped, the rest still load (the degrade-don't-crash store stance).
pub fn read_all(perch: &Path) -> Vec<SessionEntry> {
    let Ok(s) = std::fs::read_to_string(perch.join(LEDGER_FILE)) else {
        return Vec::new();
    };
    s.lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str(l).ok())
        .collect()
}

/// The last `k` rows (oldest→newest within the tail). `k == 0` ⇒ empty.
pub fn last_k(perch: &Path, k: usize) -> Vec<SessionEntry> {
    if k == 0 {
        return Vec::new();
    }
    let all = read_all(perch);
    let start = all.len().saturating_sub(k);
    all[start..].to_vec()
}

/// Append `entry`, deduping an immediate repeat of the same `session_id` (a
/// re-bind of the live session is not a new boundary) and bounding the ledger to
/// [`MAX_LEDGER`] (oldest rows rotate out). The whole bounded ledger is rewritten
/// atomically (it is tiny — ≤ `MAX_LEDGER` short rows — so a full rewrite avoids
/// partial-line corruption). Returns `true` if a row was written, `false` on a
/// same-session dedup.
// [impl->REQ-TERM-6]
pub fn append(perch: &Path, entry: &SessionEntry) -> io::Result<bool> {
    let mut entries = read_all(perch);
    if entries.last().map(|e| e.session_id.as_str()) == Some(entry.session_id.as_str()) {
        return Ok(false); // same session re-bound — not a new boundary
    }
    entries.push(entry.clone());
    let start = entries.len().saturating_sub(MAX_LEDGER);
    let body: String = entries[start..]
        .iter()
        .map(|e| serde_json::to_string(e).unwrap_or_default())
        .map(|l| l + "\n")
        .collect();
    std::fs::create_dir_all(perch)?;
    atomic_write_string(&perch.join(LEDGER_FILE), &body)?;
    Ok(true)
}

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

    fn entry(sid: &str, trigger: SessionTrigger) -> SessionEntry {
        SessionEntry {
            ts: crate::timefmt::rfc3339_utc(1_700_000_000),
            session_id: sid.to_string(),
            trigger,
        }
    }

    // [unit->REQ-TERM-6] rows append in order and read back oldest→newest.
    #[test]
    fn appends_and_reads_in_order() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        let all = read_all(&perch);
        assert_eq!(all.len(), 2);
        assert_eq!(all[0].session_id, "s1");
        assert_eq!(all[0].trigger, SessionTrigger::Boot);
        assert_eq!(all[1].session_id, "s2");
        assert_eq!(all[1].trigger, SessionTrigger::Clear);
    }

    // [unit->REQ-TERM-6] a re-bind of the same session is deduped (not a new
    // boundary); a different session appends.
    #[test]
    fn same_session_rebind_is_deduped() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        assert!(append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap());
        assert!(
            !append(&perch, &entry("s1", SessionTrigger::Boot)).unwrap(),
            "same session re-bind → no new row"
        );
        assert_eq!(read_all(&perch).len(), 1);
        assert!(append(&perch, &entry("s2", SessionTrigger::Clear)).unwrap());
        assert_eq!(read_all(&perch).len(), 2);
    }

    // [unit->REQ-TERM-6] last_k returns the newest K rows; the ledger is bounded so
    // it never grows past MAX_LEDGER.
    #[test]
    fn last_k_and_bounding() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        for i in 0..(MAX_LEDGER + 10) {
            append(&perch, &entry(&format!("s{i}"), SessionTrigger::Clear)).unwrap();
        }
        assert_eq!(read_all(&perch).len(), MAX_LEDGER, "ledger is bounded");
        let last3 = last_k(&perch, 3);
        assert_eq!(last3.len(), 3);
        let n = MAX_LEDGER + 10;
        assert_eq!(last3[2].session_id, format!("s{}", n - 1), "newest last");
        assert_eq!(last3[0].session_id, format!("s{}", n - 3));
        assert!(last_k(&perch, 0).is_empty());
    }

    // [unit->REQ-TERM-6] a blank/garbage line is skipped, the valid rows still load.
    #[test]
    fn tolerant_of_garbage_lines() {
        let dir = tempfile::tempdir().unwrap();
        let perch = dir.path().join("perch");
        std::fs::create_dir_all(&perch).unwrap();
        std::fs::write(
            perch.join(LEDGER_FILE),
            "not json\n\n{\"ts\":\"t\",\"session_id\":\"good\",\"trigger\":\"boot\"}\n",
        )
        .unwrap();
        let all = read_all(&perch);
        assert_eq!(all.len(), 1);
        assert_eq!(all[0].session_id, "good");
    }
}