[package]
name = "spt-msg"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core local messaging: TCP wire framing, loopback listener, TCP-first/spool-fallback delivery, ready-agent lifecycle, ring. Built on spt-proto + spt-store."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_Foundation"] }

[dev-dependencies]
tempfile = "3"
===PROTO===
[package]
name = "spt-proto"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core wire protocol: envelope grammar + HTML-entity codec + two-slice parser. Public SDK (R-ARCH-2)."

[dependencies]
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }


   Compiling spt-store v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-store)
   Compiling spt-msg v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-msg)
   Compiling spt v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.93s


Baseline green. Start T0 — scaffold `spt-runtime`. Check commit trailer first.


Bash

Check co-author trailer format


M2 is large and described daemon-centric, but the daemon is M3. Per the
2026-06-01 scoping decisions (interim no-daemon · harness-hosted only ·
split), M2 splits into:
- M2a — harness contract: spt-runtime (manifest schema + AgentRuntime/
  ManifestRuntime) + the `api` subcommand surface + harness-hosted startup
  (seed->listen->bind, interim seed-file) + a generic mock adapter +
  contract E2E. Zero Claude Code conventions in-repo.
- M2b — live-agent lifecycle (spt-live): Psyche spawn, pulse, commune/
  signoff ingest, echo-commune, history. Own JIT plan when M2a lands.

M2a-PLAN.md: 11 tasks (T0-T10), source/req/acceptance per task, activation
map (REQ-MANIFEST-1, REQ-API-1/2/3, REQ-START-1/2/4, REQ-SEAM-SPAWN/
POSTSPAWN/ACTIVITY/INJECT/CAPABILITY + LOCAL-API-AUTH/CASCADE-WIPE-GUARD/
SUBPROCESS-TIMEOUT hazards + REQ-ARCH-2), interim-no-daemon framing, and a
clean-room posture validated against the sister (lifecycle is extremely
CC-coupled -> clean-room behind the manifest; CC specifics become the mock
adapter's opaque templates).

ROADMAP M2 line: split into M2a/M2b bullets; spt-hosted topology + the
consolidated spt-daemon explicitly moved to M3 (narrows the earlier "both
topologies in M2" wording). No req-stage changes; traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Trailer confirmed. Scaffold crate (T0).


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


TaskCreate

T0: Scaffold spt-runtime crate

New crate (members + deps spt-proto/store/msg, serde, toml); activation-prep. Compiles; traceable-reqs check green.


Task #1 created successfully: T0: Scaffold spt-runtime crate

T0: Scaffold spt-runtime crate


T1: Manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section + parse + validate (helpful errors). REQ-MANIFEST-1.


Task #2 created successfully: T1: Manifest schema + parse + validate

T1: Manifest schema + parse + validate


T2: AgentRuntime trait + ManifestRuntime

Substitution-key catalog + opaque-template fill + spawn-session seam (timeout). REQ-SEAM-SPAWN, REQ-HAZARD-SUBPROCESS-TIMEOUT.


Task #3 created successfully: T2: AgentRuntime trait + ManifestRuntime


T3: spt api dispatch group + local-api-auth

--manifest/--adapter resolution, adapter_name on every call, exit codes, local-api-auth guard. REQ-API-1, REQ-HAZARD-LOCAL-API-AUTH.


Task #4 created successfully: T3: spt api dispatch group + local-api-auth


T4: Harness-hosted startup (seed/listen/bind)

api seed (interim seed-file by parent_pid) + api listen (consume seed, validate session_id, bind skeleton->live, relay loop) + api bind. REQ-START-1/2, REQ-SEAM-POSTSPAWN, REQ-API-2, REQ-HAZARD-WINDOWS-PID-RECYCLE.


Task #5 created successfully: T4: Harness-hosted startup (seed/listen/bind)


T5: Delivery + activity (state/poll/inject)

api state busy|idle (+echo-gate sentinel, --no-gate, echo-gate set/clear) + api poll [--include-deferred] + inject-method selection. REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2.


Task #6 created successfully: T5: Delivery + activity (state/poll/inject)


T6: Worker perches + cascade-wipe guard

api worker-start/worker-stop/worker-poll + cascade-wipe guard. REQ-API-2, REQ-HAZARD-CASCADE-WIPE-GUARD.


Task #7 created successfully: T6: Worker perches + cascade-wipe guard

T6: Worker perches + cascade-wipe guard


T7: Reporting + boundary surface

api boundary clear|compact + session-end (--erase) + presence + history-log + emit stub + capability hostable_types + env-alias supply. REQ-API-2/3, REQ-START-4, REQ-SEAM-CAPABILITY, REQ-HAZARD-SOFT-CLEANUP.


Task #8 created successfully: T7: Reporting + boundary surface

T7: Reporting + boundary surface


T8: Generic mock adapter

adapters/mock/manifest.toml + cross-platform session scripts/helper bin; zero CC conventions. REQ-MANIFEST-1, REQ-API-3.


Task #9 created successfully: T8: Generic mock adapter

T8: Generic mock adapter


T9: Contract E2E test

load mock manifest -> ManifestRuntime spawns session -> api bind -> api state idle -> api poll delivers spooled message. REQ-API-1/2 int, REQ-START-2 int.


Task #10 created successfully: T9: Contract E2E test

T9: Contract E2E test


T10: Activation sweep

Activate M2a reqs + hazards + REQ-ARCH-2, green check, CI green, amend ROADMAP/CONTEXT for split.


Task #11 created successfully: T10: Activation sweep

T10: Activation sweep


Updated task #1 status


crates\spt-store\Cargo.toml:10:serde = { version = "1", features = ["derive"] }
crates\spt-store\Cargo.toml:11:serde_json = { version = "1", features = ["preserve_order"] }


serde 1 in use, toml new. Scaffold crate.


Write

[package]
name = "spt-runtime"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime\Cargo.toml (file state is current in your context — no need to Read it back)

[package]
name = "spt-runtime"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core harness contract: runtime-manifest schema + AgentRuntime/ManifestRuntime (opaque-template substitution + spawn-session seam). The outbound half of the harness-agnostic contract (M2a). Public SDK (R-ARCH-2)."

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

[dev-dependencies]
tempfile = "3"


//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.

// Scaffold (T0): subsequent tasks populate these modules.


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime\src\lib.rs (file state is current in your context — no need to Read it back)

//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.

// Scaffold (T0): subsequent tasks populate these modules.


Edit

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]

members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]


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

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


1	# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
2	#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
3	# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
4	# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
5	[workspace]
6	resolver = "2"
7	members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]
8

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]


# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]

# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]


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

# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]

+# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.

-members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt"]

+members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-runtime", "crates/spt"]


Bash

cargo build -p spt-runtime 2>&1 | tail -8 && echo "---CHECK---" && traceable-reqs check 2>&1 | tail -3

Build new crate + reqs check


   Compiling toml_write v0.1.2
   Compiling winnow v0.7.15
   Compiling toml_datetime v0.6.11
   Compiling serde_spanned v0.6.9
   Compiling toml_edit v0.22.27
   Compiling toml v0.8.23
   Compiling spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.46s
---CHECK---
       Update gated on user confirmation by default; opt-in full-auto
  [OK] REQ-UPD-5  required: []  stages: -doc -impl -unit -int
       spt-core ripple-updates registered adapters


T0 compiles, check green. Commit T0.


traceable-reqs check >$null 2>&1; echo "exit=$LASTEXITCODE"

Confirm check exit code


/usr/bin/bash: line 4: $null: ambiguous redirect
exit=


T0: Scaffold spt-runtime crate

New crate (members + deps spt-proto/store/msg, serde, toml); activation-prep. Compiles; traceable-reqs check green.

T1: Manifest schema + parse + validate

Typed structs for every docs/MANIFEST.md section + parse + validate (helpful errors). REQ-MANIFEST-1.

Substitution-key catalog + opaque-template fill + spawn-session seam (timeout). REQ-SEAM-SPAWN, REQ-HAZARD-SUBPROCESS-TIMEOUT.

--manifest/--adapter resolution, adapter_name on every call, exit codes, local-api-auth guard. REQ-API-1, REQ-HAZARD-LOCAL-API-AUTH.

api seed (interim seed-file by parent_pid) + api listen (consume seed, validate session_id, bind skeleton->live, relay loop) + api bind. REQ-START-1/2, REQ-SEAM-POSTSPAWN, REQ-API-2, REQ-HAZARD-WINDOWS-PID-RECYCLE.

api state busy|idle (+echo-gate sentinel, --no-gate, echo-gate set/clear) + api poll [--include-deferred] + inject-method selection. REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2.

T6: Worker perches + cascade-wipe guard

api worker-start/worker-stop/worker-poll + cascade-wipe guard. REQ-API-2, REQ-HAZARD-CASCADE-WIPE-GUARD.

T7: Reporting + boundary surface

api boundary clear|compact + session-end (--erase) + presence + history-log + emit stub + capability hostable_types + env-alias supply. REQ-API-2/3, REQ-START-4, REQ-SEAM-CAPABILITY, REQ-HAZARD-SOFT-CLEANUP.

T8: Generic mock adapter

adapters/mock/manifest.toml + cross-platform session scripts/helper bin; zero CC conventions. REQ-MANIFEST-1, REQ-API-3.

T9: Contract E2E test

load mock manifest -> ManifestRuntime spawns session -> api bind -> api state idle -> api poll delivers spooled message. REQ-API-1/2 int, REQ-START-2 int.

T10: Activation sweep

Activate M2a reqs + hazards + REQ-ARCH-2, green check, CI green, amend ROADMAP/CONTEXT for split.


traceable-reqs check >/dev/null 2>&1; echo "exit=$?"

Confirm check exit code


git add -A && git commit -q -F - <<'EOF'
feat(spt-runtime): M2a T0 — scaffold harness-contract crate

Fourth layer in the acyclic stack (spt-proto -> spt-store -> spt-msg ->
spt-runtime). Empty module surface; T1 adds the manifest schema, T2 the
AgentRuntime/ManifestRuntime seam. Deps: spt-proto/store/msg + serde + toml.

Workspace members + header updated. Reqs stay inactive (activation-prep);
traceable-reqs check green; cargo build -p spt-runtime green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
git log --oneline -1

Commit T0


warning: in the working copy of 'Cargo.lock', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/Cargo.toml', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'crates/spt-runtime/src/lib.rs', LF will be replaced by CRLF the next time Git touches it
6741453 feat(spt-runtime): M2a T0 — scaffold harness-contract crate


T0 done. Now T1 — manifest schema. Read MANIFEST.md (the contract).


Updated task #2 status


1	# Runtime manifest schema
2
3	> Draft design doc (2026-05-31). Resolves PRD §17.2. Implementation detail — see [`../CONTEXT.md`](../CONTEXT.md) for the model. Schema grounded in the sister project's actual functional surface.
4
5	## Principle
6
7	**SPT is not a harness.** The manifest declares only what *varies per harness*. Command templates are **opaque strings** — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core just runs it with substitution keys filled. Anything spt-core owns (sentinels, spool/registry/perch schemas, the event-block vocabulary, the version-trampoline mechanism, config knobs) is **not** in the manifest.
8
9	## What is NOT in the manifest (spt-core-owned)
10
11	- **Sentinels** (`.idle`-equiv, echo-gate) — managed by `api state`/`api echo-gate`; the adapter only *calls* them.
12	- **Spool / registry / perch `info.json` / daemon-state schemas** — core.
13	- **Event-block vocabulary** — the XML tags spt-core surfaces to agents (e.g. `<SPT_EVENT>`, `<owl_messages>`-equivalent) are a **fixed, documented constant**, not configurable. Adapters pass spt-core's output through unchanged.
14	- **File-drop filenames** — statically `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md`. Only the *watched directory* is declared (in `[session]`).
15	- **Config knobs** (pulse period, echo-commune window, route-guard window, daily-refresh) — spt-core **global settings**, with optional **per-endpoint override** for endpoints with unique needs. Not per-adapter.
16	- **Cross-adapter fallback** (psyche/echo invocation falls back to another adapter on rate-limit) — a **node-wide setting**, not a manifest field, because it references *multiple* adapters (under spt-core, `ccs` is its own adapter, not a binary-swap within one). Adapter-agnostic IDs make this safe — an endpoint's Psyche can run under a fallback adapter temporarily.
17	- **Plugin-cache / version-trampoline layout** — none of SPT's concern. A CC-plugin adapter updates via `claude plugin update` (delegated command).
18
19	## Manifest sections
20
21	### `[adapter]` — header
22	```toml
23	[adapter]
24	name = "claude-spt"
25	version = "2.0.0"
26	min_spt_core_version = "1.0.0"     # readable before any update; compat gate
27	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
28	```
29
30	### `[hooks.<event>]` — inbound hook table
31	Per harness event: the `api` command it fires, the stdin fields it maps in, and its **output capability** — `can_inject` (whether this hook can surface context to the agent). When `can_inject = false`, spt-core falls back to a sentinel + relay/poll path instead of expecting injection. This is the load-bearing harness-varying fact (CC's Stop hook can't inject).
32
33	```toml
34	[hooks.SessionStart]
35	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
36	reads = ["session_id", "source", "agent_type"]
37	can_inject = true                  # via additionalContext
38
39	[hooks.Stop]
40	fires = "api state idle"
41	reads = []
42	can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
43	```
44
45	### `[session.<role>]` — outbound session role templates
46	One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.
47
48	```toml
49	[session.psyche_init]
50	command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
51	cwd = "{psyche_dir}"
52	env_remove = ["OWL_SESSION_ID"]
53	recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
54	detach = true
55	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
56	```
57	(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)
58
59	### `[env]` — env-var table
60	Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.
61
62	```toml
63	[env.OWL_SESSION_ID]
64	direction = "inject"
65	value = "{session_id}"
66	channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
67	```
68
69	### `[history]` — transcript access
70	Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
71	```toml
72	[history]
73	strategy = "fetcher"               # fetcher | locate_normalize | native
74	fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
75	# locate_normalize: locate_template + normalize_command
76	# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
77	```
78
79	### `[inject]` — inject-input methods
80	Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
81	```toml
82	[inject]
83	activity = ["hook"]                # non-disruptive during work
84	idle     = ["pty", "hook"]
85	```
86
87	### `[session]` watched dirs (file-drop)
88	```toml
89	[session]
90	commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
91	signoff_dir = ".claude"            # watched for <id>-signoff.md
92	```
93
94	### `[identity]`
95	```toml
96	[identity]
97	session_id_source = "post_spawn"   # post_spawn | uuid_inject
98	parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
99	```
100
101	### `[update]` — adapter self-update
102	```toml
103	[update]
104	avenue = "delegated"               # delegated | file_pull
105	command = "claude plugin update spt"          # delegated
106	# file_pull: repo + path_regex
107	version_check = true               # verify spt-core satisfies min_spt_core_version before/after
108	```
109
110	---
111
112	## Shell adapters (`kind = "shell"`)
113
114	A second kind of adapter. Provides a `Shell` endpoint (a driven surface) rather than hosting agents. Same file location (`adapters/`), different manifest body.
115
116	```toml
117	[adapter]
118	name = "GameRobot"
119	kind = "shell"                     # vs "harness"
120	version = "1.0.0"
121	min_spt_core_version = "1.0.0"
122
123	[shell]
124	spawn = "gamerobot-shell --link {link_token}"   # broker-launched; opaque command template
125	ephemeral = false                  # manifest property, NOT an agent choice; ephemeral ⇒ no offline perch + no history retention
126	broadcast = "subnet"               # subnet | same-node | none
127	command_receipt = "http"           # http | stdin | relay   (how it receives agent commands)
128	pre_close = "park-and-save"        # optional instruction sent to the binary on link-break
129	close_timeout_ms = 3000            # graceful termination window before force-close
130	persistent = true                  # auto-online whenever the owner endpoint is online
131	wake_command = "gamerobot-waker --link {link_token}"  # long-running wake-watcher run WHILE offline; exit(wake-opcode) ⇒ revive
132	can_shutdown = false               # if true, the shell may fire `api owner-shutdown` to suspend its owner directly
133
134	[shell.capabilities]               # the command vocabulary (agent→shell) — the toolset
135	move      = { args = ["direction", "distance"] }
136	gesture   = { args = ["name"] }
137	alert     = { args = ["symbol"] }
138	screenshot = {}
139
140	[shell.sensory]                    # the sensory vocabulary (shell→agent, REST-only)
141	types = ["image", "sound", "event"]   # "event" = arbitrary descriptive payloads
142	```
143
144	The capability + sensory vocabularies live **here**, not on the perch — spt-core resolves them by `adapter_name`. Command-delivery (`command_receipt`) reuses the agent inject-input modes. The shell binary binds via `api bind` (type=Shell, owner from the link) and pushes sensory via `api emit --type <t>`.
145
146	**Sleep/wake:** when offline (+ `wake_command` set), spt-core runs the wake-watcher (mutually exclusive with the shell binary); `exit(wake-opcode)` → wake resolution; crash-exit → respawn with exponential backoff + give-up. `persistent` auto-onlines the shell with its owner. `can_shutdown` authorizes `api owner-shutdown`. Cross-node *fresh-spawn* to wake an owner requires the owner's `shell_wake_spawn_anywhere` settings flag (pre-consent).
147
148	## Worked example: `claude-spt` (today's behavior, expressed)
149
150	Maps the sister project's actual surface onto the schema.
151
152	| Concern | Today (modern SPT) | Manifest expression |
153	|---|---|---|
154	| 7 CC hooks | hooks.json → `owl.exe <subcmd>` | `[hooks.*]` table; `Stop.can_inject=false` drives the echo-gate fallback |
155	| Psyche init | `claude -p --agents … --model sonnet --fallback-model opus …` | `[session.psyche_init].command` (opaque) |
156	| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |
157	| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
158	| `ccs` fallback | tier-2 binary swap | **node-wide cross-adapter fallback** (not manifest; `ccs` is its own adapter) |
159	| Transcript read | `.claude/projects/<enc-cwd>/<uuid>.jsonl` | `[history]` (fetcher or locate_normalize) |
160	| Commune/signoff | `.claude/<id>-commune.md` watched | `[session].commune_dir/signoff_dir`; filenames fixed |
161	| Env | `$OWL`/`$LIVE`/`OWL_SESSION_ID` via `CLAUDE_ENV_FILE` | `[env]`; PATH removes most; channel only harness-hosted |
162	| Identity | `OWL_SESSION_ID` + `claude.exe` ancestor walk | `[identity]` |
163	| Update | (today: cplugs) | `[update] avenue="delegated" command="claude plugin update"` |
164	| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |
165
166	## Open sub-items
167	- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
168	- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
169	- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
170	- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).
171

# Runtime manifest schema

> Draft design doc (2026-05-31). Resolves PRD §17.2. Implementation detail — see [`../CONTEXT.md`](../CONTEXT.md) for the model. Schema grounded in the sister project's actual functional surface.

## Principle

**SPT is not a harness.** The manifest declares only what *varies per harness*. Command templates are **opaque strings** — spt-core never parses out a model, tool list, or flag; the adapter writes the full command line and spt-core just runs it with substitution keys filled. Anything spt-core owns (sentinels, spool/registry/perch schemas, the event-block vocabulary, the version-trampoline mechanism, config knobs) is **not** in the manifest.

## What is NOT in the manifest (spt-core-owned)

- **Sentinels** (`.idle`-equiv, echo-gate) — managed by `api state`/`api echo-gate`; the adapter only *calls* them.
- **Spool / registry / perch `info.json` / daemon-state schemas** — core.
- **Event-block vocabulary** — the XML tags spt-core surfaces to agents (e.g. `<SPT_EVENT>`, `<owl_messages>`-equivalent) are a **fixed, documented constant**, not configurable. Adapters pass spt-core's output through unchanged.
- **File-drop filenames** — statically `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md`. Only the *watched directory* is declared (in `[session]`).
- **Config knobs** (pulse period, echo-commune window, route-guard window, daily-refresh) — spt-core **global settings**, with optional **per-endpoint override** for endpoints with unique needs. Not per-adapter.
- **Cross-adapter fallback** (psyche/echo invocation falls back to another adapter on rate-limit) — a **node-wide setting**, not a manifest field, because it references *multiple* adapters (under spt-core, `ccs` is its own adapter, not a binary-swap within one). Adapter-agnostic IDs make this safe — an endpoint's Psyche can run under a fallback adapter temporarily.
- **Plugin-cache / version-trampoline layout** — none of SPT's concern. A CC-plugin adapter updates via `claude plugin update` (delegated command).

## Manifest sections

### `[adapter]` — header
```toml
[adapter]
name = "claude-spt"
version = "2.0.0"
min_spt_core_version = "1.0.0"     # readable before any update; compat gate
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
```

### `[hooks.<event>]` — inbound hook table
Per harness event: the `api` command it fires, the stdin fields it maps in, and its **output capability** — `can_inject` (whether this hook can surface context to the agent). When `can_inject = false`, spt-core falls back to a sentinel + relay/poll path instead of expecting injection. This is the load-bearing harness-varying fact (CC's Stop hook can't inject).

```toml
[hooks.SessionStart]
fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
reads = ["session_id", "source", "agent_type"]
can_inject = true                  # via additionalContext

[hooks.Stop]
fires = "api state idle"
reads = []
can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
```

### `[session.<role>]` — outbound session role templates
One opaque command template per role: `self`, `psyche_init`, `psyche_resume`, `echo_commune`, `signoff`. Plus `cwd`, env inject/remove, detachment, and the recursion-guard env var. **Model, tools, flags, dir-grants all live inside `command`** — not as fields.

```toml
[session.psyche_init]
command = "claude -p --agents {agents_json} --agent owl-psyche --name {session_name} --model sonnet --fallback-model opus --effort medium --dangerously-skip-permissions --disable-slash-commands --tools Read,Edit,Write --output-format json"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
detach = true
keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
```
(`psyche_resume` adds `{session_id}`; both carry the spt-core-owned `{psyche_prompt}`. `signoff` is a `psyche_resume` variant firing a final context-save. `echo_commune` is a cheap-model template consuming history.)

### `[env]` — env-var table
Vars to inject/read, and **how**. With `spt` on PATH the table is small. The injection *channel* is asymmetric: **spt-hosted** sessions inherit env from the broker that spawns them (no channel needed); **harness-hosted** sessions need the harness's declared channel (the broker didn't spawn them). File-bridge-only-when-not-launcher, applied to env.

```toml
[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
```

### `[history]` — transcript access
Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
```toml
[history]
strategy = "fetcher"               # fetcher | locate_normalize | native
fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
# locate_normalize: locate_template + normalize_command
# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
```

### `[inject]` — inject-input methods
Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
```toml
[inject]
activity = ["hook"]                # non-disruptive during work
idle     = ["pty", "hook"]
```

### `[session]` watched dirs (file-drop)
```toml
[session]
commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
signoff_dir = ".claude"            # watched for <id>-signoff.md
```

### `[identity]`
```toml
[identity]
session_id_source = "post_spawn"   # post_spawn | uuid_inject
parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
```

### `[update]` — adapter self-update
```toml
[update]
avenue = "delegated"               # delegated | file_pull
command = "claude plugin update spt"          # delegated
# file_pull: repo + path_regex
version_check = true               # verify spt-core satisfies min_spt_core_version before/after
```

---

## Shell adapters (`kind = "shell"`)

A second kind of adapter. Provides a `Shell` endpoint (a driven surface) rather than hosting agents. Same file location (`adapters/`), different manifest body.

```toml
[adapter]
name = "GameRobot"
kind = "shell"                     # vs "harness"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token}"   # broker-launched; opaque command template
ephemeral = false                  # manifest property, NOT an agent choice; ephemeral ⇒ no offline perch + no history retention
broadcast = "subnet"               # subnet | same-node | none
command_receipt = "http"           # http | stdin | relay   (how it receives agent commands)
pre_close = "park-and-save"        # optional instruction sent to the binary on link-break
close_timeout_ms = 3000            # graceful termination window before force-close
persistent = true                  # auto-online whenever the owner endpoint is online
wake_command = "gamerobot-waker --link {link_token}"  # long-running wake-watcher run WHILE offline; exit(wake-opcode) ⇒ revive
can_shutdown = false               # if true, the shell may fire `api owner-shutdown` to suspend its owner directly

[shell.capabilities]               # the command vocabulary (agent→shell) — the toolset
move      = { args = ["direction", "distance"] }
gesture   = { args = ["name"] }
alert     = { args = ["symbol"] }
screenshot = {}

[shell.sensory]                    # the sensory vocabulary (shell→agent, REST-only)
types = ["image", "sound", "event"]   # "event" = arbitrary descriptive payloads
```

The capability + sensory vocabularies live **here**, not on the perch — spt-core resolves them by `adapter_name`. Command-delivery (`command_receipt`) reuses the agent inject-input modes. The shell binary binds via `api bind` (type=Shell, owner from the link) and pushes sensory via `api emit --type <t>`.

**Sleep/wake:** when offline (+ `wake_command` set), spt-core runs the wake-watcher (mutually exclusive with the shell binary); `exit(wake-opcode)` → wake resolution; crash-exit → respawn with exponential backoff + give-up. `persistent` auto-onlines the shell with its owner. `can_shutdown` authorizes `api owner-shutdown`. Cross-node *fresh-spawn* to wake an owner requires the owner's `shell_wake_spawn_anywhere` settings flag (pre-consent).

## Worked example: `claude-spt` (today's behavior, expressed)

Maps the sister project's actual surface onto the schema.

| Concern | Today (modern SPT) | Manifest expression |
|---|---|---|
| 7 CC hooks | hooks.json → `owl.exe <subcmd>` | `[hooks.*]` table; `Stop.can_inject=false` drives the echo-gate fallback |
| Psyche init | `claude -p --agents … --model sonnet --fallback-model opus …` | `[session.psyche_init].command` (opaque) |
| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |
| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
| `ccs` fallback | tier-2 binary swap | **node-wide cross-adapter fallback** (not manifest; `ccs` is its own adapter) |
| Transcript read | `.claude/projects/<enc-cwd>/<uuid>.jsonl` | `[history]` (fetcher or locate_normalize) |
| Commune/signoff | `.claude/<id>-commune.md` watched | `[session].commune_dir/signoff_dir`; filenames fixed |
| Env | `$OWL`/`$LIVE`/`OWL_SESSION_ID` via `CLAUDE_ENV_FILE` | `[env]`; PATH removes most; channel only harness-hosted |
| Identity | `OWL_SESSION_ID` + `claude.exe` ancestor walk | `[identity]` |
| Update | (today: cplugs) | `[update] avenue="delegated" command="claude plugin update"` |
| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |

## Open sub-items
- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).


Schema understood. Check spt-store style (error handling, module conventions) before writing.


ls crates/spt-store/src/ && echo "---" && head -60 crates/spt-store/src/lib.rs

Inspect spt-store module style


atomic.rs
info.rs
lib.rs
perch.rs
proc.rs
registry.rs
spool.rs
---
//! `spt-store` — the persistence commodity layer for spt-core.
//!
//! Sits directly above `spt-proto` (R-ARCH-1 layering): it serializes proto
//! types to disk (spool, registry, info.json) through a single atomic-write
//! primitive. Stable on-disk formats are copied verbatim from the sister
//! project (ADR-0001); the EBUSY-retry hardening on the write path is the
//! clean-room addition demanded by KNOWN-HAZARDS 5.2.
//!
// Second layer of the acyclically-layered crate stack (R-ARCH-1): spt-store
// depends only on spt-proto (one layer down), never the reverse — the
// dependency edge is one-way, so the workspace graph stays acyclic.
// [impl->REQ-ARCH-1]

pub mod atomic;
pub mod info;
pub mod perch;
pub mod proc;
pub mod registry;
pub mod spool;


Inspect info.rs struct+error style


//! `info.json` — the per-perch identity/status record at `<perch_path>/info.json`.
//!
//! Adapted from sister `types.rs` (`InfoJson` / `PidValue`) per ADR-0001:
//! - [`PidValue`] (untagged numeric-or-`"BUSY"`) is copied verbatim — the
//!   `"BUSY"` sentinel is load-bearing (a busy poll-listener writes it; numeric
//!   readers must treat it as "no usable pid").
//! - `state` is the spt-core [`EndpointType`](spt_proto::endpoint::EndpointType)
//!   tag string rather than the sister's `PerchState` enum (whose Spine/Touch
//!   kinds the spt-core taxonomy drops — see T4).
//!
//! ## Torn-read tolerance (KNOWN-HAZARDS 1.2, REQ-HAZARD-INFO-JSON-TORN-READ)
//! A reader may hit the file mid-write. Two layers guarantee it never observes a
//! truncated record:
//! 1. **Writers go through [`write_info`]** → [`crate::atomic::atomic_write_string`]
//!    (tmp + rename): a same-volume rename is atomic, so a concurrent reader
//!    sees the *complete* old file or the *complete* new file, never a torn one.
//! 2. **Readers tolerate failure**: [`read_info`] / [`read_pid`] parse via a
//!    bounded retry and return `None` on persistent failure — never panic.

use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::atomic::atomic_write_string;

/// Pid field that is either a real numeric pid or the `"BUSY"` sentinel a
/// listener writes while it holds the perch. Untagged: serializes as a bare
/// integer or a bare string. Copied verbatim (sister `PidValue`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PidValue {
    Numeric(u32),
    Busy(String),
}

/// On-disk shape of a perch's `info.json`. Field order is locked by declaration
/// (serde_json `preserve_order`). Optional fields are omitted when `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InfoJson {
    /// The perch / agent id.
    pub id: String,
    /// Creation timestamp (opaque string; producer's format).
    pub started: String,
    /// Owning process pid, or the `"BUSY"` sentinel.
    pub pid: PidValue,
    /// Harness session id bound to this perch.
    pub session_id: String,
    /// Endpoint type tag (`spt_proto::endpoint::EndpointType::as_tag`).
    pub state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_pid: Option<u32>,


grep -rn "pub enum.*Error\|thiserror\|impl.*Display.*for" crates/spt-store/src crates/spt-msg/src | head; echo "---deps---"; grep -rn "thiserror\|anyhow" crates/*/Cargo.toml

Check error-handling idiom


---deps---


grep -rn "Result<\|Err(\|-> .*Error" crates/spt-store/src/registry.rs | head -15

Check Result idiom


21:fn open_registry(owlery: &Path) -> rusqlite::Result<Connection> {
48:pub fn register_address(id: &str, addr: &std::net::SocketAddr, owlery: &Path) -> rusqlite::Result<()> {
60:) -> rusqlite::Result<()> {
87:pub fn unregister_address(id: &str, owlery: &Path) -> rusqlite::Result<()> {
95:pub fn clean_stale_entries(owlery: &Path) -> rusqlite::Result<()> {


grep -rn "io::Error\|io::Result\|-> Result<\|ErrorKind\|String>" crates/spt-msg/src/*.rs | grep -i error | head -15

Check spt-msg error style


crates/spt-msg/src/listener.rs:24:use std::io::ErrorKind;
crates/spt-msg/src/listener.rs:56:            let err = std::io::Error::last_os_error();
crates/spt-msg/src/listener.rs:57:            if err.kind() == ErrorKind::Interrupted {
crates/spt-msg/src/listener.rs:132:            Err(ref e) if e.kind() == ErrorKind::WouldBlock => None,
crates/spt-msg/src/wire.rs:52:        return Err(io::Error::new(
crates/spt-msg/src/wire.rs:53:            io::ErrorKind::InvalidData,
crates/spt-msg/src/wire.rs:60:    String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
crates/spt-msg/src/wire.rs:119:        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);


Patterns clear (io::Error idiom, no thiserror, doc-heavy). Write manifest schema module.


Write

//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
//!
//! A manifest declares only what *varies per harness*; command templates are
//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
//! a human-readable message instead of panicking on bad adapter input.
//!
//! Closed vocabularies (adapter kind, history strategy, env direction, inject
//! method, identity source, update avenue, shell broadcast/receipt) are modeled
//! as enums so an unknown variant is a parse error pointing at the bad value —
//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
//!
//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
//
// [impl->REQ-MANIFEST-1]

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
/// adapter declares only its header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
    pub adapter: Adapter,
    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub hooks: BTreeMap<String, Hook>,
    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
    #[serde(default, skip_serializing_if = "Session::is_empty")]
    pub session: Session,
    /// `[env.<VAR>]` — env-var inject/read table.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, EnvVar>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history: Option<History>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<Identity>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update: Option<Update>,
    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Adapter {
    pub name: String,
    #[serde(default)]
    pub kind: AdapterKind,
    pub version: String,
    /// Lowest spt-core version this adapter tolerates (compat gate).
    pub min_spt_core_version: String,
    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostable_types: Vec<String>,
}

/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
/// surface (MANIFEST §Shell adapters).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    #[default]
    Harness,
    Shell,
}

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

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

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

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

/// `[session.<role>]` — one opaque outbound command template plus its spawn
/// context. Model/tools/flags all live inside `command`, never as fields
/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
/// emission scalar-before-table clean).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRole {
    /// Opaque command line, with `{key}` substitution placeholders.
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Env var set on summarizer children so their own hooks bail (recursion
    /// guard).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recursion_guard_env: Option<String>,
    #[serde(default)]
    pub detach: bool,
    /// Env vars to strip from the child's inherited environment.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_remove: Vec<String>,
    /// Substitution keys spt-core guarantees to fill for this role.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<String>,
}

/// `[env.<VAR>]` — a single env-var directive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvVar {
    pub direction: EnvDirection,
    /// Value to inject (with substitution); required for `inject`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<String>,
    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvDirection {
    Inject,
    Read,
}

/// `[history]` — transcript access strategy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct History {
    pub strategy: HistoryStrategy,
    /// `fetcher` strategy: adapter binary emitting normalized history.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fetcher: Option<String>,
    /// `locate_normalize` strategy: where the raw transcript lives.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locate_template: Option<String>,
    /// `locate_normalize` strategy: command normalizing the raw transcript.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub normalize_command: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStrategy {
    /// spt-core asks the adapter (pull, adapter binary emits normalized).
    Fetcher,
    /// spt-core locates the raw transcript then normalizes it.
    LocateNormalize,
    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
    Native,
}

/// `[inject]` — inject-input methods per activity state.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Inject {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub activity: Vec<InjectMethod>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub idle: Vec<InjectMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InjectMethod {
    Pty,
    Hook,
    Relay,
    Http,
}

/// `[identity]` — how the harness's session id is obtained.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Identity {
    pub session_id_source: SessionIdSource,
    /// Process-tree anchor name when `session_id` is absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_ancestor_name: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionIdSource {
    /// Discovered after spawn (process-tree / wrapper handoff).
    PostSpawn,
    /// Injected as a UUID the harness echoes back.
    UuidInject,
}

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` avenue: source repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// Verify spt-core satisfies `min_spt_core_version` before/after.
    #[serde(default)]
    pub version_check: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
    Delegated,
    /// spt-core pulls files from a repo.
    FilePull,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.
    #[serde(default)]
    pub ephemeral: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub broadcast: Option<Broadcast>,
    /// How the shell receives agent commands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_receipt: Option<CommandReceipt>,
    /// Instruction sent to the binary on link-break.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pre_close: Option<String>,
    /// Graceful-termination window before force-close.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub close_timeout_ms: Option<u64>,
    /// Auto-online whenever the owner endpoint is online.
    #[serde(default)]
    pub persistent: bool,
    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wake_command: Option<String>,
    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
    #[serde(default)]
    pub can_shutdown: bool,
    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub capabilities: BTreeMap<String, ShellCapability>,
    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
    pub sensory: Sensory,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
}

/// `[shell.sensory]` — the sensory payload types a shell may emit.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Sensory {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Sensory {
    pub fn is_empty(&self) -> bool {
        self.types.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Broadcast {
    #[serde(rename = "subnet")]
    Subnet,
    #[serde(rename = "same-node")]
    SameNode,
    #[serde(rename = "none")]
    None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandReceipt {
    Http,
    Stdin,
    Relay,
}

/// Parse/validation failure — carries a human-readable message for the adapter
/// author rather than aborting.
#[derive(Debug)]
pub enum ManifestError {
    /// TOML deserialization failed (syntax or an unknown enum variant).
    Parse(toml::de::Error),
    /// Parsed cleanly but violated a cross-field invariant.
    Validation(String),
}

impl fmt::Display for ManifestError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
        }
    }
}

impl std::error::Error for ManifestError {}

impl From<toml::de::Error> for ManifestError {
    fn from(e: toml::de::Error) -> Self {
        ManifestError::Parse(e)
    }
}

impl Manifest {
    /// Parse a manifest from TOML text, then validate its cross-field
    /// invariants. Never panics on adapter input — malformed TOML, an unknown
    /// enum variant, or a broken invariant all return [`ManifestError`].
    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
        let manifest: Manifest = toml::from_str(s)?;
        manifest.validate()?;
        Ok(manifest)
    }

    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
    /// so the emitter never hits "values must precede tables".
    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
        toml::to_string(self)
    }

    /// Check the cross-field invariants the type system can't: kind↔body
    /// agreement, strategy↔field presence, inject completeness.
    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());
                }
                _ => {}
            }
        }

        Ok(())
    }
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime\src\manifest.rs (file state is current in your context — no need to Read it back)

//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
//!
//! A manifest declares only what *varies per harness*; command templates are
//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
//! a human-readable message instead of panicking on bad adapter input.
//!
//! Closed vocabularies (adapter kind, history strategy, env direction, inject
//! method, identity source, update avenue, shell broadcast/receipt) are modeled
//! as enums so an unknown variant is a parse error pointing at the bad value —
//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
//!
//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
//
// [impl->REQ-MANIFEST-1]

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
/// adapter declares only its header.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
    pub adapter: Adapter,
    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub hooks: BTreeMap<String, Hook>,
    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
    #[serde(default, skip_serializing_if = "Session::is_empty")]
    pub session: Session,
    /// `[env.<VAR>]` — env-var inject/read table.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, EnvVar>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub history: Option<History>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inject: Option<Inject>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<Identity>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub update: Option<Update>,
    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<Shell>,
}

/// `[adapter]` — the manifest header, readable before any update (compat gate).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Adapter {
    pub name: String,
    #[serde(default)]
    pub kind: AdapterKind,
    pub version: String,
    /// Lowest spt-core version this adapter tolerates (compat gate).
    pub min_spt_core_version: String,
    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hostable_types: Vec<String>,
}

/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
/// surface (MANIFEST §Shell adapters).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AdapterKind {
    #[default]
    Harness,
    Shell,
}

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

/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
/// with the fixed set of `[session.<role>]` command templates.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Session {
    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
    // only the directory is adapter-declared (MANIFEST §watched dirs).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commune_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff_dir: Option<String>,
    // Role templates (`[session.<role>]`). `self` is the agent's own session;
    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
    // *executed* in M2b.
    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
    pub self_: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_init: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psyche_resume: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub echo_commune: Option<SessionRole>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signoff: Option<SessionRole>,
}

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

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

/// `[session.<role>]` — one opaque outbound command template plus its spawn
/// context. Model/tools/flags all live inside `command`, never as fields
/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
/// emission scalar-before-table clean).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionRole {
    /// Opaque command line, with `{key}` substitution placeholders.
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Env var set on summarizer children so their own hooks bail (recursion
    /// guard).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub recursion_guard_env: Option<String>,
    #[serde(default)]
    pub detach: bool,
    /// Env vars to strip from the child's inherited environment.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_remove: Vec<String>,
    /// Substitution keys spt-core guarantees to fill for this role.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<String>,
}

/// `[env.<VAR>]` — a single env-var directive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnvVar {
    pub direction: EnvDirection,
    /// Value to inject (with substitution); required for `inject`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<String>,
    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvDirection {
    Inject,
    Read,
}

/// `[history]` — transcript access strategy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct History {
    pub strategy: HistoryStrategy,
    /// `fetcher` strategy: adapter binary emitting normalized history.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fetcher: Option<String>,
    /// `locate_normalize` strategy: where the raw transcript lives.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub locate_template: Option<String>,
    /// `locate_normalize` strategy: command normalizing the raw transcript.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub normalize_command: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStrategy {
    /// spt-core asks the adapter (pull, adapter binary emits normalized).
    Fetcher,
    /// spt-core locates the raw transcript then normalizes it.
    LocateNormalize,
    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
    Native,
}

/// `[inject]` — inject-input methods per activity state.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Inject {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub activity: Vec<InjectMethod>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub idle: Vec<InjectMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InjectMethod {
    Pty,
    Hook,
    Relay,
    Http,
}

/// `[identity]` — how the harness's session id is obtained.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Identity {
    pub session_id_source: SessionIdSource,
    /// Process-tree anchor name when `session_id` is absent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_ancestor_name: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionIdSource {
    /// Discovered after spawn (process-tree / wrapper handoff).
    PostSpawn,
    /// Injected as a UUID the harness echoes back.
    UuidInject,
}

/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Update {
    pub avenue: UpdateAvenue,
    /// `delegated` avenue: the command spt-core delegates to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// `file_pull` avenue: source repo.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// `file_pull` avenue: path selector.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path_regex: Option<String>,
    /// Verify spt-core satisfies `min_spt_core_version` before/after.
    #[serde(default)]
    pub version_check: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateAvenue {
    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
    Delegated,
    /// spt-core pulls files from a repo.
    FilePull,
}

/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Shell {
    /// Broker-launched opaque spawn command.
    pub spawn: String,
    /// Ephemeral ⇒ no offline perch + no history retention.
    #[serde(default)]
    pub ephemeral: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub broadcast: Option<Broadcast>,
    /// How the shell receives agent commands.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_receipt: Option<CommandReceipt>,
    /// Instruction sent to the binary on link-break.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pre_close: Option<String>,
    /// Graceful-termination window before force-close.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub close_timeout_ms: Option<u64>,
    /// Auto-online whenever the owner endpoint is online.
    #[serde(default)]
    pub persistent: bool,
    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub wake_command: Option<String>,
    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
    #[serde(default)]
    pub can_shutdown: bool,
    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub capabilities: BTreeMap<String, ShellCapability>,
    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
    pub sensory: Sensory,
}

/// One entry in `[shell.capabilities]` — a command and its argument names.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct ShellCapability {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
}

/// `[shell.sensory]` — the sensory payload types a shell may emit.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Sensory {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub types: Vec<String>,
}

impl Sensory {
    pub fn is_empty(&self) -> bool {
        self.types.is_empty()
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Broadcast {
    #[serde(rename = "subnet")]
    Subnet,
    #[serde(rename = "same-node")]
    SameNode,
    #[serde(rename = "none")]
    None,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandReceipt {
    Http,
    Stdin,
    Relay,
}

/// Parse/validation failure — carries a human-readable message for the adapter
/// author rather than aborting.
#[derive(Debug)]
pub enum ManifestError {
    /// TOML deserialization failed (syntax or an unknown enum variant).
    Parse(toml::de::Error),
    /// Parsed cleanly but violated a cross-field invariant.
    Validation(String),
}

impl fmt::Display for ManifestError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
        }
    }
}

impl std::error::Error for ManifestError {}

impl From<toml::de::Error> for ManifestError {
    fn from(e: toml::de::Error) -> Self {
        ManifestError::Parse(e)
    }
}

impl Manifest {
    /// Parse a manifest from TOML text, then validate its cross-field
    /// invariants. Never panics on adapter input — malformed TOML, an unknown
    /// enum variant, or a broken invariant all return [`ManifestError`].
    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
        let manifest: Manifest = toml::from_str(s)?;
        manifest.validate()?;
        Ok(manifest)
    }

    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
    /// so the emitter never hits "values must precede tables".
    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
        toml::to_string(self)
    }

    /// Check the cross-field invariants the type system can't: kind↔body
    /// agreement, strategy↔field presence, inject completeness.
    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());
                }
                _ => {}
            }
        }

        Ok(())
    }
}


Now wire module into lib.rs and add tests.


Edit

// Scaffold (T0): subsequent tasks populate these modules.

pub mod manifest;

pub use manifest::{Manifest, ManifestError};


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime\src\lib.rs has been updated successfully. (file state is current in your context — no need to Read it back)

pub mod manifest;

pub use manifest::{Manifest, ManifestError};

//! spt-core harness contract — outbound half (M2a).
//!
//! `spt-runtime` makes spt-core **drivable by a manifest-described harness** with
//! zero Claude Code (or any harness) conventions in-repo. It is the fourth layer
//! in the acyclic stack (`spt-proto -> spt-store -> spt-msg -> spt-runtime`).
//!
//! Two surfaces land here across M2a:
//! - the **runtime-manifest schema** + parse/validate ([`manifest`], T1), and
//! - the [`AgentRuntime`] trait + default `ManifestRuntime` (opaque-template
//!   substitution + spawn-session seam, T2).
//!
//! The inbound half (the `spt api` subcommand surface) lives in the `spt` binary
//! over this crate. See `M2a-PLAN.md`.

// Scaffold (T0): subsequent tasks populate these modules.

-// Scaffold (T0): subsequent tasks populate these modules.

+pub use manifest::{Manifest, ManifestError};


T0: Scaffold spt-runtime crate

T1: Manifest schema + parse + validate

T6: Worker perches + cascade-wipe guard

T7: Reporting + boundary surface

T8: Generic mock adapter

T9: Contract E2E test

T10: Activation sweep


Add unit tests inline.


Bash

grep -rln "mod tests" crates/spt-store/src crates/spt-msg/src | head; echo "---"; ls crates/spt-msg/tests 2>/dev/null

Check test placement convention


crates/spt-store/src/atomic.rs
crates/spt-store/src/info.rs
crates/spt-store/src/perch.rs
crates/spt-store/src/proc.rs
crates/spt-store/src/registry.rs
crates/spt-store/src/spool.rs
crates/spt-msg/src/deliver.rs
crates/spt-msg/src/listener.rs
crates/spt-msg/src/ready.rs
crates/spt-msg/src/ring.rs
---
killer_quickstart.rs


Edit

        Ok(())
    }
}

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

    /// A full harness manifest exercising every section (the worked
    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
    const HARNESS_FIXTURE: &str = r#"
[adapter]
name = "claude-spt"
version = "2.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]

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

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

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

[session.psyche_init]
command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
detach = true
keys = ["session_name", "psyche_dir"]

[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"

[history]
strategy = "fetcher"
fetcher = "claude-spt-history --session {session_id}"

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

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

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

    const SHELL_FIXTURE: &str = r#"
[adapter]
name = "GameRobot"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token}"
ephemeral = false
broadcast = "subnet"
command_receipt = "http"
persistent = true
can_shutdown = false

[shell.capabilities]
move = { args = ["direction", "distance"] }
screenshot = {}

[shell.sensory]
types = ["image", "sound", "event"]
"#;

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_fixture_parses_all_sections() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert_eq!(m.adapter.name, "claude-spt");
        assert_eq!(m.adapter.kind, AdapterKind::Harness);
        assert_eq!(m.adapter.hostable_types.len(), 4);
        // hooks
        assert!(m.hooks["SessionStart"].can_inject);
        assert!(!m.hooks["Stop"].can_inject);
        assert_eq!(m.hooks["SessionStart"].reads, ["session_id", "source", "agent_type"]);
        // session: dirs + one role
        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
        let role = m.session.psyche_init.as_ref().unwrap();
        assert!(role.detach);
        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));
        assert_eq!(m.session.roles().len(), 1);
        // env
        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
        // history / inject / identity / update enums
        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
        let inject = m.inject.unwrap();
        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {
        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
        assert_eq!(m.adapter.kind, AdapterKind::Shell);
        let shell = m.shell.as_ref().unwrap();
        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
        assert!(shell.capabilities["screenshot"].args.is_empty());
        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn round_trips_through_toml() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let serialized = m.to_toml_string().expect("serialize");
        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
        assert_eq!(m, reparsed, "manifest must survive a serialize→parse round-trip");
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn unknown_enum_variant_is_an_error_not_a_panic() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "telepathy"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
        // message names the offending value
        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_kind_without_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("[shell]"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_kind_with_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "x"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn inject_env_without_value_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[env.FOO]
direction = "inject"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(e.to_string().contains("FOO"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn fetcher_strategy_requires_fetcher_field() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "fetcher"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn minimal_header_only_manifest_is_valid() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
        )
        .expect("parse");
        assert_eq!(m.adapter.name, "min");
        assert!(m.session.is_empty());
        assert!(m.hooks.is_empty());
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn syntactically_broken_toml_is_an_error_not_a_panic() {
        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)));
    }
}


The file C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime\src\manifest.rs has been updated successfully. (file state is current in your context — no need to Read it back)

        Ok(())
    }
}

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

    /// A full harness manifest exercising every section (the worked
    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
    const HARNESS_FIXTURE: &str = r#"
[adapter]
name = "claude-spt"
version = "2.0.0"
min_spt_core_version = "1.0.0"
hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]

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

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

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

[session.psyche_init]
command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
cwd = "{psyche_dir}"
env_remove = ["OWL_SESSION_ID"]
recursion_guard_env = "SPT_ECHO_COMMUNE"
detach = true
keys = ["session_name", "psyche_dir"]

[env.OWL_SESSION_ID]
direction = "inject"
value = "{session_id}"
channel = "CLAUDE_ENV_FILE"

[history]
strategy = "fetcher"
fetcher = "claude-spt-history --session {session_id}"

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

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

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

    const SHELL_FIXTURE: &str = r#"
[adapter]
name = "GameRobot"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "gamerobot-shell --link {link_token}"
ephemeral = false
broadcast = "subnet"
command_receipt = "http"
persistent = true
can_shutdown = false

[shell.capabilities]
move = { args = ["direction", "distance"] }
screenshot = {}

[shell.sensory]
types = ["image", "sound", "event"]
"#;

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_fixture_parses_all_sections() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        assert_eq!(m.adapter.name, "claude-spt");
        assert_eq!(m.adapter.kind, AdapterKind::Harness);
        assert_eq!(m.adapter.hostable_types.len(), 4);
        // hooks
        assert!(m.hooks["SessionStart"].can_inject);
        assert!(!m.hooks["Stop"].can_inject);
        assert_eq!(m.hooks["SessionStart"].reads, ["session_id", "source", "agent_type"]);
        // session: dirs + one role
        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
        let role = m.session.psyche_init.as_ref().unwrap();
        assert!(role.detach);
        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));
        assert_eq!(m.session.roles().len(), 1);
        // env
        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
        // history / inject / identity / update enums
        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
        let inject = m.inject.unwrap();
        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_fixture_parses() {
        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
        assert_eq!(m.adapter.kind, AdapterKind::Shell);
        let shell = m.shell.as_ref().unwrap();
        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
        assert!(shell.capabilities["screenshot"].args.is_empty());
        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn round_trips_through_toml() {
        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
        let serialized = m.to_toml_string().expect("serialize");
        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
        assert_eq!(m, reparsed, "manifest must survive a serialize→parse round-trip");
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn unknown_enum_variant_is_an_error_not_a_panic() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "telepathy"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
        // message names the offending value
        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn shell_kind_without_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
kind = "shell"
version = "1.0.0"
min_spt_core_version = "1.0.0"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
        assert!(e.to_string().contains("[shell]"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn harness_kind_with_shell_section_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[shell]
spawn = "x"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn inject_env_without_value_fails_validation() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[env.FOO]
direction = "inject"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(e.to_string().contains("FOO"));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn fetcher_strategy_requires_fetcher_field() {
        let bad = r#"
[adapter]
name = "x"
version = "1.0.0"
min_spt_core_version = "1.0.0"

[history]
strategy = "fetcher"
"#;
        let e = Manifest::from_toml_str(bad).unwrap_err();
        assert!(matches!(e, ManifestError::Validation(_)));
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn minimal_header_only_manifest_is_valid() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
        )
        .expect("parse");
        assert_eq!(m.adapter.name, "min");
        assert!(m.session.is_empty());
        assert!(m.hooks.is_empty());
    }

    #[test] // [unit->REQ-MANIFEST-1]
    fn syntactically_broken_toml_is_an_error_not_a_panic() {
        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
        assert!(matches!(e, ManifestError::Parse(_)));
    }
}

+    /// A full harness manifest exercising every section (the worked

+    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).

+hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]

+fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"

+reads = ["session_id", "source", "agent_type"]

+command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"

+fetcher = "claude-spt-history --session {session_id}"

+spawn = "gamerobot-shell --link {link_token}"

+move = { args = ["direction", "distance"] }

+    fn harness_fixture_parses_all_sections() {

+        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");

+        assert_eq!(m.adapter.name, "claude-spt");

+        assert_eq!(m.adapter.kind, AdapterKind::Harness);

+        assert_eq!(m.adapter.hostable_types.len(), 4);

+        assert!(m.hooks["SessionStart"].can_inject);

+        assert!(!m.hooks["Stop"].can_inject);

+        assert_eq!(m.hooks["SessionStart"].reads, ["session_id", "source", "agent_type"]);

+        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));

+        let role = m.session.psyche_init.as_ref().unwrap();

+        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);

+        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));

+        assert_eq!(m.session.roles().len(), 1);

+        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);

+        // history / inject / identity / update enums

+        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);

+        let inject = m.inject.unwrap();

+        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);

+        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);

+        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);

+        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");

+        assert_eq!(m.adapter.kind, AdapterKind::Shell);

+        let shell = m.shell.as_ref().unwrap();

+        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));

+        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));

+        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);

+        assert!(shell.capabilities["screenshot"].args.is_empty());

+        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);

+        let serialized = m.to_toml_string().expect("serialize");

+        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");

+        assert_eq!(m, reparsed, "manifest must survive a serialize→parse round-trip");

+    fn unknown_enum_variant_is_an_error_not_a_panic() {

+        let e = Manifest::from_toml_str(bad).unwrap_err();

+        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");

+        // message names the offending value

+        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));

+    fn shell_kind_without_shell_section_fails_validation() {

+        assert!(matches!(e, ManifestError::Validation(_)));

+        assert!(e.to_string().contains("[shell]"));

+    fn harness_kind_with_shell_section_fails_validation() {

+    fn inject_env_without_value_fails_validation() {

+        assert!(e.to_string().contains("FOO"));

+    fn fetcher_strategy_requires_fetcher_field() {

+    fn minimal_header_only_manifest_is_valid() {

+        let m = Manifest::from_toml_str(

+            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",

+        assert_eq!(m.adapter.name, "min");

+    fn syntactically_broken_toml_is_an_error_not_a_panic() {

+        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();

+        assert!(matches!(e, ManifestError::Parse(_)));


Bash

cargo test -p spt-runtime 2>&1 | tail -25

Run spt-runtime tests


   Compiling spt-runtime v0.0.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt-runtime)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.35s
     Running unittests src\lib.rs (target\debug\deps\spt_runtime-f434f1e7c3b45576.exe)

running 10 tests
test manifest::tests::syntactically_broken_toml_is_an_error_not_a_panic ... ok
test manifest::tests::minimal_header_only_manifest_is_valid ... ok
test manifest::tests::fetcher_strategy_requires_fetcher_field ... ok
test manifest::tests::harness_kind_with_shell_section_fails_validation ... ok
test manifest::tests::shell_kind_without_shell_section_fails_validation ... ok
test manifest::tests::unknown_enum_variant_is_an_error_not_a_panic ... ok
test manifest::tests::inject_env_without_value_fails_validation ... ok
test manifest::tests::shell_fixture_parses ... ok
test manifest::tests::harness_fixture_parses_all_sections ... ok
test manifest::tests::round_trips_through_toml ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests spt_runtime

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


10/10 pass. Now tag the doc + activate REQ-MANIFEST-1. Inspect its registry entry.


grep -n "REQ-MANIFEST-1" -A 8 traceable-reqs.toml

Inspect REQ-MANIFEST-1 entry


59:id = "REQ-MANIFEST-1"
60-title = "Per-adapter manifest with adapter_name and min_spt_core_version"
61-required_stages = []
62-[[requirements]]
63-id = "REQ-SEAM-SPAWN"
64-title = "spawn-session seam"
65-required_stages = []
66-[[requirements]]
67-id = "REQ-SEAM-POSTSPAWN"


Activate REQ-MANIFEST-1 + tag the schema doc. Add doc stage (MANIFEST.md is the contract).


Edit

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = []

id = "REQ-MANIFEST-1"
title = "Per-adapter manifest with adapter_name and min_spt_core_version"
required_stages = ["doc", "impl", "unit"]


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