/grill-with-docs new spt-core features and changes: -`../spt-claude-code/UPDATE-NAMING-DOYLE-ASKS.md` -remove the flag `--reply-to` from `spt send`. it's nothing more than a semantic nicety that confu…


CONTEXT.md grounded (rich glossary — manifest seams, `[hooks]`, `[update]`, translation-binary, harness-agnostic boundary all defined). My 4 surveys cover the standalone CLI asks (reply-to, digest, status-json, endpoint-run/rc). I still need the manifest-contract surfaces for doc-asks 1-3. Launching the 5th survey.


Agent

Survey manifest hooks/update/translation seams

Prompt:
In the spt-core repo (C:\Users\decid\Documents\projects\spt-core), I'm designing 3 manifest-contract changes. Survey the current shapes precisely (file:line + snippets):

1. The manifest `[hooks.<event>]` table — how hooks are declared today. Find the manifest parsing struct for hooks (likely in a manifest crate). What fields does a hook entry have (`fires`? `command`? `can_inject`?)? Is it declaration-only (just metadata spt-core reads) or does spt-core ever EXECUTE a hook handler? Search for "hooks" in manifest structs + docs/MANIFEST.md. Also: is there any existing `spt api run-hook` or hook-dispatch command?

2. The `[update]` manifest section + the `spt adapter update` command. Find: the `[update]` struct (avenue = gh_release | delegated | file-pull), the `gh_release` fields (repo, asset, signing_key), the `delegated` avenue (what command shape, the self_verifies/attest model), and `[update].message`. Where is `spt adapter update` implemented (the CLI that pulls gh_release + re-registers)? How does it decide "version changed" → print message? Look in docs/MANIFEST.md + the adapter update impl + ADR-0024/0025.

3. The `[message-idle-translation-binary]` manifest section. Find its struct — does it take `path` (bare) or `command`? How is the translation binary spawned (the resident binary lifecycle)? What's the stdin/stdout protocol? Compare to how `[digest].extractor` / `[session.psyche_init]` take their command strings (the asks doc says those take commands but translation takes a bare path).

Report file:line citations + exact struct/snippet for each. Thorough — these are contract changes I must rule on.


<owl_reachability_notice>
You have an active owl perch. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Async agent launched successfully.
agentId: ab9cc887dabafe308 (internal ID - do not mention to user. Use SendMessage with to: 'ab9cc887dabafe308', summary: '<5-10 word recap>' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes.
Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.
output_file: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\ab9cc887dabafe308.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.

Survey manifest hooks/update/translation seams

Prompt:
In the spt-core repo (C:\Users\decid\Documents\projects\spt-core), I'm designing 3 manifest-contract changes. Survey the current shapes precisely (file:line + snippets):

1. The manifest `[hooks.<event>]` table — how hooks are declared today. Find the manifest parsing struct for hooks (likely in a manifest crate). What fields does a hook entry have (`fires`? `command`? `can_inject`?)? Is it declaration-only (just metadata spt-core reads) or does spt-core ever EXECUTE a hook handler? Search for "hooks" in manifest structs + docs/MANIFEST.md. Also: is there any existing `spt api run-hook` or hook-dispatch command?

2. The `[update]` manifest section + the `spt adapter update` command. Find: the `[update]` struct (avenue = gh_release | delegated | file-pull), the `gh_release` fields (repo, asset, signing_key), the `delegated` avenue (what command shape, the self_verifies/attest model), and `[update].message`. Where is `spt adapter update` implemented (the CLI that pulls gh_release + re-registers)? How does it decide "version changed" → print message? Look in docs/MANIFEST.md + the adapter update impl + ADR-0024/0025.

3. The `[message-idle-translation-binary]` manifest section. Find its struct — does it take `path` (bare) or `command`? How is the translation binary spawned (the resident binary lifecycle)? What's the stdin/stdout protocol? Compare to how `[digest].extractor` / `[session.psyche_init]` take their command strings (the asks doc says those take commands but translation takes a bare path).

Report file:line citations + exact struct/snippet for each. Thorough — these are contract changes I must rule on.


5 code surveys running in background (reply-to, digest, status-json, endpoint-run/rc, and the manifest hooks/update/translate seams). Grounded in CONTEXT.md + ADR-0005/0006 + the asks doc. While surveys land, opening the grill at the dependency root.

I'll walk the tree one question at a time, recommending an answer each. Spine: **doc-asks 1→3→2** (the one-lever-update arc, where everything else hangs off Ask 1), then the **standalone CLI asks** (reply-to, digest, status `--json`, empty-endpoint flow, `rc` marker). The ADR-0005 repo rename (`spt-claude-code`→`claude-spt`) I'll just honor — `[update].repo` + install-dir derivation already expect the new slug; not re-litigating a decided heads-up.

---

**Q1 — Ask 1 (`spt api run-hook <adapter> <event>`): the harness-agnostic boundary.**

This is the keystone. CONTEXT.md is emphatic: *"spt-core itself contains zero Claude Code conventions."* Today `[hooks.<event>]` is declaration-only. Ask 1 wants spt-core to actively **execute** the adapter's hook handler — plugin `hooks.json` pre-wires every CC event → `spt api run-hook claude-spt <event>` (hook JSON on stdin), spt-core resolves the declared handler, runs it, pipes stdin through, surfaces the handler's stdout back as `additionalContext`.

The judgment: does spt-core running an adapter's hook handler **break harness-agnosticism**?

**My recommendation: it stays inside the boundary — it's a clean generalization, NOT a CC affordance — provided spt-core treats `<event>` as an opaque adapter-namespaced key and the handler as an opaque command.** Identical posture to every other manifest seam (*"spt-core never parses out a model/tool/flag — the adapter writes the whole command line"*). spt-core's whole job becomes: (1) resolve `adapter + event` → the handler command declared in the adapter's `[hooks]` table, (2) spawn it with the payload piped stdin→stdin **verbatim**, (3) relay handler stdout back as the api call's stdout. spt-core never learns what "PostToolUse" *means* — it's just a string key. That makes `run-hook` a harness-agnostic dispatch primitive.

The load-bearing sub-decision: **an event the manifest doesn't declare a handler for = clean no-op (exit 0, empty stdout)**, so the plugin's `hooks.json` can wire *all* CC events to the stub once and never change again (the whole point of Ask 1 — static-forever hooks.json). The alternative — spt-core refuses unknown events — re-couples `hooks.json` to the declared handler set and defeats the ask.

**So: run-hook = opaque dispatch + unhandled-event = silent no-op passthrough. Agree?** Or do you want unhandled events to surface a diagnostic (louder, but then a newly-wired-but-undeclared event isn't free)?


<task-notification>
<task-id>adfef26d05f96f1cf</task-id>
<tool-use-id>toolu_01KVRNUWsecMQco5NL9jx3wR</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\adfef26d05f96f1cf.output</output-file>
<status>completed</status>
<summary>Agent "Survey status queries + json" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Perfect! Now I have all the information I need to create a comprehensive report. Let me compile it:

## Comprehensive Status Query CLI Commands Surface Analysis

I've analyzed the spt-core repository's CLI surface. Here's the complete enumeration of read-only/status commands:

### 1. STATUS/QUERY COMMANDS (all read-only, no `--json` flag except `endpoint digest`)

#### **Top-level Status Commands**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt whoami` | cli.rs:4006 | Prints session's own endpoint (thin alias for `endpoint list`) | Human-readable table |
| `spt daemon status` | cli.rs:2510-2610 | Node status: daemon state, member subnets, local endpoints | Human-readable prose |
| `spt subnet status [<name>]` | cli.rs:5106-5257 | Subnet membership: name, paired nodes, endpoints; with `--nodes` shows per-node liveness | Human-readable table + Markdown |
| `spt subnet show-code [<name>]` | cli.rs:4497+ | Shows current 6-digit pairing code + QR + URI | Human-readable (gated by elevation) |

#### **Endpoint Operations (List/Info)**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt endpoint list [--detail] [--subnet <name>]` | cli.rs:2118-2193 | Merged endpoint listing; SELF pin first, then subnets grouped | Human-readable aligned table |
| `spt endpoint digest <id> [--follow] [--json]` | cli.rs:1309-1361 | Session activity buffer snapshot or live delta-stream | **Has `--json` flag** ✓ |
| `spt endpoint description [show] [<id>]` | cli.rs:1974+ | Shows endpoint's service-description blurb | Human-readable text |
| `spt endpoint role [<id>]` | cli.rs:2042-2118 | Shows endpoint's durable role statement | Human-readable markdown |

#### **Adapter Registry**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt adapter list` | cli.rs:6431-6457 | Registered adapters with kind, mode, profiles (shipped + local) | Human-readable hierarchical list |
| `spt adapter version <adapter[:profile]>` | cli.rs:6996+ | Prints adapter's declared version from manifest | Human-readable text |

#### **Notification/Grants/Access**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt notif list [--subnet <name>]` | cli.rs:2755-2789 | Lists notifications by id, state, from, body (first line) | Tab-separated columns |
| `spt grant list [<agent>]` | cli.rs:8387-8402 | Lists capability grants as `capability:agent:qualifier @ node` | Human-readable rows |
| `spt endpoint access list [<endpoint>]` | cli.rs:6151-6175 | Lists access restrictions (OPEN/RESTRICTED/LOCKED per endpoint) | Human-readable prose |

#### **Shell Instances**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt shell list [--owner <id>]` | cli.rs:7637-7662 | Own instances (alias-first display) + instantiable shell adapters | Human-readable hierarchical list |

#### **How-To (Instructions)**

| Command | File:Line | Description | Current Output Format |
|---------|-----------|-------------|----------------------|
| `spt how-to [<topic>]` | cli.rs:6028+ | Task-oriented instructions for agents; lists topics or prints one | Human-readable markdown prose |

---

### 2. EXISTING JSON OUTPUT

**ONLY ONE command has `--json` flag:**

- **`spt endpoint digest <id> [--follow] [--json]`** (cli.rs:408-411, dispatch at 1309-1361)
  - **File:Line Definition:** cli.rs:408-411
  - **Implementation:**
    - Snapshot: `println!("{}", spt_daemon::digest_to_json(&digest));` (line 1344)
    - Stream (--follow): `println!("{}", spt_daemon::update_to_json(up));` (line 1325)
  - **Source:** `spt-daemon/src/digesthub.rs:360-368`
    ```rust
    pub fn digest_to_json(digest: &Digest) -> String {
        serde_json::to_string_pretty(digest).unwrap_or_else(|_| "{}".to_string())
    }
    pub fn update_to_json(update: &DigestUpdate) -> String {
        serde_json::to_string(update).unwrap_or_else(|_| "{}".to_string())
    }
    ```

---

### 3. OUTPUT FORMATTING STRATEGY

**NO shared formatter exists.** Each command formats ad-hoc:

1. **`endpoint list` / `whoami`**: `format_subnet_rows()` (cli.rs:2204-2239)
   - Builds aligned character-width table; no JSON variant

2. **`daemon status`**: Direct println! + `render_connection_lines()` + prose blocks (cli.rs:2510-2610)
   - Inline Markdown rendered via `crate::helpfmt::render()` (REQ-CLI-OUTPUT-MARKDOWN)

3. **`subnet status`**: `render_subnet_status()` / `render_node_rows()` (cli.rs:4623-4881, 5106-5257)
   - Aligned table; Markdown prose hints; `helpfmt::render()` for styling

4. **`endpoint digest`**: **ONLY JSON-aware command**
   - Uses `spt_daemon::{digest_to_json, update_to_json}` (spt-daemon/src/digesthub.rs)

5. **`adapter list`**: Direct println! loop; no formatter (cli.rs:6431-6457)

6. **`notif list`, `grant list`, `access list`, `shell list`**: Direct println! per row; tab-separated or colon-delimited

7. **`how-to`**: Raw markdown + `helpfmt::render()` (cli.rs:6028+)

---

### 4. DATA STRUCTURES & SERIALIZATION READINESS

#### Structures That Need JSON Support

| Structure | Location | Serialize Trait | Notes |
|-----------|----------|-----------------|-------|
| `Digest` | spt-term crate | ✓ Yes | Already serialized in digest output |
| `DigestUpdate` | spt-daemon/digest.rs | ✓ Yes (implied) | Used in `update_to_json()` |
| `SubnetStatusRow` | cli.rs:4562-4568 | ✗ **No** | (name, nodes: usize, endpoints: usize) |
| `NodeRow` | cli.rs:4765-4773 | ✗ **No** | (node, label, active, total, liveness) |
| `GrantRow` | spt-store/grants.rs | Need to check | Used in grant list |
| `AccessEntry` | spt-store/access.rs | Need to check | Used in access list |
| Shell instance row | spt-daemon/shelldisc.rs | Need to check | Via `shelldisc::instance_lines()` |
| Adapter record | spt-runtime/registry.rs | ✓ Likely (registry ops) | Manifest-backed |
| Notification row | spt-store/notif.rs | Need to check | List uses raw struct fields |

#### Confirmed Serialize Derives in spt-daemon

```bash
grep -r "#\[derive.*Serialize" crates/spt-daemon/src/ | head examples:
- crates/spt-daemon/src/config.rs: DaemonConfig
- crates/spt-daemon/src/digest.rs: Digest, DigestEntry (via spt-term)
- crates/spt-daemon/src/digesthub.rs: DigestResult, DigestUpdate
- crates/spt-daemon/src/msg.rs: [Multiple message types]
- crates/spt-daemon/src/frame.rs: [Frame types]
```

---

### 5. TRACEABLE-REQS.MD REFERENCES

**Relevant CLI-related requirements in `traceable-reqs.toml`:**

| REQ ID | Title | File:Line | Relation to JSON |
|--------|-------|-----------|------------------|
| REQ-CLI-1 | Endpoint operations (M8 D1-2, 25) | traceable-reqs.toml:1008 | Covers `endpoint list/run/fork/suspend/wake/shutdown/stop/rename/purge/digest/access/description/role` |
| REQ-CLI-2 | Daemon status view (M8 D5, 23, 25b) | traceable-reqs.toml:1012 | Covers `daemon run/start/stop/status` + pump heartbeat rendering |
| REQ-CLI-3 | Subnet operations (M7 D3) | traceable-reqs.toml:1016 | Covers `subnet status/create/show-code/join/leave/prune/revoke/detach/attach/notify` |
| REQ-CLI-4 | Shell lifecycle (M5 D3) | traceable-reqs.toml:1020 | Covers `shell spawn/list/teardown/rename/cmd/drive/tunnel/send/relink` |
| REQ-CLI-HELP-MARKDOWN | Help text renders inline Markdown | traceable-reqs.toml:1182 | Applied via `helpfmt::render()` (only `--help`, not command output) |
| REQ-CLI-OUTPUT-MARKDOWN | Command output renders inline Markdown | traceable-reqs.toml:1194 | Applied to human prose (daemon status, subnet status hints); NOT to JSON |
| REQ-TERM-4 | Digest control channel (ADR-0008) | traceable-reqs.toml (search) | Only `--json` surface; spt-term `Digest` already Serialize |

**M8 decision 1 (REQ-CLI-1):** "Endpoint operations unified under `spt endpoint` noun" — the list, digest, and other verbs live here. No JSON output planned in the M8 design doc.

---

### 6. SURFACE AREA SUMMARY FOR `--json` FLAG DESIGN

**Read-only commands that would need a `--json` variant:**

1. ✓ **`endpoint digest`** — DONE (already has `--json`)
2. ✓ **`endpoint list`** — Would serialize `Vec<ResourceRow>` (already in registry, serde-compatible)
3. ✓ **`endpoint description show`** — Single string output; trivial `{ "description": "text" }`
4. ✓ **`endpoint role`** — Single string output; trivial `{ "role": "markdown" }`
5. ✓ **`daemon status`** — Would serialize daemon state + subnets + endpoints struct
6. ✓ **`subnet status [--nodes]`** — Would serialize `Vec<SubnetStatusRow>` (+ optionally `Vec<NodeRow>` with --nodes)
7. ✓ **`subnet show-code`** — Would serialize `{ "subnet": "name", "code": "123456", "uri": "...", "expires": "..." }`
8. ✓ **`adapter list`** — Would serialize `Vec<AdapterRecord>` with profiles
9. ✓ **`adapter version`** — Single string; trivial `{ "adapter": "name", "version": "0.9.0" }`
10. ✓ **`notif list`** — Would serialize `Vec<NotifRow>` (with id, subnet, state, from_id, body, dismissed, seen)
11. ✓ **`grant list`** — Would serialize `Vec<GrantRow>` (with capability, agent, qualifier, node)
12. ✓ **`access list`** — Would serialize `Vec<AccessEntry>` (with endpoint, nodes[])
13. ✓ **`shell list`** — Would serialize `{ "own": Vec<ShellInstance>, "instantiable": Vec<AdapterOption> }`
14. ✓ **`how-to`** — Would serialize `{ "topics": [strings] }` or `{ "topic": "name", "text": "..." }`
15. ⚠ **`whoami`** — Alias for `endpoint list`; reuse same output

---

### 7. KEY FINDINGS

1. **NO shared output formatter exists** — each command builds its own println! / format! chain
   - `format_subnet_rows()` is the only table-aligned helper (and it's CLI-specific)
   - No generic `OutputFormatter` trait or enum dispatch

2. **`endpoint digest` is the ONLY existing `--json` command**
   - Pattern: direct `serde_json::to_string_pretty()` / `serde_json::to_string()`
   - Model to follow for others

3. **Most data structures DON'T yet have `#[derive(Serialize)]`**
   - `SubnetStatusRow`, `NodeRow` (cli.rs locals) — would need to add
   - `GrantRow` (spt-store/grants.rs) — check if already Serialize
   - Shell instance rows — dynamically rendered via `shelldisc::{instance_lines, instantiable_lines}()`
   - Notification rows — built from raw struct fields in the loop

4. **REQ-CLI-OUTPUT-MARKDOWN applies ONLY to human prose**
   - Inline `**bold**`, `` `code` ``, `[link](url)` rendered via `helpfmt::render()`
   - JSON output is NEVER routed through this renderer (per REQ-CLI-OUTPUT-MARKDOWN v0.12.2 comment)
   - Machine tokens (`ENDPOINT_RUN_STARTED:`, etc.) are kept marker-free so they survive the strip

5. **Design considerations for unified `--json` across all status commands:**
   - **Option A (Stateless, per-command):** Each command adds its own `--json` flag + output directly (like `digest`)
   - **Option B (Shared helper):** Add a generic `OutputFormatter { human: String, json: serde_json::Value }` struct in a shared module; each command builds both halves
   - **Option C (Middleware):** One `--output {human|json}` global flag across all commands (bigger refactor)
   - The codebase style prefers **Option A** (lean, per-command, no shared trait bloat)

---

### 8. FILES TO MODIFY FOR COMPLETE `--json` ROLLOUT

**CLI argument layer:**
- `crates/spt/src/cli.rs` — Add `json: bool` to each command's enum variant

**Output layer (per command):**
- `cmd_endpoint_list()` (2118) — serialize `Vec<ResourceRow>`
- `cmd_endpoint_description()` (1974) — serialize `{ "description": string }`
- `cmd_endpoint_role()` (2042) — serialize `{ "role": string }`
- `cmd_daemon_status()` (2510) — serialize daemon state struct
- `cmd_subnet_status()` (5106) — serialize `Vec<SubnetStatusRow>` / `Vec<NodeRow>`
- `cmd_subnet_show_code()` (4497) — serialize `{ "subnet", "code", "uri", "expires_in_seconds" }`
- `cmd_adapter()` (6205) → list branch (6431) — serialize `Vec<AdapterRecord>`
- `cmd_adapter_version()` (6996) — serialize `{ "adapter", "version" }`
- `cmd_notif_list()` (2755) — serialize notification rows
- `cmd_grant()` (8327) → list branch (8387) — serialize grant rows
- `cmd_access()` (6083) → list branch (6151) — serialize access entries
- `cmd_shell()` (7347) → list branch (7637) — serialize shell instances + instantiable

**Data structure layer (add Serialize derives where missing):**
- `cli.rs` — `SubnetStatusRow`, `NodeRow`
- `spt-store/src/grants.rs` — `GrantRow` (check existing)
- `spt-store/src/access.rs` — `AccessEntry` (check existing)
- `spt-store/src/notif.rs` — notif row struct (check existing)
- `spt-daemon/src/shelldisc.rs` — shell instance/capability structs

---

This analysis gives you the complete surface area for designing a unified `--json` flag strategy. The codebase is highly modular; adding JSON output is straightforward per-command without requiring a shared formatter (though you could build one incrementally).</result>
<usage><subagent_tokens>95485</subagent_tokens><tool_uses>44</tool_uses><duration_ms>127959</duration_ms></usage>
</task-notification>


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


<owl_messages>
<owl_message from="doyle-w409" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w409
Agent type: Explore
To message it: $OWL send doyle-w409 doyle</owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.

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


<task-notification>
<task-id>ae6e4be9294eb7205</task-id>
<tool-use-id>toolu_01YazeraUiCpAdeEhEhNwE7i</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\ae6e4be9294eb7205.output</output-file>
<status>completed</status>
<summary>Agent "Survey digest command" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Perfect! Now I have all the information. Let me compile my final report.

## Comprehensive Design Report: `spt endpoint digest` Implementation & Storage

### 1. The CLI Command Definition

**File:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs`

**Lines 402-411** — the `EndpointCmd::Digest` enum variant:
```rust
Digest {
    /// The (local) endpoint id to read.
    id: String,
    /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
    #[arg(long)]
    follow: bool,
    /// Emit structured JSON instead of the human-glanceable render.
    #[arg(long)]
    json: bool,
},
```

**Lines 1309-1361** — the command implementation (`cmd_digest`):
- **Flags:** `--follow` (delta-stream mode), `--json` (structured output)
- **Output modes:**
  - **Human-readable (default):** single-line-per-entry format via `render_digest()` / `render_update()`
  - **JSON:** pretty-printed `Digest` struct for snapshot (`digest_to_json`), compact one-line `DigestUpdate` per delta (`update_to_json`)
- **No current `--last`, `--after`, or pagination flags**
- **Status output:** stderr line `DIGEST:{id} version={version}` for snapshots

---

### 2. What a "Digest" IS: Data Model & Storage

#### 2.1 The Structured Output Type

**File:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-term/src/digest.rs` (lines 44–103)

**Core types:**

```rust
// Line 45-51: Single tool invocation
pub struct ToolUse {
    pub name: String,        // "Write", "Bash", etc.
    pub arg: String,         // truncated at ~25 chars
}

// Lines 54-86: Enum of entry types within a turn
pub enum DigestEntry {
    Agent(String),                                // agent output text
    ToolSprint(Vec<ToolUse>),                    // collapsed consecutive tools
    Boundary { kind: String, ts: Option<String> }, // "/clear", "/compact", "boot"
    Context { kind: String, body: String, ts: Option<String> }, // "psyche_download"|"echo_mirror"|"owl_message"
}

// Lines 90-96: One user turn
pub struct Turn {
    pub input: Option<String>,      // user input that opened the turn
    pub entries: Vec<DigestEntry>,  // agent + tool + context entries
}

// Lines 99-103: The rolling digest
pub struct Digest {
    pub turns: Vec<Turn>,  // oldest→newest, at most `window_turns` of them
}

// Lines 22-41: Presentation config (3-turn window, 25-char arg truncation, sprint collapsing)
pub struct DigestConfig {
    pub window_turns: usize,        // default 3
    pub arg_truncation: usize,      // default 25
    pub sprint_collapse: bool,      // default true
}
```

#### 2.2 The Published Contract (DigestRecord)

**File:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-term/src/projection.rs` (lines 84–106)

One normalized line (JSONL format) in a history source or `digest.log`:

```rust
pub struct DigestRecord {
    pub role: DigestRole,           // Input | Agent | Tool
    pub text: String,               // input/agent span (empty for tool)
    pub tool: Option<ToolRef>,      // {name, arg} for role==Tool
    pub ts: Option<String>,         // RFC3339-UTC ordering key (ADR-0019)
}
```

**Wire examples:**
```json
{"role":"input","text":"fix the bug","ts":"2026-06-13T21:00:00Z"}
{"role":"agent","text":"on it"}
{"role":"tool","tool":{"name":"Write","arg":"src/a.rs"}}
```

---

### 3. How Digests Are Produced & Stored

#### 3.1 Two-Origin Production (ADR-0019, REQ-TERM-4, REQ-TERM-5, REQ-TERM-6, REQ-TERM-7)

**File:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/digest.rs` (lines 134–180)

The digest **projects on demand** from two sources:

1. **Activity backbone** (adapter-extracted or log-less):
   - **Path A (with `[digest]` extractor):** enumerate last K sessions from `<perch>/sessions.log`, run the adapter's declared `[digest]` extractor per session, parse lines into `DigestRecord`s, splice a `TimelineItem::Boundary` between sessions (lines 182–253).
   - **Path B (log-less adapter):** read the endpoint's `<perch>/digest.log`, parse activity lines (skip context entries), convert to timeline items (lines 260–292).

2. **Context-injection entries** (spt-owned):
   - From the **same** `<perch>/digest.log` (two-origin merge via discriminator `context_kind`).
   - Subtypes: `psyche_download` | `echo_mirror` | `owl_message`.
   - Merged by RFC3339-UTC timestamp (lines 294–308).

**Function:** `project_endpoint_digest(id: &str, over: &DigestOverride) -> Digest` (lines 134–180)
- Resolves adapter + manifest
- Reads `<perch>/info.json` for adapter + session_id
- Calls `activity_spanned()` or `log_less_activity()`
- Reads spt context entries via `read_context_items()`
- Merges by timestamp via `merge_by_ts()`
- Folds through `spt_term::project_timeline()` with effective `DigestConfig`

#### 3.2 Storage: The Session Ledger & Digest Log

**File:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-store/src/sessions.rs` (lines 1–120)

**`<perch>/sessions.log`** — JSONL ledger of session rotations:
```json
{"ts":"2026-06-13T21:00:00Z","session_id":"A","trigger":"boot","cwd":null}
{"ts":"2026-06-13T21:10:00Z","session_id":"B","trigger":"clear","cwd":"/proj"}
```
- **Records:** `ts` (RFC3339-UTC), `session_id`, `trigger` (boot|clear|compact), `cwd` (optional)
- **Bounded:** max 64 rows (`MAX_LEDGER`); oldest rotate out on append
- **Dedupe:** same `session_id` re-bound → not a new row

**File:** `/c/Users/decid/Documents/projects/spt-store/src/history.rs` (lines 28–58)

**`<perch>/digest.log`** — JSONL sink for **both** activity and context entries:
```json
{"role":"input","text":"hello","ts":"2026-06-13T21:00:00Z"}
{"role":"agent","text":"hi there"}
{"context_kind":"psyche_download","body":"3 notes","ts":"2026-06-13T21:00:05Z"}
{"role":"tool","tool":{"name":"Write","arg":"a.rs"}}
```
- **Path B source:** adapter or harness pushes activity records via `api digest-entry`
- **spt injection:** context entries appended via `append_context_entry()` (lines 50–58)
- **Separate from `history.log`:** never mixed with the opaque transcript

---

### 4. No Current Line Numbers or Ordering Notion

**Critical finding:** The `Digest` and `Turn` structs have **no indexing fields**.

- **Turns are ordered but NOT indexed:** `Digest.turns` is a `Vec<Turn>` in oldest→newest order, accessible only by position.
- **No "line" concept:** The projection folds normalized records into semantic turns; the underlying line numbers from `digest.log` or session extractors are **lost after parsing**.
- **No ordering key on turns:** Turns have no `ts`, `id`, or sequence number — they are ordered by stream position only.
- **DigestEntry variants** carry `ts` for context/boundary items but not entries themselves.

**Implication for `--after <line>`:** Would need to **preserve line metadata** through the projection, or switch to a **line-keyed access** (e.g., reading raw digest.log directly before projection).

---

### 5. Existing Pagination & Filtering Patterns

#### 5.1 DigestOverride (Consumer Presentation Tuning)

**File:** `/c/Users/decid/Documents/projects/spt-daemon/src/digest.rs` (lines 58–70)

```rust
pub struct DigestOverride {
    pub window_turns: Option<usize>,        // override adapter's default window
    pub arg_truncation: Option<usize>,      // override arg width
    pub sprint_collapse: Option<bool>,      // override tool collapsing
}
```

- **Current usage:** passed in `DigestReq` to override presentation at pull/subscribe
- **No JSON request yet:** the CLI currently does not surface `DigestOverride` flags

#### 5.2 DigestUpdate Delta Stream (Subscription Versioning)

**File:** `/c/Users/decid/Documents/projects/spt-daemon/src/digest.rs` (lines 101–113)

```rust
pub struct DigestUpdate {
    pub version: u64,              // monotonic version (starts at 1)
    pub from: usize,               // window index where turns begin
    pub turns: Vec<Turn>,          // changed turns starting at `from`
}
```

- **Delta strategy:** push-driven; `--follow` uses `DigestReq::from_version` to skip old changes
- **Version 0 = no digest yet**
- **Full refresh (`from == 0`)** when window slides (front eviction)

#### 5.3 The Snapshot & Follow Paths

**File:** `/c/Users/decid/Documents/projects/spt-daemon/src/digesthub.rs`

- **`KIND_DIGEST_SNAPSHOT`** (lines 43, 196–213): pull current `Digest` on demand
- **`KIND_DIGEST_SUBSCRIBE`** (lines 47, 222–249): stream `DigestUpdate`s, starting from `from_version` floor
- **`KIND_DIGEST_REPROJECT`** (lines 51, 214–221): fire-and-forget re-project after `api digest-entry` push

**No flags for pagination, filtering, or line-based access.**

---

### 6. CC Stop Hook / Turn-End Concept

**Not found in digest-related code.**

- Digests are **projections of past logs**, not real-time turn tracking.
- Session boundaries are **explicitly marked** via `SessionTrigger::Boot|Clear|Compact`.
- The `--follow` mode streams deltas as projections update (file-watch is deferred).
- **No "turn-end hook" in the digest surface** — the projection folds records into turns semantically (input opens, tools/agent appends, next input closes).

---

### 7. Relevant traceable-reqs.toml REQ Entries

**File:** `/c/Users/decid/Documents/projects/spt-core/traceable-reqs.toml`

| REQ ID | Title | Implementation |
|--------|-------|-----------------|
| **REQ-TERM-4** | Live activity buffer snapshot + delta-stream + `api digest-entry` push | `/crates/spt-daemon/digest.rs`, `digesthub.rs`, `/crates/spt-term/projection.rs` |
| **REQ-TERM-5** | Adapter `[digest]` extractor seam + presentation defaults + `spt adapter digest-proof` | `/crates/spt-daemon/digest.rs:134–180` |
| **REQ-TERM-6** | Thread-spanning digest across K sessions via ledger | `/crates/spt-store/sessions.rs`, `/crates/spt-daemon/digest.rs:182–253` |
| **REQ-TERM-7** | Two-origin merge: activity + spt context-injection by timestamp | `/crates/spt-daemon/digest.rs:294–350`, `/crates/spt-store/history.rs:50–58` |

---

### 8. Design Implications for `spt endpoint digest --last <N> --json --after <line>`

#### Challenge 1: No Line Metadata in Turns
The projection **loses raw line numbers**. To implement `--after <line>`:
- **Option A:** Store `line_no: usize` in `DigestRecord` or `Turn` (retroactive addition)
- **Option B:** Read `digest.log` raw, count lines, slice before projection
- **Option C:** Use turn index as the "cursor" (`--after <turn_index>`)

#### Challenge 2: Window Semantics
The digest is a **rolling N-turn window**, not a full history:
- `--last 5` means "keep the last 5 turns after projection" (not pre-projection)
- A slide (old turn evicts) resets the cursor
- **Interaction:** `--last` + `--after <line>` may conflict if the line is outside the current window

#### Challenge 3: Version-Keyed Subscriptions
The `--follow` mode uses `DigestReq::from_version`. A line-keyed cursor would need:
- **New resume key:** `--after-version <v>` (integer, simpler) vs. `--after-line <n>` (fragile)
- **Carry metadata:** amend `DigestUpdate` to include source-line ranges

---

### Summary Table: Current Shape

| Aspect | Current State |
|--------|---------------|
| **CLI flags** | `--follow`, `--json` only |
| **Output format** | Human (lines + indent) or JSON (pretty snapshot / compact updates) |
| **Turns indexing** | None; positional only in `Vec<Turn>` |
| **Line metadata** | Lost after projection |
| **Session spanning** | Yes; ledger tracks rotations, last K sessions enumerated |
| **Timestamps** | On context/boundary entries; no turn-level `ts` |
| **Version tracking** | Yes; `DigestUpdate.version` (1-indexed) |
| **Pagination** | None (N-turn window only) |
| **Filtering** | None (full window only) |

---

### Key File Locations for Implementation

1. **CLI definition:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:402–411`
2. **CLI handler:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:1309–1361`
3. **Projection logic:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/digest.rs:134–180`
4. **Hub (snapshot + subscribe):** `/c/Users/decid/Documents/projects/spt-core/crates/spt-daemon/src/digesthub.rs:41–337`
5. **Session ledger:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-store/src/sessions.rs:59–120`
6. **Digest log (storage):** `/c/Users/decid/Documents/projects/spt-core/crates/spt-store/src/history.rs:28–76`
7. **Structured types:** `/c/Users/decid/Documents/projects/spt-core/crates/spt-term/src/digest.rs:44–103`
8. **Projection (turn folding):** `/c/Users/decid/Documents/projects/spt-core/crates/spt-term/src/projection.rs:185–244`</result>
<usage><subagent_tokens>78399</subagent_tokens><tool_uses>36</tool_uses><duration_ms>142735</duration_ms></usage>
</task-notification>


(Survey landing in background — noting one nugget that reshapes a later question: **`spt endpoint digest <id>` already has `--json`** (cli.rs:408, via `digest_to_json`/`update_to_json`). So your digest ask is *augmenting* an existing JSON command with `--last`/`--after`, not building new. Also: no shared output formatter exists; ~14 status commands, most structs not yet `Serialize`. Relevant when we reach Ask C/digest.)

Still holding for your **Q1** answer (run-hook = opaque dispatch + unhandled-event silent no-op — agree, or want a diagnostic on unhandled?).


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


<task-notification>
<task-id>ac7e42f4d2e94ae6a</task-id>
<tool-use-id>toolu_01Q5M5eM25W6cjqMrAVCHb2q</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\ac7e42f4d2e94ae6a.output</output-file>
<status>completed</status>
<summary>Agent "Survey spt send + reply-to" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Perfect! Now I have everything I need. Let me compile a comprehensive report with all the file:line citations.

## Summary

I've conducted a thorough search of the spt-core repository to document the `--reply-to` flag implementation. Here are my findings:

### 1. CLI Command Definition (clap args/flags)

**File: C:/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:65-108**

```rust
Send {
    /// Target perch id. May be omitted when --reply-to is given.
    target: Option<String>,
    /// Sender id carried structurally as the message `from` (auto-detected from session if omitted).
    #[arg(long)]
    from: Option<String>,
    /// Reply to this sender; sets the target and labels output REPLIED.
    #[arg(long)]
    reply_to: Option<String>,
    // ... other flags ...
}
```

**Key details:**
- Lines 71-73: Doc comment clearly states "Reply to this sender; sets the target and labels output REPLIED."
- Type: `Option<String>`
- Clap attr: `#[arg(long)]` (no value_name, conflicts, or visibility modifiers)
- Required: No (optional argument)

### 2. How `--reply-to` Flows Through to Message Delivery

**File: C:/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:1152-1174** (dispatch)

The flag is passed to the `cmd_send` function.

**File: C:/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:3609-3616** (cmd_send function)

```rust
fn cmd_send(
    target: Option<String>,
    from: Option<String>,
    reply_to: Option<String>,
    // ... other params ...
) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
```

**Flow:**
- Line 3609: `is_reply` boolean is set when `reply_to.is_some()`
- Line 3611: `reply_to` is used as a fallback for the `target` (via `target.or_else(|| reply_to.clone())`)
- Line 3657 & 3704: The `is_reply` flag is used in output labeling:
  - `if is_reply { "REPLIED" } else { "SENT" }` — changes the output label only
  - Similar pattern at lines 3714, 3740

**Critical finding:** `--reply-to` ONLY affects:
1. Which target receives the message (fallback if no positional target)
2. The output label ("REPLIED" vs "SENT")
3. It does NOT create a `__REPLY_TO__` token, correlation field, or any wire-format change

### 3. EVENT Envelope Format Definition

**File: C:/Users/decid/Documents/projects/spt-proto/src/event.rs:166-172**

```rust
pub fn compose_msg_event(from: &str, body: &str) -> String {
    format!(
        "<EVENT type=\"msg\" from=\"{}\">{}</EVENT>",
        event_attr_escape(from),
        event_body_escape(body)
    )
}
```

**File: C:/Users/decid/Documents/projects/spt-proto/src/event.rs:189-202**

```rust
pub fn compose_typed_event(event_type: &str, attrs: &[(&str, &str)], body: &str) -> String {
    let mut s = format!("<EVENT type=\"{}\"", event_attr_escape(event_type));
    for (k, v) in attrs {
        s.push(' ');
        s.push_str(k);
        s.push_str("=\"");
        s.push_str(&event_attr_escape(v));
        s.push('"');
    }
    s.push('>');
    s.push_str(&event_body_escape(body));
    s.push_str("</EVENT>");
    s
}
```

**Wire format definition at lines 10-12 of event.rs:**
- regular message → `<EVENT type="msg" from="...">body</EVENT>`
- No `reply-to` or `reply_to` attribute is ever composed
- The `from` attribute is the **only** field that identifies the sender

### 4. TCP Wire Format (Low-level message encoding)

**File: C:/Users/decid/Documents/projects/spt-msg/src/wire.rs:28-36**

```rust
pub fn encode_frame(from_id: &str, body: &str) -> Vec<u8> {
    let from_bytes = from_id.as_bytes();
    let body_bytes = body.as_bytes();
    let mut payload = Vec::with_capacity(4 + from_bytes.len() + body_bytes.len());
    payload.extend_from_slice(&(from_bytes.len() as u32).to_be_bytes());
    payload.extend_from_slice(from_bytes);
    payload.extend_from_slice(body_bytes);
    payload
}
```

**Frame structure (lines 9-12):**
- `[u32 BE len(from)][from bytes][body bytes]`
- No `__REPLY_TO__` string (this is explicitly stated in ADR-0020)
- Carries `(from, body)` structurally only

### 5. Tests Mentioning `--reply-to` / `reply-to`

**File: C:/Users/decid/Documents/projects/spt/tests/** - No test files found with `reply_to` or `reply-to` patterns (grep found 0 results)

**File: C:/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:12555-12624** (how-to documentation tests)

```rust
fn how_to_topics_cover_ready_and_send() {
    // ... HOW_TO_TOPICS asserts the topic names ...
    let send = HOW_TO_TOPICS[1].2;
    // Lines 12599: asserts HOW_TO_SEND content exists and covers `reply_to`
}
```

### 6. Documentation: help text and guides

**File: C:/Users/decid/Documents/projects/spt/src/cli.rs:5885-5909** (HOW_TO_SEND)

```
Reply to a message you received (body from stdin; prints `REPLIED:<sender>`):

    spt send --reply-to <sender>
```

This is the **only** user-facing documentation for `--reply-to`.

**File: C:/Users/decid/Documents/projects/spt/src/cli.rs:6005-6008** (HOW_TO_TOPICS registry)

```rust
(
    "send",
    "deliver messages: send vs ring, replies, SENT vs QUEUED",
    HOW_TO_SEND,
),
```

### 7. traceable-reqs.toml Entries

**File: C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml:385**

```
id = "REQ-DOCS-6"
title = "... Topics v1: `ready` (background-task guidance, --once fallback loop, reply mechanics) +
`send` (send/ring, reply-to, SENT vs QUEUED) ..."
```

Line 385 mentions `reply-to` as part of the `send` topic guidance (REQ-DOCS-6).

**File: C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml:292**

```
id = "REQ-MSG-1"
title = "Local message delivery: TCP-first to a registered address, spool fallback when offline;
id->address via registry (stale-clean first); reply routing (__REPLY_TO__)"
```

This mentions `__REPLY_TO__` but see the next requirement which supersedes it.

**File: C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml:307-309** (REQ-MSG-ENVELOPE)

This is the **critical** requirement that supersedes the `__REPLY_TO__` references:

```
id = "REQ-MSG-ENVELOPE"
title = "The <EVENT type=\"msg\" from=…>body</EVENT> envelope ... __REPLY_TO__ —
mis-elevated during the clean-room port to a fake ADR-0001 'stable wire format' — is REMOVED
entirely (spool format_row, the spt-msg TCP frame, emit parse_frame); ... Reply-correlation
rebinds onto the structural from / <EVENT from=…> attribute (ADR-0009 access-gate + ADR-0012
Psyche/spt-live reply-target). ... ADR-0020."
required_stages = ["doc", "impl", "unit", "int"]
# activated 2026-06-15 — the multi-crate refactor landed
```

### 8. Architecture Decision Records (ADRs)

**File: C:/Users/decid/Documents/projects/spt-core/docs/adr/0020-event-envelope-sole-arriving-format-reply-to-removed.md**

This ADR (accepted 2026-06-15) **documents the complete removal of `__REPLY_TO__`** and explains:

- **Line 89-93:** `__REPLY_TO__` is removed from spt-core entirely: the spt-msg TCP frame, the spool serialization, and the parse. Messages carry `(from, body)` structurally through the internals; `<EVENT>` is composed once, at the delivery boundary.

- **Line 98-106:** Reply-correlation rebinds onto the structural `from` / `<EVENT from=…>` attribute (ADR-0009 access gate + ADR-0012 Psyche reply-target).

- **Line 125-141:** Migration consequences enumerate every site that needs changes.

---

## Conclusion: Complete Removal Scope

**For clean removal of `--reply-to`, you only need to modify:**

1. **C:/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs**
   - Lines 66-67: Remove the `target: Option<String>` comment mentioning reply-to
   - Lines 71-73: Remove the entire `reply_to: Option<String>` field
   - Lines 3609: Remove `let is_reply = reply_to.is_some();`
   - Lines 3611: Simplify to just `let target = target.ok_or(...)`
   - Lines 3657, 3704, 3714, 3740: Change conditional output labels (remove is_reply checks, always use "SENT"/"QUEUED")
   - Line 1155-1156: Remove `reply_to` from the match destructuring and function call
   - Lines 5899: Update HOW_TO_SEND to remove the --reply-to example

2. **C:/Users/decid/Documents/projects/spt-core/traceable-reqs.toml**
   - Line 385: Update REQ-DOCS-6 title to remove "reply-to" mention
   - Optionally remove or update REQ-MSG-1 reference to `__REPLY_TO__` (already superseded by REQ-MSG-ENVELOPE)

**No changes needed in:**
- Wire format (already carries `(from, body)` structurally)
- EVENT envelope (already only has `from` attribute, not reply-to)
- Tests (no tests exist for this flag)
- Daemon or lower layers (no `__REPLY_TO__` token anywhere post-ADR-0020)</result>
<usage><subagent_tokens>73344</subagent_tokens><tool_uses>43</tool_uses><duration_ms>160097</duration_ms></usage>
</task-notification>


<task-notification>
<task-id>a21ce310dd7857864</task-id>
<tool-use-id>toolu_01FUisg2NtEMpB7AtjccRsKa</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\a21ce310dd7857864.output</output-file>
<status>completed</status>
<summary>Agent "Survey endpoint run + rc terminal" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Perfect! Now I have good understanding of both parts. Let me create a comprehensive report with exact file:line citations and code snippets.

## Summary Report: Surface Analysis

I've thoroughly analyzed the spt-core repository and found all the surfaces you asked for. Here's the detailed report with exact file:line citations and code snippets:

---

## **PART A: `spt endpoint run` (and bare `spt`) Endpoint/Picker Behavior**

### **1. The CLI Command Definitions**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs` (line 166–169)**

```rust
Endpoint {
    #[command(subcommand)]
    action: Option<EndpointCmd>,
},
```

The `Endpoint` variant accepts an optional subcommand (parsed as `Option<EndpointCmd>`), which allows bare `spt endpoint` (no subcommand).

**EndpointCmd::Run definition (lines 273–312):**

```rust
/// Bring up an spt-hosted harness endpoint into a broker-held PTY.
///
/// Spawns the adapter's `[session.self]` command into a broker-owned PTY
/// (the harness self-registers its perch on bind), then starts / attaches /
/// views per the terminal-action flag. The endpoint id rides argv so the
/// harness binds to exactly it. This is the non-interactive core (the
/// interactive picker lands in a later wave); the flags cover every terminal
/// action so a `spt-<id>` shortcut can bake a fully non-interactive launch.
Run {
    /// The harness adapter to host: `<adapter>[:profile]` (must be a
    /// registered `kind="harness"` adapter on this node). Omit (with --id)
    /// to launch the interactive picker.
    #[arg(long)]
    adapter: Option<String>,
    /// The endpoint id to bring up (charset: alphanumeric, `-`, `_`). Omit
    /// (with --adapter) to launch the interactive picker.
    #[arg(long)]
    id: Option<String>,
    // ... other flags (create, resume, start, attach, view, subnet)
}
```

### **2. The Bare `spt` Alias Mapping**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs`**

**Bare invocation handler (lines 1144–1150):**

```rust
// Bare `spt` (no subcommand): an interactive terminal opens the endpoint-run
// picker; a non-TTY (pipe / redirect / CI) NEVER launches the TUI (it would
// hang a script) — fall back to help. `--help`/`-h`/`help` already printed
// help and exited inside clap above.
let Some(command) = cli.cmd else {
    return bare_invocation(template);
};
```

**bare_invocation function (lines 1067–1083):**

```rust
fn bare_invocation(template: String) -> i32 {
    use clap::CommandFactory;
    use std::io::IsTerminal;
    match decide_bare(std::io::stdin().is_terminal(), std::io::stdout().is_terminal()) {
        BareAction::Picker => crate::picker::run(None, None),
        BareAction::Help => {
            // Render the help, then transform its inline Markdown to terminal
            // styling (REQ-CLI-HELP-MARKDOWN) before printing — `print_help`
            // would emit the raw `**`/backtick markers verbatim.
            let cmd = Cli::command();
            let raw = cmd.help_template(template).render_help().to_string();
            print!("{}", crate::helpfmt::render(&raw, crate::helpfmt::stdout_color()));
            println!();
            0
        }
    }
}
```

**The TTY decision (lines 1045–1063):**

```rust
enum BareAction {
    /// Interactive terminal → open the endpoint-run picker.
    Picker,
    /// Non-TTY (pipe / redirect / CI) → print help, never launch the TUI.
    Help,
}

/// The TTY-guard: the picker needs BOTH a readable key source and a renderable
/// screen, so it opens only when stdin AND stdout are terminals. Anything else
/// (a pipe, a redirect, a CI runner) falls back to help — launching a TUI there
/// would hang a script.
// [impl->REQ-RUN-PICKER]
fn decide_bare(stdin_tty: bool, stdout_tty: bool) -> BareAction {
    if stdin_tty && stdout_tty {
        BareAction::Picker
    } else {
        BareAction::Help
    }
}
```

**Command dispatch to EndpointCmd::Run (lines 1208–1260):**

```rust
// The endpoint noun (M8 decisions 1–2): bare = the merged list.
// [impl->REQ-CLI-1]
Cmd::Endpoint { action } => match action.unwrap_or(EndpointCmd::List {
    subnet: None,
    detail: false,
}) {
    // ... other endpoint subcommands ...
    EndpointCmd::Run {
        adapter,
        id,
        create: _,
        resume,
        start,
        attach,
        view,
        subnet,
    } => match (adapter, id) {
        // Both supplied → the REQ-HOST-RUN-1 non-interactive path
        // (untouched). `--create` is the default-fresh made explicit; it
        // only matters as the absence of `--resume`, so it is not
        // threaded further. The interactive picker bakes the same path.
        (Some(adapter), Some(id)) => {
            // Direct CLI: no picker-threaded cwd — cmd_endpoint_run
            // resolves it (resume info.cwd → current_dir).
            cmd_endpoint_run(&adapter, &id, resume, None, start, attach, view, subnet.as_deref())
        }
        // Bare (or partial) → the interactive picker (REQ-RUN-PICKER).
        // A lone --id pre-fills the create-new id; a lone --adapter
        // pre-selects the adapter.
        (adapter, id) => crate::picker::run(adapter, id),
    },
},
```

### **3. The Endpoint Picker Logic**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/picker/mod.rs`**

**Picker entry point (lines 35–73):**

```rust
/// Entry point for `spt endpoint run` when it is NOT fully flagged. Gathers the
/// data, drives the TUI, and dispatches the chosen outcome through the existing
/// bringup fns. Returns the process exit code.
pub fn run(prefill_adapter: Option<String>, prefill_id: Option<String>) -> i32 {
    let project_name = data::project_name();
    let adapter_rows = data::adapter_options();
    let endpoints = data::gather_endpoints();
    // The MRU-ordered member subnets for the create-new home layer (< 2 ⇒ the
    // model skips the layer; single-subnet/unpaired auto-homes). REQ-RUN-PICKER-HOME.
    let home_subnets = data::home_subnet_options();
    let mut model = PickerModel::new(
        project_name,
        adapter_rows,
        endpoints,
        prefill_adapter,
        prefill_id,
        home_subnets,
    );
    // ... setup/event loop/restore terminal ...
    match outcome {
        Ok(outcome) => dispatch(outcome),
        Err(e) => {
            eprintln!("PICKER_FAIL: {e}");
            1
        }
    }
}
```

### **4. Initial Screen Decision (Empty Endpoints Check)**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/picker/model.rs` (lines 453–498)**

```rust
impl PickerModel {
    /// A fresh model on the kind screen. `prefill_adapter`/`prefill_id` come from
    /// a lone `--adapter`/`--id` on a bare-ish `spt endpoint run`.
    pub fn new(
        project_name: String,
        adapter_rows: Vec<AdapterOption>,
        endpoints: Vec<EndpointRow>,
        prefill_adapter: Option<String>,
        prefill_id: Option<String>,
        home_subnets: Vec<String>,
    ) -> Self {
        // A lone --adapter pre-selects its row; a lone --id pre-fills the id and
        // jumps straight into create-new.
        let adapter_cursor = prefill_adapter
            .as_deref()
            .and_then(|a| adapter_rows.iter().position(|r| r.address() == a || r.adapter == a))
            .unwrap_or(0);
        // W4 UX: a bare picker OPENS on Pick-existing (the common case — most runs
        // re-attach an existing endpoint); `n` jumps to Create-new and Esc backs to
        // the Layer-1 Kind menu (now a jump-target, not the entry). A prefill still
        // jumps straight into create-new.
        let screen = if prefill_id.is_some() || prefill_adapter.is_some() {
            Screen::CreateAdapter
        } else {
            Screen::PickExisting
        };
        Self {
            screen,
            project_name,
            kind_cursor: 0,
            adapter_rows,
            adapter_cursor,
            id_buffer: prefill_id.unwrap_or_default(),
            id_error: None,
            category: Category::Project,
            endpoints,
            item_cursor: 0,
            query: String::new(),
            filtering: false,
            confirm_cursor: 0,
            resume_cursor: 0,
            home_subnets,
            home_cursor: 0,
            flash: None,
        }
    }
```

**Key insight:** The picker **does NOT check if endpoints are empty**. It ALWAYS opens on `Screen::PickExisting` (line 477) unless a prefill is provided (`--adapter` or `--id`). The decision logic is in `gather_endpoints()` which returns a `Vec<EndpointRow>` — when empty, the PickExisting screen renders with an empty list, allowing users to navigate to Create-new via the `'n'` keybind.

### **5. Endpoint Gathering (Data Layer)**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/picker/data.rs` (lines 85–96)**

```rust
/// Every discoverable endpoint, across all three categories. The model
/// partitions by category; this just produces the union (local perches +
/// subnet registry rows).
// [impl->REQ-RUN-PICKER]
pub fn gather_endpoints() -> Vec<EndpointRow> {
    // Resolve the node-key→label map ONCE (the subnet registries) so a LOCAL row's
    // `driven_by` controller renders as a NAME, not bare key-hex (REQ-PICKER-4).
    let node_labels = node_label_map();
    let local = local_rows(&node_labels);
    let subnet = subnet_rows();
    let mut rows = Vec::with_capacity(local.len() + subnet.len());
    rows.extend(local);
    rows.extend(subnet);
    reconcile_self_owned(&mut rows);
    rows
}
```

Combines:
- **Local perches** (lines 180–248): from the roster via `crate::roster::enumerate()`
- **Subnet endpoints** (lines 252–306): from WAN registry snapshots via `crate::wansend::load_snapshots()`

---

## **PART B: `spt rc` Terminal View / PTY Rendering**

### **1. The `spt rc` Command Definition**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs` (lines 170–187)**

```rust
/// Attach a local terminal to a broker-held endpoint PTY.
///
/// Connects to an spt-hosted session and drives it as a terminal. Local is
/// the degenerate single-node case of the cross-node attach (one pump,
/// loopback peer). Detach with the **ctrl-b** prefix then `d`
/// (`ctrl-b ctrl-b` sends a literal ctrl-b); detaching leaves the session
/// running on the broker. `--view` watches read-only.
Rc {
    /// The endpoint id whose broker-held session to attach.
    id: String,
    /// Read-only: render output, forward no input.
    #[arg(long, conflicts_with = "take")]
    view: bool,
    /// Take control: kick the current controller (a loud notice to them) and
    /// drive. Use on an endpoint another node controls.
    #[arg(long)]
    take: bool,
},
```

**Command dispatch (lines 1262–1271):**

```rust
Cmd::Rc { id, view, take } => {
    let intent = rc_intent(view, take);
    match crate::rc::run_attach(&id, intent) {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("RC_FAIL:{id}: {e}");
            1
        }
    }
}
```

**Intent resolver (lines 1366–1375):**

```rust
fn rc_intent(view: bool, take: bool) -> spt_net::net::attach::AttachIntent {
    use spt_net::net::attach::AttachIntent;
    if view {
        AttachIntent::Viewer
    } else if take {
        AttachIntent::Take
    } else {
        AttachIntent::Control
    }
}
```

### **2. The rc Module — Entry Point and Pump**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/rc.rs`**

**Main attach function (lines 739–759):**

```rust
/// Attach a local terminal to the broker-held session for `endpoint_id` under
/// `intent` (REQ-RCVIEW-1 / REQ-KICK-1): `Control` drives a free endpoint (and is
/// busy-refused with guidance on a controlled one), `Viewer` watches read-only,
/// `Take` kicks the incumbent controller (loud `Displaced` notice to them) and
/// takes control. Returns when the session exits, the operator detaches, or an
/// error occurs.
// [impl->REQ-RC-1] [impl->REQ-RCVIEW-1] [impl->REQ-KICK-1]
pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
    run_attach_inner(endpoint_id, intent, false)
}
```

**The inner attach function with identity resolution (lines 760–886):**

This function:
1. **Checks if controlled** (lines 770–777) — reads the perch's `driven_by` marker to refuse control on a busy endpoint:

```rust
if intent == AttachIntent::Control {
    if let Some(node) = current_driver(endpoint_id) {
        println!("'{endpoint_id}' is controlled by {node}.");
        println!("  spt rc {endpoint_id} --view   to watch read-only");
        println!("  spt rc {endpoint_id} --take   to take control (kicks {node})");
        return Ok(());
    }
}
```

2. **Sets up raw terminal mode** (line 854):

```rust
let _raw = RawGuard::enable();
```

3. **Initializes the full-duplex pump** (lines 877–886):

```rust
let mut decoder = AttachDecoder::new();
let mut stdout = std::io::stdout();
let outcome = pump(
    &mut brain,
    stream_id,
    &ops,
    rx.as_ref(),
    &mut decoder,
    &mut stdout,
    !view, // controller drives resize
    &mouse_mode,
);
```

### **3. PTY Terminal Rendering — The Pump Core**

**File: `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/rc.rs` (lines 1003–1142)**

**Full duplex pump signature (lines 1006–1015):**

```rust
fn pump(
    brain: &mut Brain,
    stream_id: u64,
    ops: &OpMinter,
    rx: Option<&mpsc::Receiver<StdinMsg>>,
    decoder: &mut AttachDecoder,
    stdout: &mut std::io::Stdout,
    controller: bool,
    mouse_mode: &MouseMode,
) -> Result<PumpEnd, String> {
```

**Raw PTY output passthrough (lines 1094–1109):**

```rust
BrokerEvent::NetStreamData { stream_id: sid, bytes, .. } if sid == stream_id => {
    for rec in decoder.push(&bytes) {
        match rec {
            AttachRecord::Output { seq, data_b64 } => {
                if seq < cursor {
                    continue; // already rendered — re-serve dedup
                }
                cursor = seq + 1;
                let bytes = decode_bytes(&data_b64)
                    .map_err(|e| format!("decode output: {e}"))?;
                // Track the harness's mouse-reporting mode from its output
                // (REQ-RC-MOUSE-FORWARD) before rendering it verbatim.
                mouse_scanner.feed(mouse_mode, &bytes);
                stdout.write_all(&bytes).map_err(|e| format!("write: {e}"))?;
                stdout.flush().map_err(|e| format!("flush: {e}"))?;
            }
```

**The only overlay: viewer letterbox indicator (lines 1114–1130):**

```rust
// Viewer letterbox feed (REQ-RCVIEW-1): on a size change,
// show a one-line indicator when our terminal differs from
// the new PTY size (rc is a byte pump — true clip/pad needs
// a grid model, deferred; the honest size cue renders here).
AttachRecord::Size { rows, cols } if pty_size != Some((rows, cols)) => {
    pty_size = Some((rows, cols));
    match crossterm::terminal::size() {
        Ok((mycols, myrows)) if (myrows, mycols) != (rows, cols) => {
            let _ = write!(
                stdout,
                "\r\n[viewing {cols}x{rows} — your terminal is {mycols}x{myrows}]\r\n"
            );
            let _ = stdout.flush();
        }
        _ => {}
    }
}
```

**Parting messages (lines 890–932):**

```rust
match outcome {
    Ok(PumpEnd::Exited(code)) => {
        let _ = writeln!(stdout, "\r\n[session '{endpoint_id}' ended ({code:?})]");
        Ok(())
    }
    Ok(PumpEnd::Detached) => {
        let _ = writeln!(stdout, "\r\n[detached — '{endpoint_id}' still running]");
        Ok(())
    }
    Ok(PumpEnd::Displaced(by)) => {
        // The loud kick notice (REQ-KICK-1): a different operator took control.
        let _ = writeln!(
            stdout,
            "\r\n[displaced — '{endpoint_id}' was taken over by {by}]"
        );
        Ok(())
    }
    // ... BrokerGone, Stalled cases ...
}
```

### **4. ANSI Terminal Control — Drawing Code**

The pump is **a raw byte passthrough** — no grid model, no ANSI sequence parsing for drawing. The ONLY overlay code is:

1. **Mouse reporting mode scanner** (lines 53–196): Parses harness `DECSET` sequences to track mouse mode for Windows scroll-wheel forwarding — writes atomic state, not terminal output.

2. **Letterbox indicator** (lines 1114–1130): A single-line text message when PTY size differs from terminal size.

3. **Key translation on Windows** (lines 227–387): Converts Windows console key events to xterm VT sequences before sending to session.

4. **Status messages** (lines 890–932): Parting lines like `[detached — 'id' still running]` printed to stdout after pump ends.

**No cursor positioning, no status line, no framing, no ANSI drawing code.** The pump is fundamentally a transparent **byte-for-byte relay** with minimal metadata overlays.

### **5. Identity Information Available at rc Callsite**

From the dispatch in `cli.rs` (line 1264) and `cmd_endpoint_run` (line 1547):

```rust
pub fn run_attach(endpoint_id: &str, intent: AttachIntent) -> Result<(), String> {
```

**At the `run_attach` call site, available identities are:**

1. **endpoint_id** (`String`) — the endpoint name (e.g., "doyle-w408")
2. **intent** (`AttachIntent`: `Control`, `Viewer`, or `Take`) — the operator's action intent

**From within `run_attach_inner`, resolved on demand:**

3. **current_driver** (lines 567–573): The node currently controlling the endpoint:

```rust
fn current_driver(endpoint_id: &str) -> Option<String> {
    let perch = spt_store::perch::resolve_perch_path(
        endpoint_id,
        spt_store::perch::ParentHint::Infer,
    );
    spt_store::info::read_info(&perch).and_then(|i| i.driven_by)
}
```

4. **Session ID** (line 830): Resolved via `resolve_session`:

```rust
let session_id = resolve_session(&mut brain, endpoint_id)
    .map_err(|e| format!("list sessions: {e}"))?
    .ok_or_else(|| format!("no live session for endpoint '{endpoint_id}'"))?;
```

5. **Terminal size** (lines 1048–1053, 1120–1127): Available from `crossterm::terminal::size()` — both controller and viewer can read it.

**No subnet name or node name are directly available in the rc pump.** The `current_driver` gives the node identity only when the endpoint is controlled; the pump receives no subnet membership info. All info flows through the `endpoint_id` which is node-local (no qualified `id@node` form until cross-node attach).

---

## **Key Findings Summary**

**A) Endpoint Picker:**
- **File:line:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/cli.rs:1233–1260` (dispatch to picker)
- **Picker entry:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/picker/mod.rs:38`
- **Empty endpoint handling:** Picker ALWAYS starts on `PickExisting` screen (line 477), regardless of endpoint count. User can press `'n'` to jump to create-new from an empty list.
- **No explicit "empty endpoint" flow** — the picker gracefully degrades to show empty PickExisting pane.

**B) RC Terminal:**
- **File:line:** `/c/Users/decid/Documents/projects/spt-core/crates/spt/src/rc.rs:739–886` (main attach pump)
- **Output rendering:** Lines 1094–1109 — **raw byte passthrough** to `stdout.write_all()`, no parsing or framing.
- **Overlays:** Only letterbox size indicator (lines 1114–1130) + parting status messages (lines 890–932).
- **Available identity at rc callsite:** `endpoint_id` + `intent`. Subnet/node resolved on demand via perch reads or broker session queries.</result>
<usage><subagent_tokens>82103</subagent_tokens><tool_uses>30</tool_uses><duration_ms>151051</duration_ms></usage>
</task-notification>


(Digest survey in — one structural flag for when we reach Ask B: the digest is a **rolling semantic Turn-window** (default 3 turns, oldest→newest), **not** a line-addressable log — turns carry no line numbers; line metadata is dropped at projection (`spt-term/projection.rs`). So your `--after <line>` assumes an addressing model that doesn't exist yet. There's already a version cursor (`DigestUpdate.version`) that may be the real seam. We'll resolve that conflict at Ask B.)

Grounding ~done (4/5 surveys in, 1 left). **Still your move on Q1.**


<task-notification>
<task-id>ab9cc887dabafe308</task-id>
<tool-use-id>toolu_01PhiuCxs5LNJzYNiAnaJxp2</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\16842a97-6889-4f10-b3ac-07f857482143\tasks\ab9cc887dabafe308.output</output-file>
<status>completed</status>
<summary>Agent "Survey manifest hooks/update/translation seams" finished</summary>
<note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note>
<result>Perfect! Now I have all the information I need. Let me compile my findings into a comprehensive report.

## Survey Summary: Three Manifest Contract Areas

Based on my thorough investigation of the spt-core codebase, here are the precise current shapes for the three manifest-contract changes you're designing:

---

### 1. **The `[hooks.<event>]` table — hook declarations**

**File:** `crates/spt-runtime/src/manifest.rs:148-159`

```rust
/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
/// stdin fields it maps, and whether it can surface context to the agent.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Hook {
    /// Opaque `api …` command line the harness invokes for this event.
    pub fires: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub reads: Vec<String>,
    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
    #[serde(default)]
    pub can_inject: bool,
}
```

**Declaration-only:** Yes. The manifest carries only metadata — `fires` is an **opaque string** that spt-core never parses. The hook structure stores the command string, the input fields the harness maps in (`reads`), and a bool flag (`can_inject`) that controls whether spt-core expects injection or falls back to sentinel+relay.

**Execution:** spt-core **does NOT execute hooks**. Hooks are **outbound**: the harness fires them (e.g., `claude SessionStart hook` → fires `api seed --pid … --session-id …`). spt-core receives the calls and acts on them; it never initiates a hook. There is **no `spt api run-hook` command**.

**Example from manifest:**
```toml
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "source", "agent_type"]
can_inject = true

[hooks.Stop]
fires = "api state idle"
reads = []
can_inject = false
```

**Documentation:** `docs/MANIFEST.md:72-85`

---

### 2. **The `[update]` section — adapter self-update**

**File:** `crates/spt-runtime/src/manifest.rs:441-554`

**Core struct:**
```rust
/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` / `gh_release` avenue: source repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// `gh_release` avenue: the release asset name to fetch.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub asset: Option<String>,
    /// The adapter's Ed25519 content-signing public key (64 hex chars / 32 bytes).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signing_key: Option<String>,
    /// `delegated` avenue: the adapter attests its own updater verifies the content.
    #[serde(default)]
    pub self_verifies: bool,
    /// Verify spt-core satisfies min_spt_core_version before/after.
    #[serde(default)]
    pub version_check: bool,
    /// Optional inverse of install — run by `spt adapter remove`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uninstall: Option<String>,
    /// Optional plain (multi-line) human notice surfaced to stdout **only when an
    /// update is actually APPLIED** (version changed), never on a no-op.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    /// `gh_release` avenue: the fetch transport — https | gh | auto.
    #[serde(default, skip_serializing_if = "Transport::is_auto")]
    pub transport: Transport,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum Transport {
    Https,
    Gh,
    #[default]
    Auto,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    Delegated,      // Delegate to the adapter's own updater
    FilePull,       // spt-core pulls files from a repo
    GhRelease,      // spt-core ships updates from GitHub releases
}
```

**Three avenues:**

1. **`gh_release`** (REQ-UPD-9): spt-core queries the repo's latest GitHub release, compares version, fetches the `.spt` archive, optionally verifies against `signing_key`, and re-registers.
   - **Required:** `repo` (user/repo)
   - **Optional:** `asset` (defaults to `adapter.spt`), `signing_key` (optional, enables fail-closed verify), `transport` (defaults to `auto`)

2. **`delegated`**: spt-core runs the adapter's own updater command.
   - **Required:** `command`
   - **Trust model:** `self_verifies` bool (adapter attests its updater verifies content)

3. **`file_pull`**: spt-core pulls files from a repo.
   - **Required:** `repo`, `signing_key` (for fail-closed verify)
   - **Optional:** `path_regex`

**The `message` field** (lines 490-499):
```rust
/// Optional plain (multi-line) human notice surfaced to stdout —
/// markdown-rendered (the helpfmt prose path) — **only when `spt adapter
/// update` actually APPLIES an update** (version changed), never on a
/// no-op. Read from the newly-installed manifest; avenue-agnostic
/// (`gh_release` / `delegated` / `file_pull`). No `{key}` substitution.
/// Use: an adapter telling the operator a post-update action (e.g. "run
/// `/reload-plugins` in any ongoing sessions").
// [impl->REQ-ADAPTER-UPDATE-MESSAGE]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
```

**Where the message is displayed:** `crates/spt/src/cli.rs:6715-6868`

- The function `adapter_update_notice()` (line 6715) reads `manifest.update.as_ref()?.message.as_deref()?` and renders it via `crate::helpfmt::render(msg, color)`.
- It is called **only after the update is applied** (line 6864-6868), in the success path after `registry::register()` completes, never on a no-op (the uptodate case `continue`s at line 6782).
- It reads the **newly-installed manifest's message** (line 6857: `registry::register(adapters, &dest, now_ms())` returns the new manifest).

**Version-decision logic** (line 6777): `if !version_is_newer(&latest, installed)` skips the update and prints `ADAPTER_UPDATE_UPTODATE` to stderr, never reaching the message path.

**`spt adapter update` command:** `crates/spt/src/cli.rs:6720-6878`

The CLI-side implementation (REQ-UPD-9) handles `gh_release` adapters only. The daemon-side `spt_daemon::adapter_update::plan_adapter_update()` (file_pull/delegated) is pure decision-logic with no message output — the CLI is the only surface that renders the notice.

**Documentation:** `docs/MANIFEST.md:292-326`, ADR-0025

---

### 3. **The `[message-idle-translation-binary]` section**

**File:** `crates/spt-runtime/src/manifest.rs:410-421`

```rust
/// `[message-idle-translation-binary]` — the opt-in idle-delivery translation
/// binary (ADR-0022). A table so the contract degrades gracefully: spt-core does
/// NOT `deny_unknown_fields`, so a newer adapter declaring a future key (e.g. a
/// spawn timeout) against an older spt-core parses fine — the unknown key is
/// ignored, never a hard manifest failure (a lifecycle-binary contract perri
/// builds blind from docs must be forward-compatible). Known keys today: `path`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct IdleTranslationBinary {
    /// The binary PATH spt-core spawns and lifecycle-manages. Opaque (resolved at
    /// endpoint bringup like every other adapter command); validated non-empty.
    pub path: String,
}
```

**Critical distinction: `path` NOT `command`**

The manifest field is **`path`** (a bare binary name), **NOT `command`**. This is deliberate:
- **`[digest].extractor`**: takes `command` (opaque full command with args)
- **`[session.psyche_init]`**: takes `command` (opaque full command with args)
- **`[message-idle-translation-binary]`**: takes **`path`** (bare binary name, resolved at spawn)

**Why the distinction:** The translation binary is lifecycle-managed and spins up **at session bringup**; a bare path allows spt-core to:
1. Resolve it against the adapter's install dir (REQ-INSTALL-11)
2. Spawn it with **no command-line arguments** — it receives **only stdin** (JSON-lines protocol)
3. Re-resolve and respawn it at live-update (W3d respawn, `read_translation_path`)

The `path` is **opaque** (spt-core never parses it), but it is resolved at bringup against `install_dir` **exactly like the role templates** (`[session.psyche_init]` programs).

**Lifecycle & stdin/stdout protocol:**

**Spawning:** `crates/spt-daemon/src/translation.rs:242-292` (`TranslationChild::spawn()`)
- Spawned at endpoint bringup (spt-hosted only; harness-hosted uses relay-poll)
- `spawn(path, cmds_sender)` → spawns the bare `path` (resolved against install dir)
- stderr is inherited (author sees diagnostics)
- No arguments are passed to the binary

**stdin** (spt-core → binary, JSON-lines):
```rust
pub enum ToBinary {
    Init { endpoint_id: String, node: String },  // first line
    Event { envelope: String },                   // each inbound message
    Input,                                        // content-free ping on operator keystroke
}
```

**stdout** (binary → spt-core, JSON-lines):
```rust
pub enum KeyCmd {
    Key { key: String },         // named key or ctrl+x
    Text { text: String },       // literal text
    Delay { delay_ms: u64 },     // pause ms
    Commit { commit: bool },     // sequence terminator
}
```

**Key map:** `crates/spt-daemon/src/translation.rs:93-128` — spt-core maps `{key:"enter"}` → `\r`, `{key:"ctrl+s"}` → `\x13`, etc.

**Lifecycle management:** `crates/spt-daemon/src/translation.rs:219-292`
- Resident binary (spawned at endpoint bringup, terminated at endpoint teardown)
- `TranslationChild::Drop` bounded reap (closes stdin → waits REAP_GRACE = 500ms → kills + waits)
- No zombies

**Program resolution (REQ-INSTALL-11):** `crates/spt-daemon/src/harnesshost.rs` + `crates/spt-daemon/src/broker.rs`
- The bare `path` is resolved against the adapter's install dir at bringup (same as role program resolution)
- A relative/bare name (e.g. `cc-spt-idle-translate`) → `<install_dir>/<name>(.exe)` before PATH
- On W3d live-update respawn, it is re-resolved and re-spawned

**Error handling:**
- Spawn failure logs `TRANSLATION_SPAWN_FAILED:<path>: <err>` to daemon stderr (amendment v0.14.2)
- **No fallback to raw inject** — idle message spools, never raw-typed (amendment v0.14.2)

**Author-time proof:** `spt adapter translate-proof <adapter> --event <envelope>` (ADR-0022 §"Author-time proof")

**Documentation:** `docs/MANIFEST.md:214-233` (the section heading: `### [message-idle-translation-binary] — spt-hosted idle delivery`), ADR-0022

---

### Summary Table

| Concern | Field | Type | Scope | Example |
|---------|-------|------|-------|---------|
| **Hooks** | `[hooks.<event>].fires` | Opaque string | Declaration only; harness executes | `"api seed --pid {parent_pid} --session-id {session_id}"` |
| **Hooks** | `[hooks.<event>].reads` | Vec<String> | Which stdin fields the event maps | `["session_id", "source", "agent_type"]` |
| **Hooks** | `[hooks.<event>].can_inject` | bool | Output capability (inject vs sentinel/relay) | `true` / `false` |
| **Update** | `[update].avenue` | enum | Which update path (delegated / file_pull / gh_release) | `gh_release` |
| **Update** | `[update].repo` | Option<String> | Source repo (required for gh_release / file_pull) | `"user/repo"` |
| **Update** | `[update].signing_key` | Option<String> | Ed25519 key for verify (required file_pull, optional gh_release) | `"deadbeef…"` (64 hex) |
| **Update** | `[update].message` | Option<String> | Post-update notice (stdout-only on APPLIED update, not no-op) | `"Run \`/reload-plugins\` in ongoing sessions."` |
| **Update** | `[update].transport` | Transport enum | Fetch method for gh_release (https / gh / auto) | `"auto"` |
| **Translation** | `[message-idle-translation-binary].path` | String | **Bare binary name** (resolved at install dir) | `"cc-spt-idle-translate"` |
| **Translation** | stdin protocol | JSON-lines | spt-core → binary (init, event, input) | `{"type":"init","endpoint_id":"…","node":"…"}` |
| **Translation** | stdout protocol | JSON-lines | binary → spt-core ({key}, {text}, {delay_ms}, {commit}) | `{"key":"ctrl+s"}` |</result>
<usage><subagent_tokens>91436</subagent_tokens><tool_uses>31</tool_uses><duration_ms>123071</duration_ms></usage>
</task-notification>


Surveys 4/5 in (only the manifest hooks/update/translate survey for asks 1-3 still running — that's the Q1 area). Consolidated grounding, then I hold for your **Q1** answer:

- **`--reply-to` removal (your ask A):** trivial. Only `cli.rs` (the flag + the `is_reply`→"REPLIED" label) + one how-to string + a `REQ-DOCS-6` title mention. Zero wire/test impact — ADR-0020 already made messages structural `(from,body)`, no `__REPLY_TO__` anywhere. Clean delete.
- **empty-endpoint flow (your ask D):** confirmed the gap. The picker **always** opens on `PickExisting` regardless of count (`picker/model.rs:477`); empty subnet/node just renders an empty list + you press `n`. Your ask = open on the **create** screen when the scope has zero endpoints. One branch in `PickerModel::new`.
- **`rc` identity marker (your ask E):** this is the thorny one — flagging early. `rc` is a **raw byte passthrough**, no grid model, no ANSI drawing; the *only* existing overlay is a one-shot letterbox line on resize. A *persistent* top-right framed box fights the harness's own cursor moves/repaints — the survey itself notes "true clip/pad needs a grid model, deferred." And subnet/node aren't even resolved at the pump callsite (only `endpoint_id` + intent). So E is real work + a design question (how to keep a corner sticky over a stream we don't parse). We'll dig in when we reach it.

Grounded and ready. **Your move on Q1** (run-hook = opaque dispatch + unhandled-event silent no-op?).


5th survey in — **fully grounded now.** And it materially sharpens Q1, so before you answer, here's the refined picture (this is still Q1, not a new question):

**Key finding:** `[hooks.<event>]` today = `{fires: String, reads: Vec<String>, can_inject: bool}` and is **purely OUTBOUND** — the *harness* fires `fires` (an `api …` command) and spt-core *receives* it. **spt-core never executes a hook.** No `run-hook` exists.

So Ask 1 isn't "make the declaration executable" — it's a **direction inversion**: spt-core becomes the *executor* of an adapter handler. That's the real architectural move to rule on.

**My sharpened recommendation:**

1. **Boundary — stays agnostic.** spt-core already treats `fires` as an opaque string; the new handler is just another opaque command keyed by an opaque `<event>`. It never learns CC semantics. ✓ inside the line.

2. **Shape — additive `handler` field on the *same* `[hooks.<event>]` table** (not a parallel section): `handler = "claude-spt hook {event}"` — an opaque command with `{key}` subst, resolved against the install dir like `[digest].extractor`/`[session.psyche_init]` (NOT a bare path — perri wants a subcommand, and command-form is consistent). `fires` stays for the direct path. **`run-hook` is opt-in/additive — zero break to today's outbound hooks.**

3. **`run-hook <adapter> <event>` = dumb conduit:** CC hook JSON piped **stdin→stdin verbatim**; handler **stdout→run-hook stdout 100% verbatim**. spt-core does NOT know CC's hook-output JSON schema (`additionalContext`, `can_inject`, decision blocks) — the handler emits exactly what CC expects, spt-core just relays bytes. So the whole `can_inject`/`additionalContext` contract stays the adapter's concern. Session threading: the CC payload on stdin already carries `session_id`; handler reads it there — spt-core needn't parse or require `--session-id`.

4. **Unhandled event (no `handler` declared) = clean no-op** (exit 0, empty stdout) → the plugin wires *all* CC events to the stub once, static-forever.

**Confirm three things:** (a) the boundary framing; (b) additive `handler` on the existing `[hooks]` table + opaque-conduit stdout passthrough (spt-core never parses CC's hook-output schema); (c) unhandled-event = silent no-op.

Any of those you'd push back on — especially (c) vs. wanting a diagnostic?


Q1: to avoid running two binaries for every hook call, i'd like to suggest an alternative collection of changes: 1. enable adapter `[strings]` to employ the same substitution keys currently available to i.e. the `[session.*]` entries, and 2. add substitution key `{adapter_dir}`, which resolves to the location the adapter is installed at. this way, adapter devs can put the dynamic strings they need in their manifest `[strings]`, then use `spt adapter get-string` to do things like fetch the adapter-->resolve their own custom binary that was packed+installed with the binary, using that to run their own hook ops. -thoughts? suggestions?


Enable adapter string substitution with adapter_dir key


Good instinct — and I think it's *mostly* the better design. Let me steelman it, then flag the one place the stated motivation doesn't hold so we land it honestly.

**What your proposal gets right (I'd adopt it):**

1. **spt-core executes *nothing* new.** `run-hook` would have added a real new surface: spt-core spawning an adapter handler. Your path keeps spt-core a **pure resolver** — `get-string` already exists; it hands back a string and the *adapter's own wrapper* does the spawn. That's the strongest possible harness-agnostic posture, and it **preserves the load-bearing invariant** CONTEXT.md states outright: *"nothing in spt-core ever executes a string."* spt-core resolves+returns; the adapter executes. Clean.

2. **`{adapter_dir}` + strings-substitution are reusable primitives**, not single-purpose. Any string that must point at a packed binary/resource benefits. `run-hook` was one narrow trick; these are general. I like trading 1 narrow surface for 2 general ones.

3. Hits the actual goal — hook **logic rides `adapter update`** (it's in the adapter binary the get-string resolves to), and `hooks.json` can go static-forever.

**Where I'll push back / sharpen:**

- **"Avoid two binaries" doesn't actually hold as stated.** The wrapper still spawns `spt adapter get-string` (1) **then** the adapter binary (2) — same two spawns as `run-hook`. The real win isn't spawn count, it's "no new spt-core execution surface + reusable primitives." On the **PostToolUse hot path** (fires every tool call), have perri **memoize the resolved path** per session (resolve once, cache in env/tmp) so get-string isn't paid per-tool. Worth stating so we don't sell a perf win that isn't there.

- **The residual plugin-resident piece is a thin per-OS dispatch wrapper.** `run-hook`'s one genuine edge was that `hooks.json` could call `spt` *directly* — no shell. Your path needs a wrapper doing `bin=$(get-string …); exec "$bin"` = shell logic = the Windows-no-bash wrinkle ADR-0006 flagged returns. **Acceptable** — it's generic and static-forever (one tiny `.sh` + one `.cmd`/`.ps1`, wired to all events, never changes; undeclared event ⇒ get-string returns "not set" ⇒ wrapper exits 0). But it *is* the residual, so name it as such, not "zero plugin logic."

- **Scope the substitution keys.** `[session.*]` keys include session-scoped values (`{id}`, `{session_id}`, `{uuid}`, `{parent_pid}`). `get-string` today is **node-local with no session context** (survey: `get-string <adapter-option> <key.path>`). So enable substitution in `[strings]` for **adapter-static keys only** — `{adapter_dir}`, and I'd add `{adapter_name}` — resolved **lazily at read** (like file-backed strings) against the record's `source_dir`. Session-scoped substitution would force `get-string` to take a `--session-id` (a bigger, separate change) — defer it unless a real use case needs it. Your binary-resolve case only needs `{adapter_dir}`.

**So my recommendation:** drop `run-hook`; adopt (a) `{adapter_dir}` (+`{adapter_name}`) substitution, (b) lazy substitution in `[strings]` scoped to adapter-static keys. Two clean F-0xx findings. This is also genuinely **ADR-worthy** later (the "resolve-not-execute" choice over `run-hook` is hard-to-reverse + surprising-without-context) — I'll draft that once shaped.

**Two things to confirm:**
1. Adapter-static substitution scope (`{adapter_dir}`/`{adapter_name}` now, session-scoped `get-string --session-id` **deferred**) — agree, or do you already foresee a hook needing session-scoped string substitution (which changes `get-string`'s shape)?
2. `{adapter_dir}` semantics: resolve to the registry record's **precise `source_dir`** (the install dir, survives updates, same dir bare-program resolution already uses) — correct target?


Q1: to avoid running two binaries for every hook call, i'd like to suggest an alternative collection of changes: 1. enable adapter `[strings]` to employ the same substitution keys currently available…


"The wrapper still spawns spt adapter get-string" -- yes, but in the case of claude code it only needs to do it once per session (to define an env var), rather than once per hook run. basically as you suggested. -agree with scoped sub keys. --- confirm both, agree with recs.


Q1 locked. Recording it before we move on.


Edit

_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).

_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).

**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.8.0 update-arc grill):
`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped substitution is a deferred, larger change. The load-bearing invariant is preserved: **spt-core still never executes a string** — it substitutes and returns; the *adapter's own wrapper* executes the result. Canonical use: a harness hook dispatcher resolves its own packed binary via `get-string` (e.g. value `"{adapter_dir}/claude-spt hook"`) **once per session into an env var**, then runs it per-hook — so hook *logic* rides `spt adapter update` (it lives in the adapter binary) and the plugin's `hooks.json` + a thin static dispatch wrapper go static-forever. This **supersedes a rejected `spt api run-hook`** (which would have made spt-core itself execute an adapter handler — a new execution surface; rejected in favor of resolve-not-execute).
_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


83	<!-- [doc->REQ-MANIFEST-5] -->
84	**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = "skill.md" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the pointer form (it can't double as inline data). Files live in the adapter's per-adapter aux dir **`adapters/<adapter>/strings/`** (sibling of `profiles/`), referenced by a path relative to it that **must stay inside that dir** (HAZARD-class containment: `..` traversal and absolute paths are refused at registration). A **local** profile's own file pointers resolve against the user-owned local-profile dir, not the adapter-shipped `strings/` (which adapter updates overwrite) — so a local override survives updates; equivalently a local profile may just inline a literal. Pointers are **validated at registration** (fail-fast on an escaping/missing pointer — records nothing) and **read lazily** at `get-string` (live file edits reflect without re-register); a missing/unreadable file at read time **skip-diagnoses** (emits a diagnostic, returns "not set" — never a silent drop or hard error, mirroring `[digest]`).
85	_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).
86

<!-- [doc->REQ-MANIFEST-5] -->
**File-backed strings** (M12-W3): a `[strings]` value MAY be a **file pointer** instead of an inline literal — a value-position table with **exactly one** key `file`: `skill = { file = "skill.md" }`. `get-string` resolves it to the file's **contents** (so large bodies — skill-instructions, hint text — stay out of the manifest). The exactly-one-key rule is the disambiguation: any other table shape stays an opaque nested strings tree (existing trees untouched), and `{ file = … }` is reserved as the pointer form (it can't double as inline data). Files live in the adapter's per-adapter aux dir **`adapters/<adapter>/strings/`** (sibling of `profiles/`), referenced by a path relative to it that **must stay inside that dir** (HAZARD-class containment: `..` traversal and absolute paths are refused at registration). A **local** profile's own file pointers resolve against the user-owned local-profile dir, not the adapter-shipped `strings/` (which adapter updates overwrite) — so a local override survives updates; equivalently a local profile may just inline a literal. Pointers are **validated at registration** (fail-fast on an escaping/missing pointer — records nothing) and **read lazily** at `get-string` (live file edits reflect without re-register); a missing/unreadable file at read time **skip-diagnoses** (emits a diagnostic, returns "not set" — never a silent drop or hard error, mirroring `[digest]`).
_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).


"The wrapper still spawns spt adapter get-string" -- yes, but in the case of claude code it only needs to do it once per session (to define an env var), rather than once per hook run. basically as you…


_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).

_Avoid_: treating strings as config knobs for spt-core itself (those are global settings); "adapter KV store" as a separate registry; putting user files in the adapter-shipped `strings/` dir (clobbered by updates — use a local profile).

**manifest substitution in `[strings]`** (ratified 2026-06-25, v0.8.0 update-arc grill):
`get-string` resolves a set of **adapter-static** substitution keys inside a returned string value at **read time** (lazily, like file-backed strings): **`{adapter_dir}`** — the registry record's precise `source_dir` (the install dir; survives updates; the same dir bare-program resolution already uses) — and **`{adapter_name}`**. Session-scoped keys (`{id}`/`{session_id}`/…) are **not** available: `get-string` carries no session context today, and a `get-string --session-id` for session-scoped substitution is a deferred, larger change. The load-bearing invariant is preserved: **spt-core still never executes a string** — it substitutes and returns; the *adapter's own wrapper* executes the result. Canonical use: a harness hook dispatcher resolves its own packed binary via `get-string` (e.g. value `"{adapter_dir}/claude-spt hook"`) **once per session into an env var**, then runs it per-hook — so hook *logic* rides `spt adapter update` (it lives in the adapter binary) and the plugin's `hooks.json` + a thin static dispatch wrapper go static-forever. This **supersedes a rejected `spt api run-hook`** (which would have made spt-core itself execute an adapter handler — a new execution surface; rejected in favor of resolve-not-execute).
_Avoid_: session-scoped substitution through bare `get-string`; reading this as spt-core executing a string (it never does — the adapter wrapper executes the resolved value).