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	<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
20	typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->
21
22	## Manifest sections
23
24	### `[adapter]` — header
25	```toml
26	[adapter]
27	name = "claude-spt"
28	version = "2.0.0"
29	min_spt_core_version = "1.0.0"     # readable before any update; compat gate
30	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
31	```
32
33	### `[hooks.<event>]` — inbound hook table
34	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).
35
36	```toml
37	[hooks.SessionStart]
38	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
39	reads = ["session_id", "source", "agent_type"]
40	can_inject = true                  # via additionalContext
41
42	[hooks.Stop]
43	fires = "api state idle"
44	reads = []
45	can_inject = false                 # no hookSpecificOutput schema → sentinel/relay fallback
46	```
47
48	### `[session.<role>]` — outbound session role templates
49	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.
50
51	```toml
52	[session.psyche_init]
53	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"
54	cwd = "{psyche_dir}"
55	env_remove = ["OWL_SESSION_ID"]
56	recursion_guard_env = "SPT_ECHO_COMMUNE"   # set on summarizer children so their own hooks bail
57	detach = true
58	keys = ["agents_json", "session_name", "psyche_dir"]   # substitution keys spt-core fills
59	```
60	(`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.)
61
62	### `[env]` — env-var table
63	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.
64
65	```toml
66	[env.OWL_SESSION_ID]
67	direction = "inject"
68	value = "{session_id}"
69	channel = "CLAUDE_ENV_FILE"        # harness-hosted only; spt-hosted inherits from broker
70	```
71
72	### `[history]` — transcript access
73	Three strategies; pick one. Pull-based (spt-core asks) or push-based (adapter sends).
74	```toml
75	[history]
76	strategy = "fetcher"               # fetcher | locate_normalize | native
77	fetcher = "claude-spt-history --session {session_id}"   # adapter binary emits normalized history
78	# locate_normalize: locate_template + normalize_command
79	# native: adapter pushes via `api history-log`; spt-core stores (also used for Shells)
80	```
81
82	### `[inject]` — inject-input methods
83	Per activity-state, any combination: `pty`, `hook`, `relay`, `http`.
84	```toml
85	[inject]
86	activity = ["hook"]                # non-disruptive during work
87	idle     = ["pty", "hook"]
88	```
89
90	### `[session]` watched dirs (file-drop)
91	```toml
92	[session]
93	commune_dir = ".claude"            # watched for <id>-commune.md (filename fixed by spt-core)
94	signoff_dir = ".claude"            # watched for <id>-signoff.md
95	```
96
97	### `[identity]`
98	```toml
99	[identity]
100	session_id_source = "post_spawn"   # post_spawn | uuid_inject
101	parent_ancestor_name = "claude"    # process-tree anchor when session_id absent
102	```
103
104	### `[update]` — adapter self-update
105	```toml
106	[update]
107	avenue = "delegated"               # delegated | file_pull
108	command = "claude plugin update spt"          # delegated
109	# file_pull: repo + path_regex
110	version_check = true               # verify spt-core satisfies min_spt_core_version before/after
111	uninstall = "claude plugin uninstall spt"     # optional; mirror of install — runs on `adapter remove` once quiesced
112	```
113
114	`spt adapter add` reuses this section as the **install** mechanism (install is the first update; `--github <user/repo>` fetches the manifest first, then installs via the declared `avenue`). `uninstall` (optional) is its inverse, fired by `spt adapter remove` (soft-deregister; runs when no instance is live under the adapter, or immediately with `--force`). Absent `uninstall` ⇒ spt-core's default cleanup (drop its copy for `file_pull`; for `delegated`, just deregister and leave the harness's own files).
115
116	---
117
118	## Shell adapters (`kind = "shell"`)
119
120	A second kind of adapter. Provides a `Shell` endpoint (a driven surface) rather than hosting agents. Same file location (`adapters/`), different manifest body.
121
122	```toml
123	[adapter]
124	name = "GameRobot"
125	kind = "shell"                     # vs "harness"
126	version = "1.0.0"
127	min_spt_core_version = "1.0.0"
128
129	[shell]
130	spawn = "gamerobot-shell --link {link_token}"   # broker-launched; opaque command template
131	ephemeral = false                  # manifest property, NOT an agent choice; ephemeral ⇒ no offline perch + no history retention
132	broadcast = "subnet"               # subnet | same-node | none
133	command_receipt = "http"           # http | stdin | relay   (how it receives agent commands)
134	pre_close = "park-and-save"        # optional instruction sent to the binary on link-break
135	close_timeout_ms = 3000            # graceful termination window before force-close
136	persistent = true                  # auto-online whenever the owner endpoint is online
137	wake_command = "gamerobot-waker --link {link_token}"  # long-running wake-watcher run WHILE offline; exit(wake-opcode) ⇒ revive
138	can_shutdown = false               # if true, the shell may fire `api owner-shutdown` to suspend its owner directly
139	require_approval = "none"          # none | remembered | always — per-spawn user-approval gate (floor; node/endpoint may tighten)
140	max_instances_per_owner = 4        # optional cap on existing instances per owner (online+offline both count); omit ⇒ unlimited
141	over_cap = "reject"                # reject | approve — at the cap: refuse, or require per-spawn approval beyond it (doesn't raise the cap)
142
143	[shell.capabilities]               # the command vocabulary (agent→shell) — the toolset
144	move      = { args = ["direction", "distance"] }
145	gesture   = { args = ["name"] }
146	alert     = { args = ["symbol"] }
147	screenshot = {}
148
149	[shell.sensory]                    # the sensory vocabulary (shell→agent, REST-only)
150	types = ["image", "sound", "event"]   # "event" = arbitrary descriptive payloads
151	```
152
153	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>`.
154
155	**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).
156
157	**Instantiation governance:** `require_approval` gates `shell spawn` (reusing the consent grant store — `remembered` persists a grant via allow-always, `always` prompts every spawn); `max_instances_per_owner` + `over_cap` cap how many instances one owner may hold (online + offline both count toward the cap). An instance's **alias** is **not** a manifest field — it is per-instance runtime state on the shell perch's `info.json` (set at `shell spawn --alias` / `shell rename`), since the manifest describes the *adapter*, not individual instances.
158
159	## Worked example: `claude-spt` (today's behavior, expressed)
160
161	Maps the sister project's actual surface onto the schema.
162
163	| Concern | Today (modern SPT) | Manifest expression |
164	|---|---|---|
165	| 7 CC hooks | hooks.json → `owl.exe <subcmd>` | `[hooks.*]` table; `Stop.can_inject=false` drives the echo-gate fallback |
166	| Psyche init | `claude -p --agents … --model sonnet --fallback-model opus …` | `[session.psyche_init].command` (opaque) |
167	| Psyche resume | `claude -p --resume <uuid> --model sonnet …` | `[session.psyche_resume]` + `{session_id}` |
168	| Echo-commune | `claude -p --model haiku --effort low …`, `OWL_ECHO_COMMUNE=1` guard | `[session.echo_commune]` + `recursion_guard_env` |
169	| `ccs` fallback | tier-2 binary swap | **node-wide cross-adapter fallback** (not manifest; `ccs` is its own adapter) |
170	| Transcript read | `.claude/projects/<enc-cwd>/<uuid>.jsonl` | `[history]` (fetcher or locate_normalize) |
171	| Commune/signoff | `.claude/<id>-commune.md` watched | `[session].commune_dir/signoff_dir`; filenames fixed |
172	| Env | `$OWL`/`$LIVE`/`OWL_SESSION_ID` via `CLAUDE_ENV_FILE` | `[env]`; PATH removes most; channel only harness-hosted |
173	| Identity | `OWL_SESSION_ID` + `claude.exe` ancestor walk | `[identity]` |
174	| Update | (today: cplugs) | `[update] avenue="delegated" command="claude plugin update"` |
175	| period / echo-window / route-guard / 24h refresh | hardcoded/flags | spt-core **global knobs** (per-endpoint override), not manifest |
176
177	## Open sub-items
178	- Exact substitution-key catalog (which keys spt-core guarantees to fill per role).
179	- TOML vs YAML final choice; schema-validation + helpful errors for adapter authors.
180	- The fixed event-block vocabulary's final tag names (`<SPT_EVENT>` etc.).
181	- Capability-declaration shape beyond `hostable_types` (per-type operation advertisement).
182

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

<!-- [doc->REQ-MANIFEST-1] The sections below are the authoritative schema; the
typed form in `crates/spt-runtime/src/manifest.rs` is kept in lockstep. -->

## 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
uninstall = "claude plugin uninstall spt"     # optional; mirror of install — runs on `adapter remove` once quiesced
```

`spt adapter add` reuses this section as the **install** mechanism (install is the first update; `--github <user/repo>` fetches the manifest first, then installs via the declared `avenue`). `uninstall` (optional) is its inverse, fired by `spt adapter remove` (soft-deregister; runs when no instance is live under the adapter, or immediately with `--force`). Absent `uninstall` ⇒ spt-core's default cleanup (drop its copy for `file_pull`; for `delegated`, just deregister and leave the harness's own files).

---

## 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
require_approval = "none"          # none | remembered | always — per-spawn user-approval gate (floor; node/endpoint may tighten)
max_instances_per_owner = 4        # optional cap on existing instances per owner (online+offline both count); omit ⇒ unlimited
over_cap = "reject"                # reject | approve — at the cap: refuse, or require per-spawn approval beyond it (doesn't raise the cap)

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

**Instantiation governance:** `require_approval` gates `shell spawn` (reusing the consent grant store — `remembered` persists a grant via allow-always, `always` prompts every spawn); `max_instances_per_owner` + `over_cap` cap how many instances one owner may hold (online + offline both count toward the cap). An instance's **alias** is **not** a manifest field — it is per-instance runtime state on the shell perch's `info.json` (set at `shell spawn --alias` / `shell rename`), since the manifest describes the *adapter*, not individual instances.

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


NOTE fROM THE USER: proceed with next steps.


1	//! Runtime-manifest schema — the typed form of [`docs/MANIFEST.md`].
2	//!
3	//! A manifest declares only what *varies per harness*; command templates are
4	//! **opaque strings** spt-core never parses (MANIFEST §Principle). This module
5	//! is the parse+validate front door: [`Manifest::from_toml_str`] deserializes
6	//! the TOML and runs [`Manifest::validate`], returning a [`ManifestError`] with
7	//! a human-readable message instead of panicking on bad adapter input.
8	//!
9	//! Closed vocabularies (adapter kind, history strategy, env direction, inject
10	//! method, identity source, update avenue, shell broadcast/receipt) are modeled
11	//! as enums so an unknown variant is a parse error pointing at the bad value —
12	//! the "helpful errors for adapter authors" open sub-item (MANIFEST §Open).
13	//!
14	//! Scope (M2a/T1): every section is *declared and parsed*. Executing the
15	//! lifecycle sections (`[session.psyche_*]`, commune/signoff dirs, `[history]`
16	//! source, `[update]` conduct) is M2b/M3 — here they only round-trip.
17	//
18	// [impl->REQ-MANIFEST-1]
19
20	use serde::{Deserialize, Serialize};
21	use std::collections::BTreeMap;
22	use std::fmt;
23
24	/// A fully-parsed runtime manifest. Top-level sections map 1:1 to the
25	/// `docs/MANIFEST.md` tables; all but `[adapter]` are optional so a minimal
26	/// adapter declares only its header.
27	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28	pub struct Manifest {
29	    pub adapter: Adapter,
30	    /// `[hooks.<event>]` — inbound hook table, keyed by harness event name.
31	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32	    pub hooks: BTreeMap<String, Hook>,
33	    /// `[session]` — watched-dir keys plus the `[session.<role>]` templates.
34	    #[serde(default, skip_serializing_if = "Session::is_empty")]
35	    pub session: Session,
36	    /// `[env.<VAR>]` — env-var inject/read table.
37	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
38	    pub env: BTreeMap<String, EnvVar>,
39	    #[serde(default, skip_serializing_if = "Option::is_none")]
40	    pub history: Option<History>,
41	    #[serde(default, skip_serializing_if = "Option::is_none")]
42	    pub inject: Option<Inject>,
43	    #[serde(default, skip_serializing_if = "Option::is_none")]
44	    pub identity: Option<Identity>,
45	    #[serde(default, skip_serializing_if = "Option::is_none")]
46	    pub update: Option<Update>,
47	    /// `[shell]` body — present iff `adapter.kind = "shell"` (validated).
48	    #[serde(default, skip_serializing_if = "Option::is_none")]
49	    pub shell: Option<Shell>,
50	}
51
52	/// `[adapter]` — the manifest header, readable before any update (compat gate).
53	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54	pub struct Adapter {
55	    pub name: String,
56	    #[serde(default)]
57	    pub kind: AdapterKind,
58	    pub version: String,
59	    /// Lowest spt-core version this adapter tolerates (compat gate).
60	    pub min_spt_core_version: String,
61	    /// Endpoint types this adapter can host (`LiveAgent`, `Worker`, …).
62	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63	    pub hostable_types: Vec<String>,
64	}
65
66	/// The two adapter kinds. A `harness` hosts agents; a `shell` provides a driven
67	/// surface (MANIFEST §Shell adapters).
68	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
69	#[serde(rename_all = "snake_case")]
70	pub enum AdapterKind {
71	    #[default]
72	    Harness,
73	    Shell,
74	}
75
76	/// `[hooks.<event>]` — one harness event → the `api` command it fires, the
77	/// stdin fields it maps, and whether it can surface context to the agent.
78	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79	pub struct Hook {
80	    /// Opaque `api …` command line the harness invokes for this event.
81	    pub fires: String,
82	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83	    pub reads: Vec<String>,
84	    /// Whether this hook can inject context (false ⇒ sentinel/relay fallback).
85	    #[serde(default)]
86	    pub can_inject: bool,
87	}
88
89	/// `[session]` — the watched-dir keys (`commune_dir`/`signoff_dir`) co-located
90	/// with the fixed set of `[session.<role>]` command templates.
91	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
92	pub struct Session {
93	    // Watched dirs (direct `[session]` keys). Filenames are spt-core-fixed;
94	    // only the directory is adapter-declared (MANIFEST §watched dirs).
95	    #[serde(default, skip_serializing_if = "Option::is_none")]
96	    pub commune_dir: Option<String>,
97	    #[serde(default, skip_serializing_if = "Option::is_none")]
98	    pub signoff_dir: Option<String>,
99	    // Role templates (`[session.<role>]`). `self` is the agent's own session;
100	    // the `psyche_*`/`echo_commune`/`signoff` roles are parsed here but only
101	    // *executed* in M2b.
102	    #[serde(rename = "self", default, skip_serializing_if = "Option::is_none")]
103	    pub self_: Option<SessionRole>,
104	    #[serde(default, skip_serializing_if = "Option::is_none")]
105	    pub psyche_init: Option<SessionRole>,
106	    #[serde(default, skip_serializing_if = "Option::is_none")]
107	    pub psyche_resume: Option<SessionRole>,
108	    #[serde(default, skip_serializing_if = "Option::is_none")]
109	    pub echo_commune: Option<SessionRole>,
110	    #[serde(default, skip_serializing_if = "Option::is_none")]
111	    pub signoff: Option<SessionRole>,
112	}
113
114	impl Session {
115	    /// True when no dirs and no roles are declared (skip-serialize helper).
116	    pub fn is_empty(&self) -> bool {
117	        self.commune_dir.is_none()
118	            && self.signoff_dir.is_none()
119	            && self.self_.is_none()
120	            && self.psyche_init.is_none()
121	            && self.psyche_resume.is_none()
122	            && self.echo_commune.is_none()
123	            && self.signoff.is_none()
124	    }
125
126	    /// All declared role templates, paired with their role name. Used by the
127	    /// runtime to enumerate spawnable roles.
128	    pub fn roles(&self) -> Vec<(&'static str, &SessionRole)> {
129	        let mut out = Vec::new();
130	        for (name, role) in [
131	            ("self", &self.self_),
132	            ("psyche_init", &self.psyche_init),
133	            ("psyche_resume", &self.psyche_resume),
134	            ("echo_commune", &self.echo_commune),
135	            ("signoff", &self.signoff),
136	        ] {
137	            if let Some(r) = role {
138	                out.push((name, r));
139	            }
140	        }
141	        out
142	    }
143	}
144
145	/// `[session.<role>]` — one opaque outbound command template plus its spawn
146	/// context. Model/tools/flags all live inside `command`, never as fields
147	/// (MANIFEST §session roles). No nested tables here (keeps TOML round-trip
148	/// emission scalar-before-table clean).
149	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150	pub struct SessionRole {
151	    /// Opaque command line, with `{key}` substitution placeholders.
152	    pub command: String,
153	    #[serde(default, skip_serializing_if = "Option::is_none")]
154	    pub cwd: Option<String>,
155	    /// Env var set on summarizer children so their own hooks bail (recursion
156	    /// guard).
157	    #[serde(default, skip_serializing_if = "Option::is_none")]
158	    pub recursion_guard_env: Option<String>,
159	    #[serde(default)]
160	    pub detach: bool,
161	    /// Env vars to strip from the child's inherited environment.
162	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163	    pub env_remove: Vec<String>,
164	    /// Substitution keys spt-core guarantees to fill for this role.
165	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166	    pub keys: Vec<String>,
167	}
168
169	/// `[env.<VAR>]` — a single env-var directive.
170	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171	pub struct EnvVar {
172	    pub direction: EnvDirection,
173	    /// Value to inject (with substitution); required for `inject`.
174	    #[serde(default, skip_serializing_if = "Option::is_none")]
175	    pub value: Option<String>,
176	    /// Harness-hosted injection channel (spt-hosted inherits from the broker).
177	    #[serde(default, skip_serializing_if = "Option::is_none")]
178	    pub channel: Option<String>,
179	}
180
181	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182	#[serde(rename_all = "snake_case")]
183	pub enum EnvDirection {
184	    Inject,
185	    Read,
186	}
187
188	/// `[history]` — transcript access strategy.
189	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190	pub struct History {
191	    pub strategy: HistoryStrategy,
192	    /// `fetcher` strategy: adapter binary emitting normalized history.
193	    #[serde(default, skip_serializing_if = "Option::is_none")]
194	    pub fetcher: Option<String>,
195	    /// `locate_normalize` strategy: where the raw transcript lives.
196	    #[serde(default, skip_serializing_if = "Option::is_none")]
197	    pub locate_template: Option<String>,
198	    /// `locate_normalize` strategy: command normalizing the raw transcript.
199	    #[serde(default, skip_serializing_if = "Option::is_none")]
200	    pub normalize_command: Option<String>,
201	}
202
203	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204	#[serde(rename_all = "snake_case")]
205	pub enum HistoryStrategy {
206	    /// spt-core asks the adapter (pull, adapter binary emits normalized).
207	    Fetcher,
208	    /// spt-core locates the raw transcript then normalizes it.
209	    LocateNormalize,
210	    /// Adapter pushes via `api history-log`; spt-core stores (Path-B).
211	    Native,
212	}
213
214	/// `[inject]` — inject-input methods per activity state.
215	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
216	pub struct Inject {
217	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218	    pub activity: Vec<InjectMethod>,
219	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220	    pub idle: Vec<InjectMethod>,
221	}
222
223	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224	#[serde(rename_all = "snake_case")]
225	pub enum InjectMethod {
226	    Pty,
227	    Hook,
228	    Relay,
229	    Http,
230	}
231
232	/// `[identity]` — how the harness's session id is obtained.
233	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234	pub struct Identity {
235	    pub session_id_source: SessionIdSource,
236	    /// Process-tree anchor name when `session_id` is absent.
237	    #[serde(default, skip_serializing_if = "Option::is_none")]
238	    pub parent_ancestor_name: Option<String>,
239	}
240
241	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242	#[serde(rename_all = "snake_case")]
243	pub enum SessionIdSource {
244	    /// Discovered after spawn (process-tree / wrapper handoff).
245	    PostSpawn,
246	    /// Injected as a UUID the harness echoes back.
247	    UuidInject,
248	}
249
250	/// `[update]` — adapter self-update directive (parsed in M2a; conducted in M3).
251	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
252	pub struct Update {
253	    pub avenue: UpdateAvenue,
254	    /// `delegated` avenue: the command spt-core delegates to.
255	    #[serde(default, skip_serializing_if = "Option::is_none")]
256	    pub command: Option<String>,
257	    /// `file_pull` avenue: source repo.
258	    #[serde(default, skip_serializing_if = "Option::is_none")]
259	    pub repo: Option<String>,
260	    /// `file_pull` avenue: path selector.
261	    #[serde(default, skip_serializing_if = "Option::is_none")]
262	    pub path_regex: Option<String>,
263	    /// Verify spt-core satisfies `min_spt_core_version` before/after.
264	    #[serde(default)]
265	    pub version_check: bool,
266	    /// Optional inverse of install — run by `spt adapter remove` once the adapter
267	    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
268	    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
269	    /// M2a; conducted with adapter-registration later.)
270	    #[serde(default, skip_serializing_if = "Option::is_none")]
271	    pub uninstall: Option<String>,
272	}
273
274	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275	#[serde(rename_all = "snake_case")]
276	pub enum UpdateAvenue {
277	    /// Delegate to the adapter's own updater (e.g. `claude plugin update`).
278	    Delegated,
279	    /// spt-core pulls files from a repo.
280	    FilePull,
281	}
282
283	/// `[shell]` — the body of a `kind = "shell"` adapter (a driven surface).
284	#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285	pub struct Shell {
286	    /// Broker-launched opaque spawn command.
287	    pub spawn: String,
288	    /// Ephemeral ⇒ no offline perch + no history retention.
289	    #[serde(default)]
290	    pub ephemeral: bool,
291	    #[serde(default, skip_serializing_if = "Option::is_none")]
292	    pub broadcast: Option<Broadcast>,
293	    /// How the shell receives agent commands.
294	    #[serde(default, skip_serializing_if = "Option::is_none")]
295	    pub command_receipt: Option<CommandReceipt>,
296	    /// Instruction sent to the binary on link-break.
297	    #[serde(default, skip_serializing_if = "Option::is_none")]
298	    pub pre_close: Option<String>,
299	    /// Graceful-termination window before force-close.
300	    #[serde(default, skip_serializing_if = "Option::is_none")]
301	    pub close_timeout_ms: Option<u64>,
302	    /// Auto-online whenever the owner endpoint is online.
303	    #[serde(default)]
304	    pub persistent: bool,
305	    /// Long-running wake-watcher run WHILE offline; exit ⇒ revive.
306	    #[serde(default, skip_serializing_if = "Option::is_none")]
307	    pub wake_command: Option<String>,
308	    /// Whether the shell may fire `api owner-shutdown` to suspend its owner.
309	    #[serde(default)]
310	    pub can_shutdown: bool,
311	    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
312	    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
313	    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
314	    pub require_approval: ShellApproval,
315	    /// Optional ceiling on concurrent-existing instances per owner endpoint
316	    /// (online + offline both count). Absent ⇒ unlimited.
317	    #[serde(default, skip_serializing_if = "Option::is_none")]
318	    pub max_instances_per_owner: Option<u32>,
319	    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
320	    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
321	    #[serde(default, skip_serializing_if = "OverCap::is_default")]
322	    pub over_cap: OverCap,
323	    /// `[shell.capabilities]` — the command vocabulary (agent→shell).
324	    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
325	    pub capabilities: BTreeMap<String, ShellCapability>,
326	    /// `[shell.sensory]` — the sensory vocabulary (shell→agent).
327	    #[serde(default, skip_serializing_if = "Sensory::is_empty")]
328	    pub sensory: Sensory,
329	}
330
331	/// One entry in `[shell.capabilities]` — a command and its argument names.
332	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
333	pub struct ShellCapability {
334	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335	    pub args: Vec<String>,
336	}
337
338	/// `[shell.sensory]` — the sensory payload types a shell may emit.
339	#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
340	pub struct Sensory {
341	    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342	    pub types: Vec<String>,
343	}
344
345	impl Sensory {
346	    pub fn is_empty(&self) -> bool {
347	        self.types.is_empty()
348	    }
349	}
350
351	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352	pub enum Broadcast {
353	    #[serde(rename = "subnet")]
354	    Subnet,
355	    #[serde(rename = "same-node")]
356	    SameNode,
357	    #[serde(rename = "none")]
358	    None,
359	}
360
361	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
362	#[serde(rename_all = "snake_case")]
363	pub enum CommandReceipt {
364	    Http,
365	    Stdin,
366	    Relay,
367	}
368
369	/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
370	/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
371	/// suppresses allow-always (prompt every spawn).
372	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
373	#[serde(rename_all = "snake_case")]
374	pub enum ShellApproval {
375	    /// No approval (default — matches the system's everything-opt-in posture).
376	    #[default]
377	    None,
378	    /// Prompt; allow-always persists a grant, later spawns auto-allow.
379	    Remembered,
380	    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
381	    Always,
382	}
383
384	impl ShellApproval {
385	    /// Whether this is the default (`none`) — for `skip_serializing_if`.
386	    pub fn is_default(&self) -> bool {
387	        matches!(self, ShellApproval::None)
388	    }
389	}
390
391	/// What happens when an owner is at its `max_instances_per_owner` cap.
392	#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
393	#[serde(rename_all = "snake_case")]
394	pub enum OverCap {
395	    /// Refuse the spawn outright (default).
396	    #[default]
397	    Reject,
398	    /// Require per-spawn approval beyond the cap (does not raise the cap).
399	    Approve,
400	}
401
402	impl OverCap {
403	    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
404	    pub fn is_default(&self) -> bool {
405	        matches!(self, OverCap::Reject)
406	    }
407	}
408
409	/// Parse/validation failure — carries a human-readable message for the adapter
410	/// author rather than aborting.
411	#[derive(Debug)]
412	pub enum ManifestError {
413	    /// TOML deserialization failed (syntax or an unknown enum variant).
414	    Parse(toml::de::Error),
415	    /// Parsed cleanly but violated a cross-field invariant.
416	    Validation(String),
417	}
418
419	impl fmt::Display for ManifestError {
420	    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421	        match self {
422	            ManifestError::Parse(e) => write!(f, "manifest parse error: {e}"),
423	            ManifestError::Validation(msg) => write!(f, "manifest invalid: {msg}"),
424	        }
425	    }
426	}
427
428	impl std::error::Error for ManifestError {}
429
430	impl From<toml::de::Error> for ManifestError {
431	    fn from(e: toml::de::Error) -> Self {
432	        ManifestError::Parse(e)
433	    }
434	}
435
436	impl Manifest {
437	    /// Parse a manifest from TOML text, then validate its cross-field
438	    /// invariants. Never panics on adapter input — malformed TOML, an unknown
439	    /// enum variant, or a broken invariant all return [`ManifestError`].
440	    pub fn from_toml_str(s: &str) -> Result<Manifest, ManifestError> {
441	        let manifest: Manifest = toml::from_str(s)?;
442	        manifest.validate()?;
443	        Ok(manifest)
444	    }
445
446	    /// Serialize back to TOML. Section structs are ordered scalars-before-tables
447	    /// so the emitter never hits "values must precede tables".
448	    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
449	        toml::to_string(self)
450	    }
451
452	    /// Check the cross-field invariants the type system can't: kind↔body
453	    /// agreement, strategy↔field presence, inject completeness.
454	    pub fn validate(&self) -> Result<(), ManifestError> {
455	        let err = |m: String| Err(ManifestError::Validation(m));
456
457	        if self.adapter.name.trim().is_empty() {
458	            return err("adapter.name must not be empty".into());
459	        }
460	        if self.adapter.version.trim().is_empty() {
461	            return err(format!(
462	                "adapter '{}': version must not be empty",
463	                self.adapter.name
464	            ));
465	        }
466
467	        // kind ↔ [shell] body agreement.
468	        match self.adapter.kind {
469	            AdapterKind::Shell if self.shell.is_none() => {
470	                return err(format!(
471	                    "shell adapter '{}' is missing its [shell] section",
472	                    self.adapter.name
473	                ));
474	            }
475	            AdapterKind::Harness if self.shell.is_some() => {
476	                return err(format!(
477	                    "harness adapter '{}' must not declare a [shell] section (set kind = \"shell\")",
478	                    self.adapter.name
479	                ));
480	            }
481	            _ => {}
482	        }
483
484	        // history strategy ↔ required fields.
485	        if let Some(h) = &self.history {
486	            match h.strategy {
487	                HistoryStrategy::Fetcher if h.fetcher.is_none() => {
488	                    return err("[history] strategy = \"fetcher\" requires `fetcher`".into());
489	                }
490	                HistoryStrategy::LocateNormalize
491	                    if h.locate_template.is_none() || h.normalize_command.is_none() =>
492	                {
493	                    return err("[history] strategy = \"locate_normalize\" requires both `locate_template` and `normalize_command`".into());
494	                }
495	                _ => {}
496	            }
497	        }
498
499	        // env: an inject directive needs a value.
500	        for (name, var) in &self.env {
501	            if var.direction == EnvDirection::Inject && var.value.is_none() {
502	                return err(format!(
503	                    "[env.{name}] direction = \"inject\" requires a `value`"
504	                ));
505	            }
506	        }
507
508	        // update avenue ↔ required fields.
509	        if let Some(u) = &self.update {
510	            match u.avenue {
511	                UpdateAvenue::Delegated if u.command.is_none() => {
512	                    return err("[update] avenue = \"delegated\" requires `command`".into());
513	                }
514	                UpdateAvenue::FilePull if u.repo.is_none() => {
515	                    return err("[update] avenue = \"file_pull\" requires `repo`".into());
516	                }
517	                _ => {}
518	            }
519	        }
520
521	        Ok(())
522	    }
523	}
524
525	#[cfg(test)]
526	mod tests {
527	    use super::*;
528
529	    /// A full harness manifest exercising every section (the worked
530	    /// `claude-spt` example from MANIFEST.md, trimmed to the parsed surface).
531	    const HARNESS_FIXTURE: &str = r#"
532	[adapter]
533	name = "claude-spt"
534	version = "2.0.0"
535	min_spt_core_version = "1.0.0"
536	hostable_types = ["LiveAgent", "ReadyAgent", "Psyche", "Worker"]
537
538	[hooks.SessionStart]
539	fires = "api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}"
540	reads = ["session_id", "source", "agent_type"]
541	can_inject = true
542
543	[hooks.Stop]
544	fires = "api state idle"
545	can_inject = false
546
547	[session]
548	commune_dir = ".claude"
549	signoff_dir = ".claude"
550
551	[session.psyche_init]
552	command = "claude -p --agent owl-psyche --name {session_name} --model sonnet"
553	cwd = "{psyche_dir}"
554	env_remove = ["OWL_SESSION_ID"]
555	recursion_guard_env = "SPT_ECHO_COMMUNE"
556	detach = true
557	keys = ["session_name", "psyche_dir"]
558
559	[env.OWL_SESSION_ID]
560	direction = "inject"
561	value = "{session_id}"
562	channel = "CLAUDE_ENV_FILE"
563
564	[history]
565	strategy = "fetcher"
566	fetcher = "claude-spt-history --session {session_id}"
567
568	[inject]
569	activity = ["hook"]
570	idle = ["pty", "hook"]
571
572	[identity]
573	session_id_source = "post_spawn"
574	parent_ancestor_name = "claude"
575
576	[update]
577	avenue = "delegated"
578	command = "claude plugin update spt"
579	version_check = true
580	"#;
581
582	    const SHELL_FIXTURE: &str = r#"
583	[adapter]
584	name = "GameRobot"
585	kind = "shell"
586	version = "1.0.0"
587	min_spt_core_version = "1.0.0"
588
589	[shell]
590	spawn = "gamerobot-shell --link {link_token}"
591	ephemeral = false
592	broadcast = "subnet"
593	command_receipt = "http"
594	persistent = true
595	can_shutdown = false
596
597	[shell.capabilities]
598	move = { args = ["direction", "distance"] }
599	screenshot = {}
600
601	[shell.sensory]
602	types = ["image", "sound", "event"]
603	"#;
604
605	    #[test] // [unit->REQ-MANIFEST-1]
606	    fn harness_fixture_parses_all_sections() {
607	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
608	        assert_eq!(m.adapter.name, "claude-spt");
609	        assert_eq!(m.adapter.kind, AdapterKind::Harness);
610	        assert_eq!(m.adapter.hostable_types.len(), 4);
611	        // hooks
612	        assert!(m.hooks["SessionStart"].can_inject);
613	        assert!(!m.hooks["Stop"].can_inject);
614	        assert_eq!(m.hooks["SessionStart"].reads, ["session_id", "source", "agent_type"]);
615	        // session: dirs + one role
616	        assert_eq!(m.session.commune_dir.as_deref(), Some(".claude"));
617	        let role = m.session.psyche_init.as_ref().unwrap();
618	        assert!(role.detach);
619	        assert_eq!(role.env_remove, ["OWL_SESSION_ID"]);
620	        assert_eq!(role.recursion_guard_env.as_deref(), Some("SPT_ECHO_COMMUNE"));
621	        assert_eq!(m.session.roles().len(), 1);
622	        // env
623	        assert_eq!(m.env["OWL_SESSION_ID"].direction, EnvDirection::Inject);
624	        // history / inject / identity / update enums
625	        assert_eq!(m.history.unwrap().strategy, HistoryStrategy::Fetcher);
626	        let inject = m.inject.unwrap();
627	        assert_eq!(inject.idle, [InjectMethod::Pty, InjectMethod::Hook]);
628	        assert_eq!(m.identity.unwrap().session_id_source, SessionIdSource::PostSpawn);
629	        assert_eq!(m.update.unwrap().avenue, UpdateAvenue::Delegated);
630	    }
631
632	    #[test] // [unit->REQ-MANIFEST-1]
633	    fn shell_fixture_parses() {
634	        let m = Manifest::from_toml_str(SHELL_FIXTURE).expect("parse");
635	        assert_eq!(m.adapter.kind, AdapterKind::Shell);
636	        let shell = m.shell.as_ref().unwrap();
637	        assert_eq!(shell.broadcast, Some(Broadcast::Subnet));
638	        assert_eq!(shell.command_receipt, Some(CommandReceipt::Http));
639	        assert_eq!(shell.capabilities["move"].args, ["direction", "distance"]);
640	        assert!(shell.capabilities["screenshot"].args.is_empty());
641	        assert_eq!(shell.sensory.types, ["image", "sound", "event"]);
642	    }
643
644	    #[test] // [unit->REQ-MANIFEST-1]
645	    fn round_trips_through_toml() {
646	        let m = Manifest::from_toml_str(HARNESS_FIXTURE).expect("parse");
647	        let serialized = m.to_toml_string().expect("serialize");
648	        let reparsed = Manifest::from_toml_str(&serialized).expect("re-parse");
649	        assert_eq!(m, reparsed, "manifest must survive a serialize→parse round-trip");
650	    }
651
652	    #[test] // [unit->REQ-MANIFEST-1]
653	    fn unknown_enum_variant_is_an_error_not_a_panic() {
654	        let bad = r#"
655	[adapter]
656	name = "x"
657	version = "1.0.0"
658	min_spt_core_version = "1.0.0"
659
660	[history]
661	strategy = "telepathy"
662	"#;
663	        let e = Manifest::from_toml_str(bad).unwrap_err();
664	        assert!(matches!(e, ManifestError::Parse(_)), "got {e}");
665	        // message names the offending value
666	        assert!(e.to_string().contains("telepathy") || e.to_string().contains("strategy"));
667	    }
668
669	    #[test] // [unit->REQ-MANIFEST-1]
670	    fn shell_kind_without_shell_section_fails_validation() {
671	        let bad = r#"
672	[adapter]
673	name = "x"
674	kind = "shell"
675	version = "1.0.0"
676	min_spt_core_version = "1.0.0"
677	"#;
678	        let e = Manifest::from_toml_str(bad).unwrap_err();
679	        assert!(matches!(e, ManifestError::Validation(_)));
680	        assert!(e.to_string().contains("[shell]"));
681	    }
682
683	    #[test] // [unit->REQ-MANIFEST-1]
684	    fn harness_kind_with_shell_section_fails_validation() {
685	        let bad = r#"
686	[adapter]
687	name = "x"
688	version = "1.0.0"
689	min_spt_core_version = "1.0.0"
690
691	[shell]
692	spawn = "x"
693	"#;
694	        let e = Manifest::from_toml_str(bad).unwrap_err();
695	        assert!(matches!(e, ManifestError::Validation(_)));
696	    }
697
698	    #[test] // [unit->REQ-MANIFEST-1]
699	    fn inject_env_without_value_fails_validation() {
700	        let bad = r#"
701	[adapter]
702	name = "x"
703	version = "1.0.0"
704	min_spt_core_version = "1.0.0"
705
706	[env.FOO]
707	direction = "inject"
708	"#;
709	        let e = Manifest::from_toml_str(bad).unwrap_err();
710	        assert!(e.to_string().contains("FOO"));
711	    }
712
713	    #[test] // [unit->REQ-MANIFEST-1]
714	    fn fetcher_strategy_requires_fetcher_field() {
715	        let bad = r#"
716	[adapter]
717	name = "x"
718	version = "1.0.0"
719	min_spt_core_version = "1.0.0"
720
721	[history]
722	strategy = "fetcher"
723	"#;
724	        let e = Manifest::from_toml_str(bad).unwrap_err();
725	        assert!(matches!(e, ManifestError::Validation(_)));
726	    }
727
728	    #[test] // [unit->REQ-MANIFEST-1]
729	    fn minimal_header_only_manifest_is_valid() {
730	        let m = Manifest::from_toml_str(
731	            "[adapter]\nname = \"min\"\nversion = \"1.0.0\"\nmin_spt_core_version = \"1.0.0\"\n",
732	        )
733	        .expect("parse");
734	        assert_eq!(m.adapter.name, "min");
735	        assert!(m.session.is_empty());
736	        assert!(m.hooks.is_empty());
737	    }
738
739	    #[test] // [unit->REQ-MANIFEST-1]
740	    fn syntactically_broken_toml_is_an_error_not_a_panic() {
741	        let e = Manifest::from_toml_str("this is not = = toml").unwrap_err();
742	        assert!(matches!(e, ManifestError::Parse(_)));
743	    }
744	}
745

//! 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,
    /// Optional inverse of install — run by `spt adapter remove` once the adapter
    /// is quiesced (the mirror of `spt adapter add`, which reuses this section as
    /// the install mechanism). Absent ⇒ spt-core's default cleanup. (Modeled in
    /// M2a; conducted with adapter-registration later.)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uninstall: Option<String>,
}

#[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,
    /// Per-spawn user approval gate (floor; a node/endpoint setting may tighten).
    /// Absent ⇒ `none`. (Modeled now; conducted when shells land.)
    #[serde(default, skip_serializing_if = "ShellApproval::is_default")]
    pub require_approval: ShellApproval,
    /// Optional ceiling on concurrent-existing instances per owner endpoint
    /// (online + offline both count). Absent ⇒ unlimited.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_instances_per_owner: Option<u32>,
    /// What happens at the cap: `reject` (default) or `approve` (per-spawn
    /// approval beyond the cap; does not raise it). Only meaningful with a cap.
    #[serde(default, skip_serializing_if = "OverCap::is_default")]
    pub over_cap: OverCap,
    /// `[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,
}

/// Per-shell instantiation-approval mode (`require_approval`). Reuses the consent
/// plumbing: `remembered` lets allow-always write a persistent grant; `always`
/// suppresses allow-always (prompt every spawn).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShellApproval {
    /// No approval (default — matches the system's everything-opt-in posture).
    #[default]
    None,
    /// Prompt; allow-always persists a grant, later spawns auto-allow.
    Remembered,
    /// Prompt on every spawn; allow-always suppressed (no persistent grant).
    Always,
}

impl ShellApproval {
    /// Whether this is the default (`none`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, ShellApproval::None)
    }
}

/// What happens when an owner is at its `max_instances_per_owner` cap.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OverCap {
    /// Refuse the spawn outright (default).
    #[default]
    Reject,
    /// Require per-spawn approval beyond the cap (does not raise the cap).
    Approve,
}

impl OverCap {
    /// Whether this is the default (`reject`) — for `skip_serializing_if`.
    pub fn is_default(&self) -> bool {
        matches!(self, OverCap::Reject)
    }
}

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

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


Now the CLI + brain lifecycle + R-TERM-3 substrate + REQ-TERM-4 registry. Read in parallel.