Perfect. Now I have a clear picture. Let me compile my findings into a comprehensive report:

## ADAPTER MANIFEST / HARNESS CONTRACT SEAM ANALYSIS

Based on my thorough exploration of the spt-core codebase, here is the architecture-readiness report for the adapter manifest contract:

---

### 1. MANIFEST DECLARATION SURFACE — WHAT EVERY HARNESS ADAPTER MUST DECLARE

A `kind="harness"` adapter must declare the following fields (per `docs/MANIFEST.md` and `crates/spt-runtime/src/manifest.rs`):

#### **[adapter] header** (Required, parsed at `manifest.rs:82-94`)
- **`name`** (String): adapter identifier, e.g., `claude-spt`
- **`version`** (String): adapter's own semantic version
- **`min_spt_core_version`** (String): minimum spt-core version tolerance (compat gate)
- **`hostable_types`** (Vec<String>): endpoint types this adapter can host (`LiveAgent`, `ReadyAgent`, `Worker`, `Psyche`, `Gateway`, etc.)
- **`kind`** (AdapterKind, default `harness`): distinguishes harness from shell adapters

#### **[hooks.<event>]** (Optional but load-bearing, `manifest.rs:106-117`)
Per harness event, declares:
- **`fires`**: opaque `api` command line (e.g., `api seed --pid {parent_pid}`)
- **`reads`**: fields the harness hook maps into stdin (metadata only — spt-core does not parse/execute)
- **`can_inject`** (bool, default false): whether this hook can surface context to the agent
  - **Load-bearing fact**: when `can_inject=false`, spt-core falls back to sentinel + relay/poll delivery (e.g., Claude Code's Stop hook fires `api state idle` with `can_inject=false`, driving the echo-gate sentinel fallback per CONTEXT.md §activity/idle detection)

#### **[session.<role>]** (Outbound templates, `manifest.rs:226-248`)
One or more of: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`, `notif`
- **`command`**: opaque template with `{key}` substitution placeholders
- **`cwd`** (optional): working directory (with substitution)
- **`recursion_guard_env`** (optional): env var name set on children to prevent recursion
- **`detach`** (bool, default false): whether the process runs detached
- **`env_remove`** (Vec<String>): env vars to strip from the child
- **`keys`** (Vec<String>): advertised substitution keys spt-core guarantees to fill

**Key constraint**: The `keys` declared must be a subset of `SUBSTITUTION_KEYS` (`manifest.rs:36-49`):
```rust
["id", "adapter_name", "session_id", "session_name", "parent_pid", "psyche_dir",
 "psyche_prompt", "psyche_context", "agents_json", "link_token", "source", "agent_type"]
```
Any `{placeholder}` in `command` must resolve to a provided value — missing keys error **before spawn** (`runtime.rs:107-110`).

#### **[env.<VAR>]** (Env-var table, `manifest.rs:250-260`)
Per variable:
- **`direction`**: `inject` (spt-core fills) or `read` (adapter reads)
- **`value`** (for `inject` only): template to substitute
- **`channel`** (harness-hosted only): how the env reaches the session (e.g., `CLAUDE_ENV_FILE`)
  - **Asymmetry**: spt-hosted sessions inherit env from the broker; harness-hosted sessions need the declared channel (file-bridge-only-when-not-launcher principle per CONTEXT.md §Manifest seams)

#### **[session] watched dirs** (`manifest.rs:159-191`)
- **`commune_dir`** (optional): where `<id>-commune.md` files are watched (fixed filename per spt-core)
- **`signoff_dir`** (optional): where `<id>-signoff.md` files are watched

#### **[history]** (Transcript access, `manifest.rs:269-293`)
One of three strategies:
- **`strategy = "fetcher"`** + `fetcher`: adapter binary that emits normalized history on demand
- **`strategy = "locate_normalize"`** + `locate_template` + `normalize_command`: spt-core locates raw transcript, adapter normalizes
- **`strategy = "native"`**: adapter pushes via `api history-log`; spt-core stores
- **Status**: Parsed in M2a; **executed in M2b** (history subsystem not yet built per `manifest.rs:14-16`)

#### **[identity]** (Session-ID sourcing, `manifest.rs:313-329`)
- **`session_id_source`**: `post_spawn` (discovered post-launch) or `uuid_inject` (spt-core injects UUID)
- **`parent_ancestor_name`** (optional): process-tree anchor name when session_id is absent

#### **[inject]** (Delivery methods, `manifest.rs:295-311`)
Per activity state, any combination: `pty`, `hook`, `relay`, `http`
- **`activity`** (Vec<InjectMethod>): how to deliver during active work
- **`idle`** (Vec<InjectMethod>): how to deliver during idle
- **Status**: Parsed in M2a; driving is M3 (PTY layer and H-mode delivery not yet built)

#### **[update]** (Self-update avenue, `manifest.rs:331-368`)
- **`avenue`**: `delegated` (adapter's own updater) or `file_pull` (spt-core pulls from repo)
- **For `delegated`**: `command` (e.g., `claude plugin update spt`) + optional `self_verifies` bool
- **For `file_pull`**: `repo`, `path_regex`, **required `signing_key`** (64 hex / 32 bytes — adapter content signing per REQ-UPD-5)
- **Optional**: `uninstall` command (mirror of install, runs when adapter is quiesced)
- **Optional**: `version_check` (bool) — verify spt-core satisfies `min_spt_core_version` before/after update
- **Status**: Parsed in M2a; conducted in M3

#### **[profiles.<name>]** (Shipped overlays, CONTEXT.md §adapter profile, `manifest.rs:51-62`)
Sparse TOML value overlays with **leaf-replace merge semantics** (arrays replaced wholesale, never spliced):
- Declared inside parent manifest
- Update as one unit with parent
- Canonical addressing: `<adapter>:<profile>` (e.g., `claude-spt:work`, `spt-usbip-driver:hid-only`)
- Consent floors are tighten-only (profile may demand approval where parent doesn't)

#### **[strings]** (KV data tree, CONTEXT.md §adapter strings, `manifest.rs:70-72`)
- TOML KV tree — opaque data only (spt-core never executes)
- Accessible via `spt adapter get-string <adapter-option> <key.path>`
- Rides profile overlay like any section

#### **[[hints]]** (Keyword hints, CONTEXT.md §keyword hints, `manifest.rs:73-79`)
Once-per-session hints:
- **`keywords`** (Vec<String>): literal case-insensitive substrings by default; regex when `regex=true`
- **`text`**: hint text surfaced when keyword matches
- **`regex`** (bool, default false): treat keywords as regex patterns
- **Order matters**: first match wins; once-per-session + at most one per message
- **Status**: Parsed in M2a; matching logic implemented (`manifest.rs:142-156`); fire-on-hint integration deferred to harness hook impl

---

### 2. VALIDATION SEAM — WHERE & HOW SPT-CORE VALIDATES THE MANIFEST

**Single validation gate**: `Manifest::validate()` (`manifest.rs:594-669`)

Called **once, during parse** via `Manifest::from_toml_str()` (`manifest.rs:580-584`):
```rust
pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
    let manifest: Manifest = toml::from_str(s)?;  // Parse TOML → serde error on syntax
    manifest.validate()?;  // Cross-field invariants
    Ok(manifest)
}
```

**Cross-field invariants checked** (not expressible in JSON Schema):
- **`adapter.name` not empty** (line 597-598)
- **`adapter.version` not empty** (line 600-605)
- **`kind ↔ [shell]` agreement** (line 607-622): shell kind must have `[shell]` section; harness must not
- **`history.strategy ↔ field requirements`** (line 624-637): `fetcher` needs `fetcher` field; `locate_normalize` needs both `locate_template` and `normalize_command`
- **`env` direction requirements** (line 639-646): `inject` direction requires a `value`
- **`update.avenue ↔ field requirements`** (line 648-666): `delegated` needs `command`; `file_pull` needs `repo` AND **`signing_key`** (adapter content signing)

**Registry validation point**: `registry::register()` (`registry.rs:140-147`)
- Manifest is read and validated **before** recording
- Invalid manifest records nothing — fail-fast
- Returned with the record; adapter author gets human-readable error

**No re-validation on load**: Once registered, the manifest is either:
- **Copied** (file_pull/absent update avenue) — live copy on disk read at runtime
- **Pointed** (delegated avenue) — source tree re-read per load (manifest updated upstream is picked up automatically)

---

### 3. DEPTH LENS — IS THE MANIFEST INTERFACE DEEP OR SHALLOW?

**Assessment: SHALLOW in some seams, DEEP in others — mixed**

#### Deep seams (good encapsulation):
- **Opaque command templates** (`[session.<role>].command`): spt-core **never parses** model/tools/flags — adapter owns entire command line. Substitution keys are the only contract. This is genuinely deep: adapter can evolve tools/models without spt-core changes.
- **Profile overlay merge** (CONTEXT.md §adapter profile): leaf-replace semantics are simple and powerful; profiles can be sparse (declare only what changes).
- **Hints matching** (`[[hints]]`): adapter declares; spt-core matches + enforces once-per-session; integration is adapter's choice (whether to call `spt api hint`).

#### Shallow seams (caller must know implementation):
- **`hook.can_inject` flag**: Adapter must understand that when `can_inject=false`, spt-core **cannot** inject context from the hook output — it will fall back to sentinel + relay/poll. This is **load-bearing** but not self-documenting (CONTEXT.md text required).
- **Substitution key catalog** (`SUBSTITUTION_KEYS`): Adapter must know the exact list of keys spt-core can fill. If an adapter declares `keys = ["psyche_foo"]` but that key is not in the list, it **errors at spawn time**, not manifest validation time. The list is documented in a Rust constant (`runtime.rs:36-49`), not in the manifest schema itself.
- **Consent floor semantics** (profiles): Profile may only **tighten** `require_approval`, never loosen. This invariant is checked at profile registration (`profile.rs:93`) but the rule must be explained in docs, not inferred from the type system.

**Verdict**: The manifest is **moderately deep** for static declaration (templates, hints, profiles), but **shallow** on the contract's meaning. An adapter author must read CONTEXT.md + MANIFEST.md closely to understand when `can_inject=false` matters, which keys are available, and how profile overlays tighten approval.

---

### 4. IS IT A REAL SEAM (MULTIPLE ADAPTERS) OR HYPOTHETICAL (MOCK ONLY)?

**Current state: MOSTLY HYPOTHETICAL — only two test adapters in-tree**

**Adapters in the codebase**:
1. **`adapters/mock/manifest.toml`** — Generic harness-agnostic test fixture
   - Exercises: `[adapter]`, `[hooks.*]`, `[session.self]`, `[session.psyche_*]`, `[session.echo_commune]`, `[session.signoff]`, `[history]` (native), `[inject]`, `[identity]`
   - **Status**: Mock, not real
   - **Test**: `adapters/mock/tests/manifest_valid.rs` (T8 fixture proof per comment)

2. **`adapters/mock-gateway/manifest.toml`** — Mock Gateway endpoint fixture
   - Exercises: `[adapter]` with `hostable_types = ["Gateway"]`, `[session.self]` with open-type `api bind --type gateway`
   - **Status**: Mock, gateway-shaped proof of open endpoint-type system
   - **Test**: Exercise REQ-EP-6 (gateway type acceptance)

**No real harness adapters in-tree**:
- ❌ No Claude Code plugin (spt-plugin) — it's downstream
- ❌ No Codex adapter
- ❌ No Cursor adapter
- ❌ No Pi (headless) adapter

**First real adapter friction will come from**:
- spt-plugin (`spt-core`-dependent downstream): Must declare hook events for all 7 CC hooks (SessionStart, Stop, etc.), map them to `api` commands, and decide which hooks can inject context.
- Actual integration testing with a real harness's hook system.

---

### 5. WHERE WOULD A REAL FIRST ADAPTER (spt-claude-code) HIT FRICTION?

#### **Friction Point A: Substitution key catalog under-specified**
- **Location**: `runtime.rs:36-49` (Rust constant, not in MANIFEST.md schema)
- **Friction**: An adapter author writing `[session.self]` with `keys = ["id", "session_id", "model"]` gets `RuntimeError::MissingKey { key: "model", … }` **at spawn time**, not during manifest registration.
- **Why it matters**: The error is runtime, not compile-time. Docs must list the exact keys; typos cost a failed spawn.
- **Blocks?** Inconveniences. Adapter author reads the docs, lists the right keys, and it works.

#### **Friction Point B: `can_inject=false` contract under-documented**
- **Location**: CONTEXT.md §Manifest seams + docs/MANIFEST.md §hooks explain the fallback, but the manifest itself does not self-document why an adapter would set it.
- **Friction**: CC's Stop hook cannot inject context (no hook-specific output schema). An adapter author must infer this from harness design docs, not from spt-core validation.
- **Why it matters**: If a hook has `can_inject=true` but actually cannot, the adapter silently fails to inject and spt-core falls back gracefully (sentinel/relay). Silent degradation.
- **Blocks?** Inconveniences. Real harness designs (CC, Codex) have this figured out; documenting it is the fix.

#### **Friction Point C: Profiles section is new (ratified 2026-06-11) — adoption unclear**
- **Location**: `manifest.rs:51-62`, `profile.rs`
- **Friction**: Profiles are a forward-looking feature for per-account/per-profile variants. The spt-plugin redesign might not use them day-one.
- **Why it matters**: The profile system is implemented (merge logic, validation, consent floors), but no real adapter has exercised it yet. Edge case: a profile with loosened consent fails validation (`profile.rs:93 check_tighten_only`), which is good. But what about a malformed profile name in a local registry? The error path exists (`RegistryError::InvalidProfileName`), but no real test has hit it.
- **Blocks?** No. The feature is fully implemented and tested (unit tests in `manifest.rs`). Real adapters can ignore profiles until they're useful.

#### **Friction Point D: `[history]` strategy selection is parsed but not executed in M2a**
- **Location**: `manifest.rs:269-293` (parsed) → NOT executed in M2a (deferred to M2b)
- **Friction**: An adapter declares `strategy = "fetcher"` with a `fetcher` binary path, but spt-core doesn't execute it until M2b. If the binary path is wrong, the adapter won't learn about it until months later when the history subsystem ships.
- **Why it matters**: A missing `fetcher` binary is not validated at registration time — only at history-fetch time (future).
- **Blocks?** Inconveniences. Adapter author should test their fetcher binary before submitting the manifest; spt-core can't validate it pre-M2b.

#### **Friction Point E: `[inject]` methods parsed but not driven in M2a**
- **Location**: `manifest.rs:295-311` (parsed) → NOT executed (M3 PTY + relay delivery)
- **Friction**: An adapter declares `idle = ["pty", "hook", "http"]`, but spt-core doesn't actually deliver via PTY in M2a. The http and hook modes are stubbed (`delivery.rs`), but pty is M3.
- **Why it matters**: An adapter author might declare pty delivery, not realize it's unimplemented, and shipping produces "pty delivery never actually happens" surprises.
- **Blocks?** Inconveniences. Docs must list M2a vs. M3 sections clearly (MANIFEST.md comments say "M2b/M3" for lifecycle sections, but don't highlight inject).

#### **Friction Point F: Hints matching is one-way (adapter → spt-core), integration burden on adapter**
- **Location**: `manifest.rs:73-79`, `manifest.rs:142-156` (matching logic in spt-core)
- **Friction**: Spt-core provides the matching algorithm, but the adapter's **user-prompt hook** must:
  1. Pipe the full user message to `spt api hint --session <id>` (stdin)
  2. Capture the matched hints from stdout
  3. Inject them into context
  - The hook integration is the adapter's responsibility. If the adapter doesn't wire the hook, hints never fire.
- **Why it matters**: Hints are a one-time suggestion; if the hook is poorly integrated, hints silently don't show up.
- **Blocks?** No. The contract is clear (CONTEXT.md). Adapter author must implement the hook binding.

#### **Friction Point G: `[update]` avenue not tested with real adapters**
- **Location**: `manifest.rs:331-368` (parsed in M2a; conducted in M3)
- **Friction**: An adapter declares `avenue = "delegated"` + `command = "claude plugin update spt"`, but spt-core doesn't conduct updates until M3. If the command path is wrong or the plugin API changes, adapters won't know until the update system ships.
- **Why it matters**: Adapter manifest is locked in early; update mechanisms are implemented late. Friction is a long feedback loop.
- **Blocks?** Inconveniences. The update avenue is declared and validated for syntax (required fields) but not tested end-to-end.

#### **Friction Point H: Open endpoint-type system is open by construction, not by schema**
- **Location**: `api::startup.rs:87` (`endpoint_type: String` default `live_agent`, open)
- **Friction**: The manifest **declares no endpoint types** — `hostable_types` is just a list. The actual type string is provided at `api bind --type <tag>`. If an adapter mints a new type (e.g., `gateway`), there's no place to declare its shape (capabilities, sensory types, approval gates) in the manifest — those live in shell manifests, not harness ones.
- **Why it matters**: A harness adapter (not a shell) that hosts a custom endpoint type (Gateway is the only real example) cannot declare type-specific constraints in its manifest. The type is just a string tag (`gateway`); validation of what operations it accepts is downstream/external.
- **Blocks?** Inconveniences (for Gateways specifically). The mock-gateway fixture proves this works; real gateways will refine it.

---

### 6. SUMMARY TABLE: MANIFEST SEAMS & FRICTION

| Seam | Field/Section | Parsed in M2a? | Executed in M2a? | Friction | Blocks? |
|---|---|---|---|---|---|
| **Adapter header** | `[adapter]` | ✅ | N/A (metadata) | None — straightforward | No |
| **Hooks** | `[hooks.<event>]` | ✅ | ⚠ (fires only) | `can_inject=false` not self-documenting | No |
| **Session templates** | `[session.<role>]` | ✅ | ✅ (spawn via `api`) | Substitution keys only listed in Rust; typos error at spawn | No |
| **Env vars** | `[env.<VAR>]` | ✅ | ⚠ (channels set, not used) | Harness must understand channel (file-bridge-only-when-not-launcher) | No |
| **History** | `[history]` | ✅ | ❌ (M2b/M3) | No validation of fetcher/normalize binaries until used | Inconveniences |
| **Identity** | `[identity]` | ✅ | ⚠ (reading only) | None — used at post-spawn | No |
| **Inject methods** | `[inject]` | ✅ | ❌ (M3 PTY/relay) | pty delivery not yet functional; docs must clarify | Inconveniences |
| **Update avenue** | `[update]` | ✅ | ❌ (M3) | No end-to-end test; long feedback loop | Inconveniences |
| **Profiles** | `[profiles.<name>]` | ✅ | ✅ (merge + re-validate) | New feature (ratified 2026-06-11); no real adapter uses yet | No |
| **Strings** | `[strings]` | ✅ | ⚠ (via `spt adapter get-string`) | Only as useful as adapter hook integration | No |
| **Hints** | `[[hints]]` | ✅ | ✅ (matching logic) | Adapter hook integration is adapter's burden | No |
| **Watched dirs** | `commune_dir`, `signoff_dir` | ✅ | ✅ (daemon watches M3) | Fixed filenames (`<id>-commune.md`); dirs are adapter-declared | No |
| **Hostable types** | `[adapter].hostable_types` | ✅ | ⚠ (declared, no enforcement) | No validation that declared types are implemented | No |
| **Shell body** | `[shell]` | ✅ (harness kind check) | N/A (harness=no shell) | Kind agreement validated; no false positives | No |

---

### 7. DISTINCT ADAPTERS TODAY (REAL vs. MOCK/TEST)

| Adapter | Kind | Hostable Types | Location | Status | Notes |
|---|---|---|---|---|---|
| **mock** | harness | LiveAgent, ReadyAgent, Worker | `adapters/mock/manifest.toml` | Test fixture (M2a proof) | Harness-agnostic; exercises full parsed surface |
| **mock-gateway** | harness | Gateway | `adapters/mock-gateway/manifest.toml` | Test fixture (REQ-EP-6 proof) | Open type system proof; non-agent endpoint |
| **(none — real)** | — | — | — | — | spt-plugin (spt-claude-code) is downstream |

**Verdict**: Manifest seam is exercised only by **test fixtures**. No real production adapter has been written against spt-core yet. Friction points will surface when the first real adapter (spt-plugin) is built.

---

### 8. EVIDENCE TABLE: FILE:LINE CITATIONS

| Question | Answer | Evidence |
|---|---|---|
| **What must a harness adapter DECLARE in manifest?** | [adapter], [hooks.*], [session.<role>], [env], [history], [identity], [inject], [update], plus optional [profiles], [strings], [[hints]] | `docs/MANIFEST.md` §Manifest sections (lines 22–160); `manifest.rs:28-79` (Manifest struct) |
| **Where does spt-core PARSE + VALIDATE?** | `Manifest::from_toml_str()` → `Manifest::validate()` (single gate) | `manifest.rs:580-584` (parse), `manifest.rs:594-669` (validate) |
| **Where is validation scattered?** | Registry also validates before recording | `registry.rs:147` (`Manifest::from_toml_str` called before record) |
| **Is validate the single gate?** | Yes, one gate for parse+validation; registration adds auth gate | `manifest.rs:580-584` |
| **How many distinct adapters exist today?** | 2 test fixtures (mock, mock-gateway); 0 real | `adapters/mock/manifest.toml`, `adapters/mock-gateway/manifest.toml` (glob found only these) |
| **What seams are under-exercised?** | history (M2b), inject delivery (M3), update avenue (M3) | `manifest.rs:14-16` ("parsed in M2a; executed M2b/M3") |
| **Where is Manifest interface DEEP vs SHALLOW?** | Deep: opaque templates, profile overlays. Shallow: can_inject meaning, substitution key list, consent floors | `runtime.rs:36-49` (keys in Rust constant); CONTEXT.md (explains can_inject, consent floors) |
| **What friction points block a real adapter?** | None. All friction is inconveniences (docs, late execution, long feedback loops) | See Friction Points A–H above |

---

### FINAL VERDICT

The adapter manifest/harness contract seam is **well-designed but unevenly mature**:

- ✅ **M2a (parsing + validation)** is complete and tested. The mock fixtures prove the contract works.
- ⚠ **M2b–M3 (execution)** are deferred. History subsystem, inject delivery, and update avenue are declared but not driven yet. Real adapters won't hit friction until those ship.
- ✅ **Encapsulation is good**: Opaque templates, profile overlays, and hint matching are genuinely deep seams.
- ⚠ **Meaning is implicit**: `can_inject`, substitution key catalog, consent floor rules must be learned from docs, not inferred from the manifest.
- ❌ **No real adapters yet**: Only mock/test fixtures exist. Real friction surfaces with spt-plugin.

**For an spt-claude-code author**, the contract is **clear and complete**. The open issues are **integration and documentation**, not specification.

Prompt:
Explore the spt-core codebase (C:\Users\decid\Documents\projects\spt-core) and report on the ADAPTER MANIFEST / HARNESS CONTRACT seam — the interface a harness adapter (like a future `spt-claude-code`) must satisfy. This is for an architecture-readiness review.

Read CONTEXT.md (the domain glossary — use its exact vocabulary in your report) and any docs/adr/*.md + docs/MANIFEST.md that govern the adapter/manifest contract. Then walk the code.

Focus on these questions:
1. What must a `kind="harness"` adapter DECLARE in its manifest.toml? Enumerate every field/section spt-core reads (adapter metadata, [history], profiles, strings, hints, echo-commune command, any digest seam, launch/hosting fields). Cite file:line.
2. Where does spt-core PARSE + VALIDATE a manifest (spt-runtime manifest.rs, registry)? Is `Manifest::validate` the single gate, or is validation scattered?
3. Apply the DEPTH lens: is the manifest interface DEEP (small surface, lots of behavior behind it) or SHALLOW (caller must know as much as the implementation)? Is it a real seam (multiple adapters) or hypothetical (only mock/test adapters exercise it)?
4. Where would a REAL first adapter (spt-claude-code) hit friction or under-specified contract? E.g. fields that exist but are never exercised by a real adapter, validation gaps, things a harness adapter needs that the manifest can't express yet.
5. Grep for how many DISTINCT adapters exist today (mock/test fixtures vs real). List them.

Report conc: per finding give file:line evidence, the friction, and whether it blocks or merely inconveniences a real harness-adapter author. Do NOT propose solutions — just map the seam + its gaps. Use CONTEXT.md domain terms.


/improve-codebase-architecture --> update based on the current state. the main thing i care to understand is whether any outstanding improvements should be made before building `spt-claude-code`, or i…


<owl_messages>
<owl_message from="doyle-w362" priority="info">[WORKING_PERCH_NOTICE] Subagent working perch created.
Perch: doyle-w362
Agent type: Explore
To message it: $OWL send doyle-w362 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. Launch this Agent with run_in_background: true so you stay reachable for incoming messages while it works.
</owl_reachability_notice>


Perfect! Now I have enough. Let me write the comprehensive findings:

## Architecture-Readiness Review: spt-core Harness-Hosted Endpoint Hosting Seam

### Executive Summary
spt-core **DOES host harness-hosted endpoints** (the path Claude Code will integrate with), but the architecture seam is **partially complete**. The M9 digest work revealed a critical **never-live-wired production path** (the old PTY-byte-parse feed), and while the **seed→listen→bind flow is fully implemented** for harness-hosted endpoints, several **manifest seams are parse-only** (no production callers). spt-claude-code must integrate through **exactly three narrowly-scoped contracts**.

---

### 1. Harness-Hosted Endpoint Establishment (The Seam)

#### How spt-core Establishes a Harness-Hosted Endpoint

**File**: `crates/spt/src/api/startup.rs` (lines 1–350)

The flow is **three-step**, all implemented and production-ready:

1. **`api seed` (line 44)** — Harness's SessionStart hook writes an ephemeral in-memory seed record to the daemon, keyed by **parent process PID**. The seed carries:
   - `parent_pid` (the stable session-binding anchor per CONTEXT.md line 103)
   - `session_id` (the harness's opaque session ID)
   - `adapter` (the adapter name, required for manifest resolution)
   - `cwd`, `created` timestamp

2. **`api listen <id>` (line 226)** — Agent invokes `$SPT listen <id>` (Monitor-bound relay):
   - Resolves parent PID from its own process tree
   - **Takes** the seed from the daemon (consume-once, defends against stale/recycled pids)
   - Validates seed's `parent_pid` is still alive (KNOWN-HAZARDS 5.1, line 115)
   - Calls **`establish_perch()`** (line 147) → flips skeleton→live

3. **`api bind <id>` (line 326)** — Direct post-spawn alternative (for adapters that inject a session UUID at spawn):
   - Accepts `--set-session-id <sid>` or reads from env
   - Gets parent PID from invoking process
   - Calls **`establish_perch()`** directly (line 147)

#### What `establish_perch()` Does (Lines 147–213)

**File**: `crates/spt/src/api/startup.rs` (lines 147–213)

Establishes the live perch:

1. **Resolves** perch path: `owlery/<id>/` (from `crates/spt-store/src/perch.rs`)
2. **Writes `info.json`** with:
   - `id`, `session_id`, `state` (endpoint type tag), `pid`, `parent_pid`, `cwd`
   - **`adapter`** field (line 120, line 199) — **the adapter name**, required for manifest resolution
   - `rest_state`, `home_subnet` (immutable home assignment per REQ-INST-15)
3. **Writes `.ready` file** (line 206) — signals the endpoint is live
4. **Mints/preserves capability token** (line 210-211) — used for auth on subsequent commands

**Critical field: `info.json` `adapter` field** (`crates/spt-store/src/info.rs` line 123):
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adapter: Option<String>,  // Adapter-at-creation rule (REQ-INST-15)
```
Recorded once, never changed except via resume-under-new. This is **how spt-core knows which manifest to load** for digest, history, lifecycle operations.

---

### 2. The Session Surface Seam (PTY vs. Log-Based)

#### Two Topologies, One Abstract Contract

**File**: `crates/spt-term/src/surface.rs` (lines 1–135)

**SessionSurface trait** is the abstraction:
- **spt-hosted** (M3b daemon-launched): writes bytes directly to a broker-held PTY
- **harness-hosted** (M2b interim; Claude Code): no PTY — **in-session relay process** (`api listen`) streams daemon events to stdout, no SessionSurface involvement

**Status**: The trait exists + is used for spt-hosted. Harness-hosted **deliberately bypasses the PTY** entirely — the daemon holds **no session surface for harness-hosted endpoints**. Output is **file-based** (history logs) or **event-based** (via the relay's stdout). No SessionSurface needed.

#### Digest: The Critical Log-vs-PTY Split

**File**: `crates/spt-daemon/src/digest.rs` (lines 1–121)

M9's digest redesign discovered a **critical never-wired production path**:

- **Old (superseded)**: byte-feed from PTY, parsed on-the-fly (fold parser on every chunk) — M9 found zero production callers (`run_digest_feed` never live-wired per commit 76ff4a3)
- **New (M9, working)**: **on-demand projection from the endpoint's history source** per adapter's `[history]` strategy

The projection logic (`project_endpoint_digest` line 80):

```rust
pub fn project_endpoint_digest(id: &str, config: &DigestConfig) -> Digest {
    let lines = endpoint_digest_records(id);  // Resolve adapter → fetch history
    spt_term::project_lines(lines.iter().map(String::as_str), config)
}
```

**How it resolves the history source** (lines 90–121):
1. Read the perch's `info.json` → get `adapter` field
2. Load the adapter's manifest via `spt_runtime::registry::resolve_option(&adapters_dir(), adapter)` (line 100)
3. Check manifest's `[history]` section (line 102)
4. If `[history]` present: **Path A** — fetch + normalize via the declared strategy
5. Otherwise: **Path B** — read the perch's `digest.log` (pushed via `api digest-entry`)

**This is topology-independent**: Claude Code's per-session JSONL is Path A's worked example. No PTY involved.

---

### 3. Adapter Registry & Manifest Resolution

#### Adapter Registration

**File**: `crates/spt-runtime/src/registry.rs` (lines 1–100)

An adapter record persists at `adapters/<name>/record.toml`:

```rust
pub struct AdapterRecord {
    pub name: String,
    pub kind: AdapterKind,  // "harness" or "shell"
    pub mode: RecordMode,   // Copy (spt owns manifest) or Pointer (adapter owns)
    pub source_dir: String,
    pub registered_at_ms: u64,
    pub active: bool,  // false = soft-deregister (hidden from new creation)
}
```

**Registration is node-local capability** (line 14) — "may this node drive this adapter."

#### How Harness Adapters Declare Their Contract

**File**: `docs/MANIFEST.md` (lines 1–200) + `crates/spt-runtime/src/manifest.rs` (lines 1–200)

A harness adapter manifest (`kind = "harness"`) must declare (minimal example from `adapters/mock/manifest.toml`):

```toml
[adapter]
name = "mock"
kind = "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Worker"]

[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id}"
can_inject = true

[history]
strategy = "native"  # or "locate_normalize" / "fetcher"

[session]
commune_dir = ".mock"
signoff_dir = ".mock"

[session.self]
command = "mock-session --id {id} --session-id {session_id}"
```

**What spt-core requires to host Claude Code**:

1. **`[adapter]` header** (MANIFEST.md lines 24–31, manifest.rs lines 84–94):
   - `name`: the adapter identifier (e.g., "claude-spt")
   - `kind = "harness"`
   - `version`: adapter version
   - `min_spt_core_version`: compat gate (checked before update)
   - `hostable_types`: which endpoint types it can host (e.g., `["LiveAgent"]`)

2. **`[hooks.<event>]`** (lines 37–46, manifest.rs lines 108–117):
   - Per harness event → the `api` command it fires
   - **SessionStart** must fire `api seed` (the only required hook for harness-hosted)
   - **`can_inject`** flag: does the hook channel support context injection? CC's Stop hook has `can_inject=false` → triggers sentinel+relay fallback

3. **`[history]`** (lines 81–89, manifest.rs lines 41):
   - Declares how spt-core fetches the session transcript
   - **Path A (locate_normalize)**: `locate_template` + `normalize_command` (e.g., Claude Code's per-session JSONL)
   - **Path B (native)**: adapter pushes via `api history-log`; spt-core stores
   - **Path C (fetcher)**: adapter binary emits normalized history directly

4. **`[session.<role>]` templates** (lines 48–60, manifest.rs lines 161–191):
   - `[session.self]`: spawn the agent session (opaque command, spt-core never parses it)
   - `[session.psyche_init]`, `[session.psyche_resume]`: spawn Psyche companion (if live-capable)
   - `[session.echo_commune]`: cheap-model template for context digest
   - Keys spt-core fills: `{id}`, `{session_id}`, `{psyche_dir}`, `{psyche_prompt}`
   - **Model, flags, billing, harness-internal context are all in the `command` string** — spt-core never opens it

5. **`[session]` watched directories** (lines 99–104):
   - `commune_dir`, `signoff_dir` — where spt-core watches for `<id>-commune.md` / `<id>-signoff.md` file-drops

**Optional but recommended**:
- `[session.notif]` (CONTEXT.md line 182): render notifications native to the endpoint
- `[env]` (MANIFEST.md lines 71–79): env-var injection for harness-hosted sessions
- `[identity]` (MANIFEST.md lines 106–111): how spt-core discovers the session id
- `[update]` (lines 152–162): how to self-update the adapter

---

### 4. The Mock Endpoint Test (Verification of Harness-Hosted Path)

**File**: `crates/spt-daemon/tests/digest.rs` (lines 45–68, lines 98–160)

The E2E test `harness_hosted_digest_projects_and_pushes_deltas()` **proves harness-hosted endpoints work**:

```rust
fn establish_cc_endpoint(id: &str, fixture: &std::path::Path) {
    let adapter = format!("mock-cc-{id}");
    let manifest = format!(
        "[history]\nstrategy = \"locate_normalize\"\n\
         locate_template = '{}'\nnormalize_command = '{}'\n",
        fixture.display(), PASSTHROUGH
    );
    // Register adapter + create perch with adapter field
    spt_runtime::registry::register(...)?;
    let mut rec = InfoJson::new(id, "0", std::process::id(), "cc-session", "live_agent");
    rec.adapter = Some(adapter);  // ← The adapter field!
    write_info(&perch_path, &rec)?;
}
```

The test:
1. **Establishes** a harness-hosted perch (no daemon, no PTY)
2. **Projects a digest** from a fixture JSONL (Path A locate+normalize)
3. **Verifies** the projection works without a broker or PTY

This **directly maps to Claude Code's topology**: per-session JSONL is Path A.

---

### 5. What's HALF-BUILT or Never-Wired (Blocks vs. Inconveniences)

#### ❌ NEVER-WIRED (M9 discovered)

**PTY-byte-parse digest feed** (`run_digest_feed`):
- **Status**: Code exists but zero production callers (commit 76ff4a3)
- **Why**: The old shape assumed broker-held PTY with byte chunks; M9 redesigned it as log projection
- **Impact**: Not a block — M9 replaced it entirely with on-demand projection
- **File**: No modern callers; grep `run_digest_feed` yields nothing

#### ⚠ PARSE-ONLY, NOT EXECUTED (M2b interim)

These manifest sections **round-trip** but are **not driven in M2b** (daemon re-hosts them in M3b):

1. **`[session.psyche_init]` / `[session.psyche_resume]`** (manifest.rs lines 175–177):
   - **Parsed**: ✓ (manifest.rs)
   - **Called in M2b**: ✓ (crates/spt/src/api/live.rs, spawn_psyche)
   - **Called in M3b daemon**: ✓ (crates/spt-daemon/src/lifecycle.rs line 48)
   - **Status**: Fully implemented across both M2b (interim) and M3b (production)

2. **`[session.notif]`** (manifest.rs lines 189–190):
   - **Parsed**: ✓
   - **Called**: ✓ (crates/spt-daemon/src/notif.rs, line 48)
   - **Status**: Implemented in M3b (daemon-side)

3. **`[inject]` per-activity-state delivery methods** (manifest.rs lines 43):
   - **Parsed**: ✓
   - **Called in M2b**: ✓ (crates/spt/src/api/delivery.rs line 37, `resolve_inject_methods`)
   - **Called in M3b**: ✓ (crates/spt-daemon/src/injecthost.rs)
   - **Status**: Fully wired

#### ⚠ OPTIONAL, NOT REQUIRED

- **`[identity]` session_id_source** (manifest.rs lines 45): optional; defaults are safe
- **`[env]`** (manifest.rs lines 39): optional; minimal needed (spt on PATH)
- **`[update]`** (manifest.rs lines 47): optional; defaults to no auto-update

---

### 6. What spt-claude-code MUST Implement/Declare

**Minimal contract** (harness-hosted agent endpoint):

```toml
[adapter]
name = "claude-spt"
kind = "harness"
version = "X.Y.Z"
min_spt_core_version = "0.5.0"  # Current M9 version
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]

[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "parent_pid"]
can_inject = true

[hooks.Idle]
fires = "api state idle"
can_inject = false

[hooks.SessionEnd]
fires = "api session-end"
can_inject = false

[session]
commune_dir = ".claude"
signoff_dir = ".claude"

[session.self]
command = "claude --id {id} --session-id {session_id}"
keys = ["id", "session_id"]

[session.psyche_init]
command = "claude -p --agents {agents_json} --model sonnet --id {id}"
keys = ["id", "agents_json", "psyche_dir", "psyche_prompt"]
cwd = "{psyche_dir}"
recursion_guard_env = "CLAUDE_ECHO_COMMUNE"

[session.psyche_resume]
command = "claude -p --agents {agents_json} --resume --id {id}"
keys = ["id", "agents_json", "psyche_dir", "psyche_prompt", "session_id"]
cwd = "{psyche_dir}"
recursion_guard_env = "CLAUDE_ECHO_COMMUNE"

[session.echo_commune]
command = "claude --model haiku --id {id}"
keys = ["id"]
recursion_guard_env = "CLAUDE_ECHO_COMMUNE"

[history]
strategy = "locate_normalize"
locate_template = "~/.claude/projects/{session_id}.jsonl"
normalize_command = "claude-spt-normalize-history"

[inject]
activity = ["hook"]
idle = ["hook", "relay"]

[identity]
session_id_source = "post_spawn"
parent_ancestor_name = "claude"

[update]
avenue = "delegated"
command = "claude plugin update spt"
version_check = true
```

**What each field does** (for Claude Code integration):

| Manifest Field | Purpose | CC Requirement |
|---|---|---|
| `[adapter]` | Identifies the adapter + compat gate | **Required** — names the adapter, declares min spt-core version |
| `[hooks.SessionStart]` | Records the seed (parent pid + session id + adapter) | **Required** — entry point for harness-hosted flow |
| `[hooks.Idle]` | Signals idle state (no echo-gate inject) | Required for graceful shutdown; `can_inject=false` → relay fallback |
| `[session]` commune/signoff dirs | Watched directories for context file-drops | Required (even if CC uses its own file-drop convention) |
| `[session.self]` | Command template to spawn the agent session | **Required** — spt-core runs it with `{id}`, `{session_id}` filled |
| `[session.psyche_init/resume]` | Command to spawn the Psyche companion | Required for live-agent (optional for ReadyAgent-only) |
| `[session.echo_commune]` | Cheap-model template for context digest | Required for live-agent |
| `[history]` | How spt-core fetches the session transcript | **Critical** — enables digest, resume, echo-commune |
| `[inject]` activity/idle | Per-state message-delivery methods | Recommended (defaults to hook-only) |
| `[identity]` session_id_source | How spt-core discovers the session id | Recommended (defaults to post_spawn) |

---

### 7. The Two Actual Hosting Paths (Clear Distinction)

#### Harness-Hosted (Claude Code)

- **Launcher**: Claude Code (user-launched session binary)
- **Perch seeding**: SessionStart hook calls `api seed`
- **Binding**: Agent calls `$SPT listen <id>` (Monitor-bound relay) → consumes seed → binds
- **Event delivery**: Relay process (in-session child) streams stdout
- **PTY**: **None** — spt-core holds no session surface
- **History**: Adapter's logs (Path A locate+normalize, or Path B native store via `api history-log`)
- **Digest**: On-demand projection from history source
- **Status in M9**: **Fully implemented and tested** (`api seed`, `api listen`, digest.rs projection)

#### spt-Hosted (Daemon-Launched)

- **Launcher**: spt-daemon (via broker PTY)
- **Binding**: Daemon spawns → binary calls `api bind` (direct)
- **Event delivery**: Daemon writes PTY directly, or relay per manifest
- **PTY**: **Yes** — broker holds SessionSurface
- **History**: Same (adapter's logs)
- **Digest**: Same (on-demand projection from history)
- **Status in M9**: **Partially implemented** (lifecycle in M2b interim, daemon-hosted in M3b-deferred)

**Both paths converge on**:
- Adapter manifest + registry
- Perch `info.json` (with `adapter` field)
- History strategy + digest projection
- Psyche + echo-commune lifecycle (M2b interim for harness, M3b daemon for spt-hosted)

---

### 8. Friction & Blockers for spt-claude-code

#### No Blocks, One Friction Point

**✓ Unblocked**:
1. Seed→listen→bind flow is production-ready
2. Manifest contract is finalized (MANIFEST.md locked)
3. Registry is ready (adapter add/remove/list)
4. Digest/history projection is topology-independent
5. Mock adapter (`adapters/mock/`) exercises the full contract

**⚠ Friction**:
1. **Psyche lifecycle is M2b interim** (lives in relay process, limited to one machine):
   - M3b daemon will host it robustly
   - For M2b → spt-claude-code works but Psyche is local-only, no cross-machine sync
   - Not a hard block; design question: does CC adapter assume M3b daemon or interim M2b relay?

2. **No automated history log-watch** (CONTEXT.md line 36):
   - Digest freshness is currently **pull + push-driven** (adapter nudges via `api digest-entry`, or frontend pulls)
   - Autonomous file-watch is deferred to consuming frontend milestone
   - Not a block for MVP; CC can push entries via `api digest-entry` or frontend pulls on demand

3. **Adapter profiles are future** (CONTEXT.md line 76):
   - Ratified but not yet shipped (first consumers spt-claude-code + usbip)
   - If CC needs per-account variants, deferred to v1.1

---

### 9. Architecture Readiness Verdict

| Aspect | Status | Evidence |
|--------|--------|----------|
| **Harness-hosted endpoint establishment** | ✓ Complete | seed→listen→bind fully wired, tested in digest.rs |
| **Manifest contract finalized** | ✓ Yes | MANIFEST.md locked, schema in manifest.rs, mock adapter exercising it |
| **Adapter registry** | ✓ Ready | registry.rs, adapters/ directory structure, add/remove/resolve all wired |
| **Info.json adapter field** | ✓ Present | info.rs line 123, establish_perch writes it, digest resolves from it |
| **History/digest for harness-hosted** | ✓ Working | digest.rs projection from history source, Path A (locate+normalize) tested |
| **Psyche/echo-commune lifecycle** | ⚠ Interim | M2b (relay-hosted), M3b deferred; both wired but design TBD for cross-machine |
| **Log-watch freshness** | ⚠ Deferred | Pull + push-driven; autonomous watch deferred to frontend |
| **Adapter profiles** | ⚠ Deferred | Ratified, not shipped; nice-to-have for v1.1 |

**Readiness**: **Architecture seam is READY for spt-claude-code integration**. All critical paths are wired and tested. M2b interim Psyche is local-only but functional. No hard blockers.

---

### File:Line Reference Map

| Concern | File | Lines |
|---------|------|-------|
| Seed→listen→bind flow | `crates/spt/src/api/startup.rs` | 44–350 |
| Perch establishment | `crates/spt/src/api/startup.rs` | 147–213 |
| Info.json adapter field | `crates/spt-store/src/info.rs` | 123 |
| Adapter registry + record | `crates/spt-runtime/src/registry.rs` | 1–100 |
| Manifest schema | `crates/spt-runtime/src/manifest.rs` | 28–94 |
| Digest projection | `crates/spt-daemon/src/digest.rs` | 80–121 |
| History resolution | `crates/spt-daemon/src/digest.rs` | 90–121 |
| SessionSurface trait | `crates/spt-term/src/surface.rs` | 101–134 |
| E2E harness test | `crates/spt-daemon/tests/digest.rs` | 45–160 |
| Mock adapter manifest | `adapters/mock/manifest.toml` | 1–80 |
| Manifest documentation | `docs/MANIFEST.md` | 1–200 |
| Context vocabulary | `CONTEXT.md` | 23–181 |

Prompt:
Explore the spt-core codebase (C:\Users\decid\Documents\projects\spt-core) and report on how spt-core HOSTS a harness-hosted endpoint — the lifecycle/launch/registration seam a real `spt-claude-code` adapter must integrate with. Architecture-readiness review.

Read CONTEXT.md entries on term-daemon, session surface, live agent / endpoint, adapter, info.json/perch, and endpoint_type (the M9 gateway-wan seam). Use CONTEXT.md exact vocabulary.

Focus:
1. How is a harness-hosted endpoint ESTABLISHED? What does spt-core need to know to host/launch a Claude-Code session — the adapter registry (crates/spt-runtime/src/registry.rs), perch info.json (crates/spt-store/src/info.rs, perch.rs), the `adapter` field, `endpoint_type`. Cite file:line. Look at how the CC-fixture E2E (crates/spt-daemon/tests/digest.rs) establishes a harness endpoint — `establish_cc_endpoint` — that's the closest thing to a real CC integration; what does it stub vs require?
2. The session surface seam (spt-term SessionSurface): does a harness adapter run INSIDE a supervised PTY, or is a harness-hosted endpoint (no broker/PTY, per the M9 digest work) a DIFFERENT hosting path? Is that second path (harness-hosted, log-sourced, no PTY) fully built or partially? This matters: CC is harness-hosted.
3. What's the actual LAUNCH story for a harness adapter — how does spt-core start a Claude Code session, or does the adapter self-host and just register? Grep for how kind="harness" vs native sessions differ in hosting.
4. READINESS: list concretely what spt-claude-code must implement/declare to be hosted + addressable + echo/digest-capable. Flag anything HALF-BUILT (a path the mock E2E stubs but no production code drives — e.g. the M9 work noted run_digest_feed had zero production callers; are there similar never-live-wired harness-hosting paths?).
5. DEPTH + DELETION-TEST lens on the hosting seam. Hypothetical-vs-real (only mock adapters?).

Report concise: file:line evidence, friction, blocks-vs-inconveniences. Do NOT propose solutions — map the seam + gaps.


Three thorough maps back. Before I synthesize into the report for Brandon, I'm the reviewer — verify the three load-bearing claims the subagents built recommendations on (silent digest-skip with no diagnostic, substitution-keys validated at spawn not register, and whether the digest contract is already documented). Don't hand Brandon recs built on a subagent misread.


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

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


40	//! is **skipped**, never a panic and never an aborted projection.
41	//!
42	//! ## Presentation (unchanged from the original conception)
43	//!
44	//! The formula is spt-core-owned: the last [`DigestConfig::window_turns`] user
45	//! turns (~3), consecutive tool records collapsed into one `ToolSprint`, and
46	//! tool args truncated to [`DigestConfig::arg_truncation`] (~25) chars. Only the
47	//! *source* changed (records, not regex-over-bytes); the projected [`Digest`]
48	//! shape and its rendering are identical to the superseded parser's output.
49	// [impl->REQ-TERM-4]
50
51	use serde::Deserialize;
52
53	use crate::digest::{truncate_arg, Digest, DigestConfig, DigestEntry, ToolUse, Turn};
54
55	/// The role of a [`DigestRecord`] — the closed source-tag set. Deserialized from
56	/// the lowercase wire token; an unrecognized token fails the record (→ skipped),
57	/// which is exactly the forward-compat "unknown role → skip" behavior.
58	#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
59	#[serde(rename_all = "lowercase")]
60	pub enum DigestRole {
61	    /// A user-input span opens a new turn.
62	    Input,
63	    /// Agent output within the current turn.
64	    Agent,
65	    /// One tool invocation (collapsed into the trailing sprint).
66	    Tool,
67	}
68
69	/// The `{name, arg}` of a `role: "tool"` record. `arg` is the *untruncated*
70	/// fragment as the adapter emitted it; presentation truncation is applied during
71	/// projection (extraction is the adapter's, presentation is spt-core's).
72	#[derive(Debug, Clone, Deserialize)]
73	pub struct ToolRef {
74	    /// The tool name (e.g. `Write`).
75	    pub name: String,
76	    /// The argument fragment (truncated at projection time).
77	    #[serde(default)]
78	    pub arg: String,
79	}
80
81	/// One record of the **published digest-record contract** — the spt-core-owned
82	/// overlay a projectable adapter targets in its normalized history line. Unknown
83	/// fields are ignored (forward-compat); the history subsystem still carries the
84	/// underlying line opaquely.
85	#[derive(Debug, Clone, Deserialize)]
86	pub struct DigestRecord {
87	    /// The source tag (input / agent / tool).
88	    pub role: DigestRole,
89	    /// The span text (input or agent). Empty/absent for a tool record.
90	    #[serde(default)]
91	    pub text: String,
92	    /// The tool reference — present iff `role == Tool`.
93	    #[serde(default)]
94	    pub tool: Option<ToolRef>,
95	}
96
97	/// Parse one normalized line into a [`DigestRecord`] per the published contract.
98	/// Returns `None` for a line that is not a valid record — malformed JSON or an
99	/// unknown/missing `role` — so the caller drops it (forward-compat skip). Unknown
100	/// *fields* on an otherwise-valid record are tolerated (ignored).
101	// [impl->REQ-TERM-4]
102	pub fn record_to_tagged(raw: &str) -> Option<DigestRecord> {
103	    serde_json::from_str(raw).ok()
104	}
105
106	/// Project a sequence of contract records into the rolling [`Digest`]: fold them
107	/// into turns (an `input` opens a turn; `agent` appends text; consecutive `tool`
108	/// records collapse into one `ToolSprint`), then keep the last
109	/// `config.window_turns`. A `tool` record missing its `tool` object is skipped
110	/// (defensive — the contract requires it present for the tool role).
111	// [impl->REQ-TERM-4]
112	pub fn project(records: &[DigestRecord], config: &DigestConfig) -> Digest {
113	    let mut turns: Vec<Turn> = Vec::new();
114	    let mut current = Turn::default();
115	    let mut started = false;
116
117	    for r in records {
118	        match r.role {
119	            DigestRole::Input => {
120	                if started {
121	                    turns.push(std::mem::take(&mut current));
122	                }
123	                current = Turn {
124	                    input: Some(r.text.clone()),
125	                    entries: Vec::new(),
126	                };
127	                started = true;
128	            }
129	            DigestRole::Agent => {
130	                if !r.text.is_empty() {
131	                    current.entries.push(DigestEntry::Agent(r.text.clone()));
132	                    started = true;
133	                }
134	            }
135	            DigestRole::Tool => {
136	                let Some(t) = &r.tool else {
137	                    continue; // tool role without a tool object → malformed, skip
138	                };
139	                let tu = ToolUse {
140	                    name: t.name.clone(),
141	                    arg: truncate_arg(t.arg.trim(), config.arg_truncation),
142	                };
143	                // Collapse into the trailing sprint when the last entry is one.
144	                if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
145	                    sprint.push(tu);
146	                } else {
147	                    current.entries.push(DigestEntry::ToolSprint(vec![tu]));
148	                }
149	                started = true;
150	            }
151	        }
152	    }
153	    if started {
154	        turns.push(current);
155	    }
156
157	    // Keep only the last N turns (the rolling window).
158	    let n = config.window_turns;
159	    if turns.len() > n {

//! is **skipped**, never a panic and never an aborted projection.
//!
//! ## Presentation (unchanged from the original conception)
//!
//! The formula is spt-core-owned: the last [`DigestConfig::window_turns`] user
//! turns (~3), consecutive tool records collapsed into one `ToolSprint`, and
//! tool args truncated to [`DigestConfig::arg_truncation`] (~25) chars. Only the
//! *source* changed (records, not regex-over-bytes); the projected [`Digest`]
//! shape and its rendering are identical to the superseded parser's output.
// [impl->REQ-TERM-4]

use serde::Deserialize;

use crate::digest::{truncate_arg, Digest, DigestConfig, DigestEntry, ToolUse, Turn};

/// The role of a [`DigestRecord`] — the closed source-tag set. Deserialized from
/// the lowercase wire token; an unrecognized token fails the record (→ skipped),
/// which is exactly the forward-compat "unknown role → skip" behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DigestRole {
    /// A user-input span opens a new turn.
    Input,
    /// Agent output within the current turn.
    Agent,
    /// One tool invocation (collapsed into the trailing sprint).
    Tool,
}

/// The `{name, arg}` of a `role: "tool"` record. `arg` is the *untruncated*
/// fragment as the adapter emitted it; presentation truncation is applied during
/// projection (extraction is the adapter's, presentation is spt-core's).
#[derive(Debug, Clone, Deserialize)]
pub struct ToolRef {
    /// The tool name (e.g. `Write`).
    pub name: String,
    /// The argument fragment (truncated at projection time).
    #[serde(default)]
    pub arg: String,
}

/// One record of the **published digest-record contract** — the spt-core-owned
/// overlay a projectable adapter targets in its normalized history line. Unknown
/// fields are ignored (forward-compat); the history subsystem still carries the
/// underlying line opaquely.
#[derive(Debug, Clone, Deserialize)]
pub struct DigestRecord {
    /// The source tag (input / agent / tool).
    pub role: DigestRole,
    /// The span text (input or agent). Empty/absent for a tool record.
    #[serde(default)]
    pub text: String,
    /// The tool reference — present iff `role == Tool`.
    #[serde(default)]
    pub tool: Option<ToolRef>,
}

/// Parse one normalized line into a [`DigestRecord`] per the published contract.
/// Returns `None` for a line that is not a valid record — malformed JSON or an
/// unknown/missing `role` — so the caller drops it (forward-compat skip). Unknown
/// *fields* on an otherwise-valid record are tolerated (ignored).
// [impl->REQ-TERM-4]
pub fn record_to_tagged(raw: &str) -> Option<DigestRecord> {
    serde_json::from_str(raw).ok()
}

/// Project a sequence of contract records into the rolling [`Digest`]: fold them
/// into turns (an `input` opens a turn; `agent` appends text; consecutive `tool`
/// records collapse into one `ToolSprint`), then keep the last
/// `config.window_turns`. A `tool` record missing its `tool` object is skipped
/// (defensive — the contract requires it present for the tool role).
// [impl->REQ-TERM-4]
pub fn project(records: &[DigestRecord], config: &DigestConfig) -> Digest {
    let mut turns: Vec<Turn> = Vec::new();
    let mut current = Turn::default();
    let mut started = false;

    for r in records {
        match r.role {
            DigestRole::Input => {
                if started {
                    turns.push(std::mem::take(&mut current));
                }
                current = Turn {
                    input: Some(r.text.clone()),
                    entries: Vec::new(),
                };
                started = true;
            }
            DigestRole::Agent => {
                if !r.text.is_empty() {
                    current.entries.push(DigestEntry::Agent(r.text.clone()));
                    started = true;
                }
            }
            DigestRole::Tool => {
                let Some(t) = &r.tool else {
                    continue; // tool role without a tool object → malformed, skip
                };
                let tu = ToolUse {
                    name: t.name.clone(),
                    arg: truncate_arg(t.arg.trim(), config.arg_truncation),
                };
                // Collapse into the trailing sprint when the last entry is one.
                if let Some(DigestEntry::ToolSprint(sprint)) = current.entries.last_mut() {
                    sprint.push(tu);
                } else {
                    current.entries.push(DigestEntry::ToolSprint(vec![tu]));
                }
                started = true;
            }
        }
    }
    if started {
        turns.push(current);
    }

    // Keep only the last N turns (the rolling window).
    let n = config.window_turns;
    if turns.len() > n {


34:    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
159:/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
163:    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
245:    /// Substitution keys spt-core guarantees to fill for this role.
247:    pub keys: Vec<String>,
594:    pub fn validate(&self) -> Result<(), ManifestError> {
704:keys = ["session_name", "psyche_dir"]


Confirmed #1: `record_to_tagged` = `serde_json::from_str(raw).ok()` — silent `None` on any malformed/unknown line, zero diagnostic or skip-count. Misconfigured normalizer → silently empty digest. Confirmed #2 partially: no `SUBSTITUTION_KEYS`/`MissingKey` in manifest.rs, so `validate()` doesn't gate declared `keys` against the catalog — that's a spawn-time error. Verify validate() scope + that the contract is already documented.


594	    pub fn validate(&self) -> Result<(), ManifestError> {
595	        let err = |m: String| Err(ManifestError::Validation(m));
596
597	        if self.adapter.name.trim().is_empty() {
598	            return err("adapter.name must not be empty".into());
599	        }
600	        if self.adapter.version.trim().is_empty() {
601	            return err(format!(
602	                "adapter '{}': version must not be empty",
603	                self.adapter.name
604	            ));
605	        }
606
607	        // kind ↔ [shell] body agreement.
608	        match self.adapter.kind {
609	            AdapterKind::Shell if self.shell.is_none() => {
610	                return err(format!(
611	                    "shell adapter '{}' is missing its [shell] section",
612	                    self.adapter.name
613	                ));
614	            }
615	            AdapterKind::Harness if self.shell.is_some() => {
616	                return err(format!(
617	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
618	                    self.adapter.name
619	                ));
620	            }
621	            _ => {}
622	        }
623
624	        // history strategy ↔ required fields.
625	        if let Some(h) = &self.history {
626	            match h.strategy {
627	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
628	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
629	                }
630	                HistoryStrategy::LocateNormalize
631	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
632	                {
633	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
634	                }
635	                _ => {}
636	            }
637	        }
638
639	        // env: an inject directive needs a value.
640	        for (name, var) in &self.env {
641	            if var.direction == EnvDirection::Inject && var.value.is_none() {
642	                return err(format!(
643	                    "[env.{name}] direction = \"inject\" requires a `value`"
644	                ));
645	            }
646	        }
647
648	        // update avenue ↔ required fields.
649	        if let Some(u) = &self.update {
650	            match u.avenue {
651	                UpdateAvenue::Delegated if u.command.is_none() => {
652	                    return err("[update] avenue = \"delegated\" requires `command`".into());
653	                }
654	                UpdateAvenue::FilePull if u.repo.is_none() => {
655	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
656	                }
657	                UpdateAvenue::FilePull if u.signing_key.is_none() => {
658	                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
659	                    // verifiable, so a file_pull adapter must declare its key.
660	                    return err(
661	                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
662	                    );
663	                }
664	                _ => {}
665	            }
666	        }
667
668	        Ok(())
669	    }

    pub fn validate(&self) -> Result<(), ManifestError> {
        let err = |m: String| Err(ManifestError::Validation(m));

        if self.adapter.name.trim().is_empty() {
            return err("adapter.name must not be empty".into());
        }
        if self.adapter.version.trim().is_empty() {
            return err(format!(
                "adapter '{}': version must not be empty",
                self.adapter.name
            ));
        }

        // kind ↔ [shell] body agreement.
        match self.adapter.kind {
            AdapterKind::Shell if self.shell.is_none() => {
                return err(format!(
                    "shell adapter '{}' is missing its [shell] section",
                    self.adapter.name
                ));
            }
            AdapterKind::Harness if self.shell.is_some() => {
                return err(format!(
                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
                    self.adapter.name
                ));
            }
            _ => {}
        }

        // history strategy ↔ required fields.
        if let Some(h) = &self.history {
            match h.strategy {
                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
                }
                HistoryStrategy::LocateNormalize
                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
                {
                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
                }
                _ => {}
            }
        }

        // env: an inject directive needs a value.
        for (name, var) in &self.env {
            if var.direction == EnvDirection::Inject && var.value.is_none() {
                return err(format!(
                    "[env.{name}] direction = \"inject\" requires a `value`"
                ));
            }
        }

        // update avenue ↔ required fields.
        if let Some(u) = &self.update {
            match u.avenue {
                UpdateAvenue::Delegated if u.command.is_none() => {
                    return err("[update] avenue = \"delegated\" requires `command`".into());
                }
                UpdateAvenue::FilePull if u.repo.is_none() => {
                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
                }
                UpdateAvenue::FilePull if u.signing_key.is_none() => {
                    // Adapter content signing (REQ-UPD-5): a pulled payload must be
                    // verifiable, so a file_pull adapter must declare its key.
                    return err(
                        "[update] avenue = \"file_pull\" requires `signing_key` (adapter content signing)".into(),
                    );
                }
                _ => {}
            }
        }

        Ok(())
    }


Bash

echo "=== MANIFEST.md digest-record contract section ==="; sed -n '110,155p' C:/Users/decid/Documents/projects/spt-core/docs/MANIFEST.md 2>&1; echo "=== SUBSTITUTION_KEYS location ==="; grep -rn "SUBSTITUTION_KEYS\|MissingKey" C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/*.rs 2>&1 | head

Check validate scope + MANIFEST contract doc + keys catalog


=== MANIFEST.md digest-record contract section ===
parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
```

### Session digest — the published digest-record contract (ADR-0008 amendment)

<!-- [doc->REQ-TERM-4] The session digest is a PROJECTION of the endpoint's
normalized session logs, never a PTY-byte parse (the superseded source mechanism).
It has NO manifest seam — it rides the existing `[history]` strategy (Path A
locate+normalize / fetcher, or Path B native store). What IS published here is the
digest-record CONTRACT: the small, fixed, spt-core-owned shape a projectable
adapter targets in each normalized line (or pushes via `api digest-entry`). The
history subsystem stays opaque — it carries each line verbatim; this contract is
the digest's own overlay on that line's content. One schema, no mapping layer. -->

A **projectable** adapter emits its normalized history lines (or pushes digest
entries) in the digest-record contract — a JSON object per record:

```json
{"role": "input", "text": "add a file"}
{"role": "agent", "text": "on it"}
{"role": "tool",  "tool": {"name": "Write", "arg": "src/a.rs"}}
```

- `role` — the closed set `input` | `agent` | `tool` (the source tag).
- `text` — the input / agent span (omitted/empty for a `tool` record).
- `tool` — `{name, arg}`, present **iff** `role == "tool"`. Consecutive `tool`
  records collapse into one sprint.

**Forward-compat:** unknown object fields are ignored; a line that is not a valid
record (malformed JSON, missing/unknown `role`, a `tool` record without its `tool`
object) is **skipped** by the projection, never fatal.

**Presentation is spt-core's** (the last ~3 user turns, tool-arg truncation to ~25
chars); **extraction is the adapter's** (the record contract). No manifest section
configures the digest — declare `[history]` and emit the contract.

Two access modes over the digest control channel: a **snapshot pull** (`spt
endpoint digest <id>` — projected on demand) and a **structured-delta stream**
(`spt endpoint digest <id> --follow` — changed turns pushed; deltas driven by
pulls / `api digest-entry` pushes). Autonomous file-watch freshness is deferred to
the consuming frontend milestone. Local addressing only.

### `[update]` — adapter self-update
```toml
[update]
avenue = "delegated"               # delegated | file_pull
=== SUBSTITUTION_KEYS location ===
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:36:pub const SUBSTITUTION_KEYS: &[&str] = &[
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:55:    MissingKey { key: String, command: String },
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:69:            RuntimeError::MissingKey { key, command } => {
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:102:        let close = after.find('}').ok_or_else(|| RuntimeError::MissingKey {
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:107:        let value = keys.get(key).ok_or_else(|| RuntimeError::MissingKey {
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:447:            RuntimeError::MissingKey { key, .. } => assert_eq!(key, "session_name"),
C:/Users/decid/Documents/projects/spt-core/crates/spt-runtime/src/runtime.rs:448:            other => panic!("expected MissingKey, got {other}"),


All three claims confirmed precisely:

- **Silent digest-skip** — `record_to_tagged` = `serde_json::from_str(raw).ok()`; zero diagnostic/count. Misconfigured normalizer → silently empty digest.
- **Late validation** — `validate()` (manifest.rs:594-669) checks names/kind/history-fields/env/update, but **not** `[session.*].keys ⊆ SUBSTITUTION_KEYS` nor that `{placeholders}` resolve. Those error at *spawn* (runtime.rs:107 `MissingKey`), not at `spt adapter add`.
- **Contract IS documented** (MANIFEST.md, `[doc->REQ-TERM-4]`) — so the gap isn't docs; it's the missing diagnostic *surface*.

Codebase is genuinely healthy — verdict is READY. Friction is narrow and bites the *first* adapter author hardest (exactly Brandon's question). Building the report.


printf '%s' "$TEMP\architecture-review-"; date +%Y%m%d-%H%M%S; echo "TMP=$TEMP"; echo "TEMP=$TEMP"

Resolve temp dir + timestamp


C:\Users\decid\AppData\Local\Temp\architecture-review-20260613-160608
TMP=C:\Users\decid\AppData\Local\Temp
TEMP=C:\Users\decid\AppData\Local\Temp


Write

<!DOCTYPE html>

<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>spt-core — Architecture Readiness for spt-claude-code</title>

    architecture review·2026-06-13·reviewer: doyle·HEAD 1fc6236 (M9 complete)

Is spt-core ready to build spt-claude-code against?

A deepening-opportunities pass focused on the adapter-facing seams a real Claude Code harness adapter must consume — the manifest contract, the content seams (history / echo-commune / session digest), and the harness-hosted lifecycle.

READY

no hard blockers

      Build it. Claude Code's exact topology — [history] Path A locate_normalize over a per-session JSONL, projected to a digest with no broker and no PTY — is already proven by a passing M9 integration test (harness_hosted_digest_projects_and_pushes_deltas). The seam a CC adapter binds to (api seed → listen → bind, the adapter field on info.json, the published digest-record contract) is wired and tested.

The friction that remains is narrow and late-feedback-shaped — it bites the first real adapter author hardest, which is precisely the situation you're about to enter. Two cheap deepenings below would turn multi-hour reverse-engineering sessions into immediate signals. Neither blocks starting today.

What spt-claude-code actually touches

Three seams. Each is a real seam only when a second adapter exercises it — today every one is exercised by mock/test fixtures only. spt-claude-code is the first real adapter; it will be the thing that converts these from hypothetical to real.

flowchart LR
  CC["Claude Code session
per-session JSONL"]:::ext
  subgraph SEAM1["manifest contract seam"]
    M["manifest.toml
[adapter] [hooks] [session.*]
[history] [identity] [inject]"]:::seamN
  end
  subgraph SEAM2["lifecycle / hosting seam"]
    S["api seed → listen → bind
info.json.adapter field"]:::seamN
  end
  subgraph SEAM3["content seams"]
    H["[history] fetch
opaque HistoryRecord.raw"]:::seamN
    E["echo-commune
raw → cheap-model summary"]:::seamN
    D["digest projection
parse {role,text,tool}"]:::seamN
  end
  CC --> M
  CC --> S
  M --> H
  S --> H
  H --> E
  H --> D
  D --> OUT["spt endpoint digest <id>"]:::out
  E --> COM["<id>-commune.md"]:::out
  classDef ext fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe;
  classDef seamN fill:#1e293b,stroke:#475569,color:#e2e8f0;
  classDef out fill:#14532d,stroke:#22c55e,color:#dcfce7;

The pivotal fact: a single [history] source feeds two consumers with different expectations — echo-commune wants the line opaque (pipes it verbatim), the digest wants it to parse as the {role,text,tool} contract. That overlap is where the sharp edges live.

What's already deep (don't touch)

Opaque command templates

Every [session.<role>].command is a black box — spt-core never parses model/flags/tools. Genuine depth: the adapter evolves its whole invocation behind a tiny substitution interface.

Single validation gate

Manifest::from_toml_str → validate() is one seam, called at registry-register. Cross-field invariants concentrate in one place. Good locality.

Digest-record contract

M9 published a small, fixed, well-documented overlay ([doc->REQ-TERM-4]) while keeping HistoryRecord opaque. Topology-independent, no manifest seam. Clean.

Deepening opportunities

Ranked. Two are cheap and worth doing before the CC build because they target the first-adapter-author experience directly. None is a blocker.

candidate 01 · manifest contract seam

Validate the adapter contract at register time, not spawn time

      Strong

Files

crates/spt-runtime/src/manifest.rs:594 (validate)
crates/spt-runtime/src/runtime.rs:36 SUBSTITUTION_KEYS
crates/spt-runtime/src/runtime.rs:107 MissingKey

Problem

validate() checks names, kind↔[shell], history-strategy fields, env, update — but not that a [session.<role>].keys entry is one of the twelve SUBSTITUTION_KEYS, nor that every {placeholder} in a command template resolves. A typo'd key or an unknown placeholder sails through spt adapter add and only blows up as a RuntimeError::MissingKey at the first spawn — possibly long after registration, on someone else's machine.

Solution

Pull the key/placeholder check behind the existing validate() interface: for each [session.*], assert declared keys ⊆ SUBSTITUTION_KEYS and that every {…} in command/cwd names a declared key. Same seam, more leverage.

Benefits

Locality: the contract a template must satisfy is checked in the one place the contract already lives. Leverage: a first-adapter author gets a precise error at add time instead of a runtime surprise. Tests: the interface (validate) becomes the test surface — table-test bad-key / bad-placeholder manifests directly, no spawn harness needed.

Deletion test

Remove the check and the complexity reappears as N runtime spawn-failures across every adapter and every author. It concentrates complexity — it earns its keep.

before — feedback at spawn

spt adapter add claude-spt  ✓ ok

… hours / a deploy later …

claude session starts →

RuntimeError::MissingKey { key:"model" }

spawn aborted. where? which template?

deepen ↓

after — feedback at register

spt adapter add claude-spt →

[session.self]: key "model" is not a
 substitution key (have: id, session_id,
 parent_pid, …). placeholder {model}
 will never resolve.

caught before anything is recorded.

candidate 02 · session-digest content seam · CC-specific

Give the digest projection a diagnostic surface for contract-mismatch

      Strong

Files

crates/spt-term/src/projection.rs:102 record_to_tagged
crates/spt-term/src/projection.rs:112 project
crates/spt-daemon/src/digest.rs:80 project_endpoint_digest

Problem

record_to_tagged is literally serde_json::from_str(raw).ok() — every line that isn't valid contract JSON is silently dropped, no count, no sample, no log. For Claude Code — whose entire value-add is projecting its JSONL into a digest — a one-character normalizer bug yields an empty digest with zero signal. The seam is deep on the happy path but exposes no interface for its own degradation. The author's only symptom is "spt endpoint digest returns nothing," then a spelunk through the contract spec.

Solution

Make the projection report what it discarded: count skipped lines, keep a first-mismatch sample, and surface it — a tracing::warn on a non-zero skip ratio, a skipped count in the snapshot reply, and/or a spt endpoint digest <id> --explain. The history line stays opaque; this reports only the digest's own parse outcome (no ADR-0008 opacity conflict).

Benefits

Leverage: "why is my digest empty?" (hours) becomes "47/50 lines skipped: expected role∈{input,agent,tool}, got "user"" (seconds). Locality: the diagnostic lives at the parse boundary, the one place that knows a line was rejected. Tests: the skip-count becomes an assertable output — the projection's degradation path gets a test surface it currently lacks.

Deletion test

This isn't a pass-through being questioned — it's adding a missing facet to a deep module's interface. The justification is leverage at the seam: a deep module that can't report its own failure mode forces every consumer to debug blind.

before — silent drop

          <svg viewBox="0 0 320 130" class="w-full">
            <text x="6" y="14" fill="#64748b" font-size="9" font-family="monospace">normalizer emits {"user":"hi"} ✗contract</text>
            <rect x="6" y="22" width="120" height="22" rx="3" fill="#1e293b" stroke="#475569"/>
            <text x="12" y="37" fill="#94a3b8" font-size="9" font-family="monospace">record_to_tagged()</text>
            <text x="150" y="37" fill="#f43f5e" font-size="11" font-family="monospace">→ None</text>
            <line x1="200" y1="33" x2="250" y2="33" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="3 2"/>
            <text x="256" y="30" fill="#64748b" font-size="9">/dev/</text>
            <text x="256" y="40" fill="#64748b" font-size="9">null</text>
            <rect x="6" y="60" width="308" height="40" rx="4" fill="#0f172a" stroke="#7f1d1d"/>
            <text x="14" y="78" fill="#94a3b8" font-size="10" font-family="monospace">$ spt endpoint digest cc</text>
            <text x="14" y="93" fill="#64748b" font-size="10" font-family="monospace">(empty) — no error, no clue</text>
            <text x="6" y="120" fill="#475569" font-size="9">author bisects the spec by hand →</text>
          </svg>

deepen ↓

after — reported degradation

          <svg viewBox="0 0 320 130" class="w-full">
            <text x="6" y="14" fill="#64748b" font-size="9" font-family="monospace">normalizer emits {"user":"hi"} ✗contract</text>
            <rect x="6" y="22" width="120" height="22" rx="3" fill="#1e293b" stroke="#475569"/>
            <text x="12" y="37" fill="#94a3b8" font-size="9" font-family="monospace">record_to_tagged()</text>
            <text x="150" y="37" fill="#f59e0b" font-size="11" font-family="monospace">→ skip+count</text>
            <rect x="6" y="56" width="308" height="46" rx="4" fill="#0f172a" stroke="#166534"/>
            <text x="14" y="73" fill="#94a3b8" font-size="10" font-family="monospace">$ spt endpoint digest cc --explain</text>
            <text x="14" y="88" fill="#fbbf24" font-size="9" font-family="monospace">47/50 skipped · want role∈{input,</text>
            <text x="14" y="98" fill="#fbbf24" font-size="9" font-family="monospace">agent,tool}, got key "user"</text>
            <text x="6" y="120" fill="#475569" font-size="9">author fixes the normalizer in seconds →</text>
          </svg>

candidate 03 · content seams · coverage

Prove the dual-consumer seam end-to-end before CC depends on it

      Worth exploring

Files

crates/spt-live/src/echo.rs (unit only)
crates/spt/tests/contract_e2e.rs (echo role wired, never driven)
crates/spt-daemon/tests/digest.rs (digest path proven)

Problem

The M9 E2E proves one [history] source → digest. Echo-commune is unit-tested in isolation and its role is wired in contract_e2e.rs but never actually driven. Nothing proves a single normalizer's output feeding both echo (opaque pass-through) and digest (contract parse) from the same source — which is exactly what a CC adapter will do on day one. The opaque↔contract tension (Candidate 02's silent-skip) lives precisely in this untested overlap.

Solution

Add one Path-A fixture that locates a JSONL, normalizes once, and asserts both a non-empty echo summary and a correct digest projection from that single source. The mock adapter only exercises Path B (native push) today; this closes the gap CC will be first to hit.

Benefits

Leverage: converts the dual-consumer seam from hypothetical (one consumer ever tested at a time) to real (both, one source). Locality: the test pins the contract that a CC normalizer must satisfy, so a regression surfaces here, not in the downstream repo.

coverage map — one source, two consumers

[history] source

→ digestproven (M9)

→ echo-communeunit only

→ both, 1 srcuntested ← CC's day-one path

candidate 04 · lifecycle seam · constraint, not a fix

Decide the Psyche-hosting assumption: M2b interim vs M3b daemon

      Speculative

Problem

For a live-agent CC adapter, the Psyche companion + echo-commune lifecycle runs in the M2b interim (relay-process-hosted, single-machine); robust M3b daemon-hosting is milestone-staged. This isn't a defect to fix before CC — it's a constraint to decide against: does the first spt-claude-code target the interim path (local-only Psyche, ship sooner) or wait on M3b?

Why it's listed, not recommended

Nothing here is shallow or broken. Surfacing it so the CC design makes the call consciously rather than discovering the boundary mid-build. No code change in spt-core implied.

also deferred (by design, M9 ruling)

· autonomous file-watch digest freshness → consuming-frontend milestone

· Option-C digest persistence re-home → same

· adapter profiles → ratified, first consumer is CC itself (opt-in)

CC can push via api digest-entry / pull on demand meanwhile.

top recommendation

Do Candidate 02 (digest diagnostic) first — ideally alongside the CC build, not before it.

      You are not blocked. The right move is to start spt-claude-code now and let it drive these fixes — it's the first real adapter, so it's the only thing that can tell you which sharp edges actually cut.
      Of the four, Candidate 02 is the highest-leverage because it targets the exact failure you'll hit first: a normalizer that doesn't quite emit the contract, producing an empty digest with no signal. It's CC-specific (the digest is CC's headline feature), it's small (a count + a sample + a warn), and it has zero ADR risk (it reports the digest's own parse result, leaving HistoryRecord opaque).

      Candidate 01 (register-time key validation) is the natural companion — same spirit (move feedback earlier), trivial, and it makes writing the very first manifest forgiving. Candidate 03 (dual-consumer E2E) is best written as you build the CC normalizer, since the CC fixture is the realest test data you'll have. Candidate 04 is a design decision to make on day one, not a code task.

      01 + 02 before/with build
      03 during build
      04 decide day one

Bottom line: the codebase is in a good place. M9 landed clean and the CC topology is already green in CI. The deepenings are first-adapter-author ergonomics, not structural debt — none of them gates starting.

  Vocabulary: module · interface · seam · depth · deep/shallow · leverage · locality · deletion test (architecture) · adapter · harness-hosted · [history] · digest-record contract · echo-commune · perch (CONTEXT.md domain). Generated by /improve-codebase-architecture.


File created successfully at: C:\Users\decid\AppData\Local\Temp\architecture-review-20260613-160608.html (file state is current in your context — no need to Read it back)

<!DOCTYPE html>

<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>spt-core — Architecture Readiness for spt-claude-code</title>

    architecture review·2026-06-13·reviewer: doyle·HEAD 1fc6236 (M9 complete)

Is spt-core ready to build spt-claude-code against?

A deepening-opportunities pass focused on the adapter-facing seams a real Claude Code harness adapter must consume — the manifest contract, the content seams (history / echo-commune / session digest), and the harness-hosted lifecycle.

READY

no hard blockers

      Build it. Claude Code's exact topology — [history] Path A locate_normalize over a per-session JSONL, projected to a digest with no broker and no PTY — is already proven by a passing M9 integration test (harness_hosted_digest_projects_and_pushes_deltas). The seam a CC adapter binds to (api seed → listen → bind, the adapter field on info.json, the published digest-record contract) is wired and tested.

The friction that remains is narrow and late-feedback-shaped — it bites the first real adapter author hardest, which is precisely the situation you're about to enter. Two cheap deepenings below would turn multi-hour reverse-engineering sessions into immediate signals. Neither blocks starting today.

What spt-claude-code actually touches

Three seams. Each is a real seam only when a second adapter exercises it — today every one is exercised by mock/test fixtures only. spt-claude-code is the first real adapter; it will be the thing that converts these from hypothetical to real.

flowchart LR
  CC["Claude Code session
per-session JSONL"]:::ext
  subgraph SEAM1["manifest contract seam"]
    M["manifest.toml
[adapter] [hooks] [session.*]
[history] [identity] [inject]"]:::seamN
  end
  subgraph SEAM2["lifecycle / hosting seam"]
    S["api seed → listen → bind
info.json.adapter field"]:::seamN
  end
  subgraph SEAM3["content seams"]
    H["[history] fetch
opaque HistoryRecord.raw"]:::seamN
    E["echo-commune
raw → cheap-model summary"]:::seamN
    D["digest projection
parse {role,text,tool}"]:::seamN
  end
  CC --> M
  CC --> S
  M --> H
  S --> H
  H --> E
  H --> D
  D --> OUT["spt endpoint digest <id>"]:::out
  E --> COM["<id>-commune.md"]:::out
  classDef ext fill:#0c4a6e,stroke:#38bdf8,color:#e0f2fe;
  classDef seamN fill:#1e293b,stroke:#475569,color:#e2e8f0;
  classDef out fill:#14532d,stroke:#22c55e,color:#dcfce7;

The pivotal fact: a single [history] source feeds two consumers with different expectations — echo-commune wants the line opaque (pipes it verbatim), the digest wants it to parse as the {role,text,tool} contract. That overlap is where the sharp edges live.

What's already deep (don't touch)

Opaque command templates

Every [session.<role>].command is a black box — spt-core never parses model/flags/tools. Genuine depth: the adapter evolves its whole invocation behind a tiny substitution interface.

Single validation gate

Manifest::from_toml_str → validate() is one seam, called at registry-register. Cross-field invariants concentrate in one place. Good locality.

Digest-record contract

M9 published a small, fixed, well-documented overlay ([doc->REQ-TERM-4]) while keeping HistoryRecord opaque. Topology-independent, no manifest seam. Clean.

Deepening opportunities

Ranked. Two are cheap and worth doing before the CC build because they target the first-adapter-author experience directly. None is a blocker.

candidate 01 · manifest contract seam

Validate the adapter contract at register time, not spawn time

      Strong

Files

crates/spt-runtime/src/manifest.rs:594 (validate)
crates/spt-runtime/src/runtime.rs:36 SUBSTITUTION_KEYS
crates/spt-runtime/src/runtime.rs:107 MissingKey

Problem

validate() checks names, kind↔[shell], history-strategy fields, env, update — but not that a [session.<role>].keys entry is one of the twelve SUBSTITUTION_KEYS, nor that every {placeholder} in a command template resolves. A typo'd key or an unknown placeholder sails through spt adapter add and only blows up as a RuntimeError::MissingKey at the first spawn — possibly long after registration, on someone else's machine.

Solution

Pull the key/placeholder check behind the existing validate() interface: for each [session.*], assert declared keys ⊆ SUBSTITUTION_KEYS and that every {…} in command/cwd names a declared key. Same seam, more leverage.

Benefits

Locality: the contract a template must satisfy is checked in the one place the contract already lives. Leverage: a first-adapter author gets a precise error at add time instead of a runtime surprise. Tests: the interface (validate) becomes the test surface — table-test bad-key / bad-placeholder manifests directly, no spawn harness needed.

Deletion test

Remove the check and the complexity reappears as N runtime spawn-failures across every adapter and every author. It concentrates complexity — it earns its keep.

before — feedback at spawn

spt adapter add claude-spt  ✓ ok

… hours / a deploy later …

claude session starts →

RuntimeError::MissingKey { key:"model" }

spawn aborted. where? which template?

deepen ↓

after — feedback at register

spt adapter add claude-spt →

[session.self]: key "model" is not a
 substitution key (have: id, session_id,
 parent_pid, …). placeholder {model}
 will never resolve.

caught before anything is recorded.

candidate 02 · session-digest content seam · CC-specific

Give the digest projection a diagnostic surface for contract-mismatch

      Strong

Files

crates/spt-term/src/projection.rs:102 record_to_tagged
crates/spt-term/src/projection.rs:112 project
crates/spt-daemon/src/digest.rs:80 project_endpoint_digest

Problem

record_to_tagged is literally serde_json::from_str(raw).ok() — every line that isn't valid contract JSON is silently dropped, no count, no sample, no log. For Claude Code — whose entire value-add is projecting its JSONL into a digest — a one-character normalizer bug yields an empty digest with zero signal. The seam is deep on the happy path but exposes no interface for its own degradation. The author's only symptom is "spt endpoint digest returns nothing," then a spelunk through the contract spec.

Solution

Make the projection report what it discarded: count skipped lines, keep a first-mismatch sample, and surface it — a tracing::warn on a non-zero skip ratio, a skipped count in the snapshot reply, and/or a spt endpoint digest <id> --explain. The history line stays opaque; this reports only the digest's own parse outcome (no ADR-0008 opacity conflict).

Benefits

Leverage: "why is my digest empty?" (hours) becomes "47/50 lines skipped: expected role∈{input,agent,tool}, got "user"" (seconds). Locality: the diagnostic lives at the parse boundary, the one place that knows a line was rejected. Tests: the skip-count becomes an assertable output — the projection's degradation path gets a test surface it currently lacks.

Deletion test

This isn't a pass-through being questioned — it's adding a missing facet to a deep module's interface. The justification is leverage at the seam: a deep module that can't report its own failure mode forces every consumer to debug blind.

before — silent drop

          <svg viewBox="0 0 320 130" class="w-full">
            <text x="6" y="14" fill="#64748b" font-size="9" font-family="monospace">normalizer emits {"user":"hi"} ✗contract</text>
            <rect x="6" y="22" width="120" height="22" rx="3" fill="#1e293b" stroke="#475569"/>
            <text x="12" y="37" fill="#94a3b8" font-size="9" font-family="monospace">record_to_tagged()</text>
            <text x="150" y="37" fill="#f43f5e" font-size="11" font-family="monospace">→ None</text>
            <line x1="200" y1="33" x2="250" y2="33" stroke="#f43f5e" stroke-width="1.5" stroke-dasharray="3 2"/>
            <text x="256" y="30" fill="#64748b" font-size="9">/dev/</text>
            <text x="256" y="40" fill="#64748b" font-size="9">null</text>
            <rect x="6" y="60" width="308" height="40" rx="4" fill="#0f172a" stroke="#7f1d1d"/>
            <text x="14" y="78" fill="#94a3b8" font-size="10" font-family="monospace">$ spt endpoint digest cc</text>
            <text x="14" y="93" fill="#64748b" font-size="10" font-family="monospace">(empty) — no error, no clue</text>
            <text x="6" y="120" fill="#475569" font-size="9">author bisects the spec by hand →</text>
          </svg>

deepen ↓

after — reported degradation

          <svg viewBox="0 0 320 130" class="w-full">
            <text x="6" y="14" fill="#64748b" font-size="9" font-family="monospace">normalizer emits {"user":"hi"} ✗contract</text>
            <rect x="6" y="22" width="120" height="22" rx="3" fill="#1e293b" stroke="#475569"/>
            <text x="12" y="37" fill="#94a3b8" font-size="9" font-family="monospace">record_to_tagged()</text>
            <text x="150" y="37" fill="#f59e0b" font-size="11" font-family="monospace">→ skip+count</text>
            <rect x="6" y="56" width="308" height="46" rx="4" fill="#0f172a" stroke="#166534"/>
            <text x="14" y="73" fill="#94a3b8" font-size="10" font-family="monospace">$ spt endpoint digest cc --explain</text>
            <text x="14" y="88" fill="#fbbf24" font-size="9" font-family="monospace">47/50 skipped · want role∈{input,</text>
            <text x="14" y="98" fill="#fbbf24" font-size="9" font-family="monospace">agent,tool}, got key "user"</text>
            <text x="6" y="120" fill="#475569" font-size="9">author fixes the normalizer in seconds →</text>
          </svg>

candidate 03 · content seams · coverage

Prove the dual-consumer seam end-to-end before CC depends on it

      Worth exploring

Files

crates/spt-live/src/echo.rs (unit only)
crates/spt/tests/contract_e2e.rs (echo role wired, never driven)
crates/spt-daemon/tests/digest.rs (digest path proven)

Problem

The M9 E2E proves one [history] source → digest. Echo-commune is unit-tested in isolation and its role is wired in contract_e2e.rs but never actually driven. Nothing proves a single normalizer's output feeding both echo (opaque pass-through) and digest (contract parse) from the same source — which is exactly what a CC adapter will do on day one. The opaque↔contract tension (Candidate 02's silent-skip) lives precisely in this untested overlap.

Solution

Add one Path-A fixture that locates a JSONL, normalizes once, and asserts both a non-empty echo summary and a correct digest projection from that single source. The mock adapter only exercises Path B (native push) today; this closes the gap CC will be first to hit.

Benefits

Leverage: converts the dual-consumer seam from hypothetical (one consumer ever tested at a time) to real (both, one source). Locality: the test pins the contract that a CC normalizer must satisfy, so a regression surfaces here, not in the downstream repo.

coverage map — one source, two consumers

[history] source

→ digestproven (M9)

→ echo-communeunit only

→ both, 1 srcuntested ← CC's day-one path

candidate 04 · lifecycle seam · constraint, not a fix

Decide the Psyche-hosting assumption: M2b interim vs M3b daemon

      Speculative

Problem

For a live-agent CC adapter, the Psyche companion + echo-commune lifecycle runs in the M2b interim (relay-process-hosted, single-machine); robust M3b daemon-hosting is milestone-staged. This isn't a defect to fix before CC — it's a constraint to decide against: does the first spt-claude-code target the interim path (local-only Psyche, ship sooner) or wait on M3b?

Why it's listed, not recommended

Nothing here is shallow or broken. Surfacing it so the CC design makes the call consciously rather than discovering the boundary mid-build. No code change in spt-core implied.

also deferred (by design, M9 ruling)

· autonomous file-watch digest freshness → consuming-frontend milestone

· Option-C digest persistence re-home → same

· adapter profiles → ratified, first consumer is CC itself (opt-in)

CC can push via api digest-entry / pull on demand meanwhile.

top recommendation

Do Candidate 02 (digest diagnostic) first — ideally alongside the CC build, not before it.

      You are not blocked. The right move is to start spt-claude-code now and let it drive these fixes — it's the first real adapter, so it's the only thing that can tell you which sharp edges actually cut.
      Of the four, Candidate 02 is the highest-leverage because it targets the exact failure you'll hit first: a normalizer that doesn't quite emit the contract, producing an empty digest with no signal. It's CC-specific (the digest is CC's headline feature), it's small (a count + a sample + a warn), and it has zero ADR risk (it reports the digest's own parse result, leaving HistoryRecord opaque).

      Candidate 01 (register-time key validation) is the natural companion — same spirit (move feedback earlier), trivial, and it makes writing the very first manifest forgiving. Candidate 03 (dual-consumer E2E) is best written as you build the CC normalizer, since the CC fixture is the realest test data you'll have. Candidate 04 is a design decision to make on day one, not a code task.

      01 + 02 before/with build
      03 during build
      04 decide day one

Bottom line: the codebase is in a good place. M9 landed clean and the CC topology is already green in CI. The deepenings are first-adapter-author ergonomics, not structural debt — none of them gates starting.

  Vocabulary: module · interface · seam · depth · deep/shallow · leverage · locality · deletion test (architecture) · adapter · harness-hosted · [history] · digest-record contract · echo-commune · perch (CONTEXT.md domain). Generated by /improve-codebase-architecture.


Bash

start "" "C:\Users\decid\AppData\Local\Temp\architecture-review-20260613-160608.html" 2>&1; echo opened

Open report in browser


opened


Report open. Path: `C:\Users\decid\AppData\Local\Temp\architecture-review-20260613-160608.html`

**Verdict: READY. Build spt-claude-code now — no blockers.** CC's exact topology (Path A `locate_normalize` over per-session JSONL → digest, no broker/PTY) already green in CI via M9 E2E. Verified the subagent claims myself before writing.

Friction = narrow, first-adapter-author-shaped (late feedback, not structural debt). Four candidates:

| # | Seam | Friction | Strength |
|---|------|----------|----------|
| **01** | manifest contract | `validate()` doesn't check `keys ⊆ SUBSTITUTION_KEYS` / placeholder resolve → errors at **spawn**, not `adapter add` | Strong |
| **02** | session-digest | `record_to_tagged` = `from_str().ok()` → bad normalizer = **silently empty digest, zero signal** | Strong (CC-specific) |
| **03** | content seams | one `[history]` source → echo + digest dual-consumer **never E2E-proven together** (CC's day-one path) | Worth exploring |
| **04** | lifecycle | Psyche M2b interim (local-only) vs M3b daemon — a **decision**, not a fix | Speculative |

**Top rec:** Candidate 02 first (or alongside the build) — targets the exact first failure (empty digest, no clue), small, zero ADR risk. 01 is the trivial companion. Don't gate the build on any of them; let CC drive them.

Which would you like to explore? (Or "none — just wanted the readout.")


looking at the v0.5.0 release notes, **this was never discussed with or approved by me**: [Image #1] -- the adapter manifest still needs a manifest section for digests!! users have no way to guarantee that the session logs of their harness follow SPT