# M2a Plan — Harness contract (`spt-runtime` + `api` surface + mock adapter)

> **Just-in-time, lightweight** — same pattern as `M0-PLAN.md` / `M1-PLAN.md`. The
> ordered task layer between ROADMAP.md (milestone sequence) and
> `traceable-reqs.toml` (requirement checklist), scoped to **M2a only**. Honors
> ROADMAP §13 ("lightweight but structured, GSD too heavy"). Branch:
> `dev-freeform`.

> **M2 is split** (decision 2026-06-01): **M2a = the harness contract** (this
> plan); **M2b = the live-agent/Psyche lifecycle** (`spt-live`: Psyche spawn,
> pulse, commune/signoff ingest, echo-commune, history). M2b gets its own JIT
> plan when M2a lands.

## Goal

Make spt-core **drivable by a manifest-described harness**, with zero Claude
Code (or any harness) conventions in-repo. Two halves of the contract
(CONTEXT "harness contract"):

- **Outbound** — `spt-runtime`: parse a runtime manifest, and execute its opaque
  command templates via the `AgentRuntime` trait + the default `ManifestRuntime`
  (substitution-key fill + spawn).
- **Inbound** — the `api` subcommand surface: the `spt api <cmd>` entry points a
  harness's hooks fire to mutate on-disk SPT state (bind/seed/listen/poll/state/
  worker/boundary/session-end/…), each carrying `adapter_name`.

Proven by a **generic mock adapter** (a manifest fixture + trivial cross-platform
scripts) that exercises the whole contract end-to-end — no `claude`, no `.claude`,
no hooks-as-CC.

**M2a done =** `spt-runtime` compiles + the `spt api` surface ships · `cargo test
--workspace` green · an E2E proves: load mock manifest → `ManifestRuntime` spawns
a session → that session fires `api bind` → `api state idle` → `api poll` delivers
a spooled message — all harness-agnostic · `traceable-reqs check` green with M2a
reqs *activated* · CI stays green.

## Scope

**In:** runtime-manifest schema (all sections) + parse + validate; `AgentRuntime`
trait + `ManifestRuntime` (opaque-template substitution + spawn-session seam); the
inbound `api` surface as direct on-disk mutations — `bind`, `seed`, `listen`,
`poll`, `state`, `echo-gate`, `worker-start/stop/poll`, `boundary`, `session-end`,
`presence`, `history-log` (Path-B native store), `emit` (Shell stub); the
**harness-hosted** startup topology (seed→listen→bind, interim seed-**file**);
`adapter_name` on every `api` call + the local-api-auth guard; capability
declaration (`hostable_types`); env-alias supply (`$SPT`/`$OWL`/`$LIVE`); a mock
adapter + contract E2E.

**Out:**
- **The lifecycle (M2b):** Psyche spawn-psyche seam, pulse, commune/signoff
  drop-file *ingest*, echo-commune, the history *source* for echo, resume-session
  seam. M2a only **declares** these manifest sections + watched dirs and **parses**
  them — it does not execute them.
- **spt-hosted topology + PTY inject (M3):** the daemon-launches-into-broker-PTY
  path (`REQ-START-3`) needs the `spt-term` broker; M2a builds only the `api bind`
  path that spt-hosted will reuse, plus hook/relay inject (PTY inject = M3).
- **The consolidated `spt-daemon` (M3):** see interim note below.
- **Adapter ripple-update *execution* (M3 self-update):** M2a parses `[update]`;
  conducting updates is M3.
- **Cross-node / Shell concrete kinds (M4/M5).**

**Interim-model note (no daemon — decision 2026-06-01).** CONTEXT describes the
lifecycle as daemon-centric ("the always-on daemon holds the perch… daemon-spawns
the Psyche"). The consolidated `spt-daemon` is **M3**. So M2a mirrors M1's
standalone model: the long-running **`api listen`** process holds the perch +
relays (heir to `$LIVE start`'s Monitor-bound poll loop), and the **seed is an
ephemeral file** (keyed by `parent_pid`, validated against `session_id`) rather
than the daemon's in-memory record. M3 swaps the daemon in: in-memory seed,
daemon-held perches, daemon-spawned Psyche. **Keep the `api`/listen surface thin
so M3 is a move, not a rewrite** (same discipline as M1's listener).

## Clean-room posture (validated against the sister)

The sister lifecycle is **extremely Claude-Code-coupled** (`claude -p`, the 7 CC
hooks, `.claude/`, the wrapper owning a `claude` subprocess). For a
harness-agnostic core almost none of it copies. Copy-verbatim only the
harness-orthogonal **mechanism**; clean-room the rest behind the manifest:

- **Copy (mechanism, ADR-0001):** the CLI dispatch *shape* (`cli.rs` subcommand
  enum/dispatch), sentinel-file mechanics (`.idle`/echo-gate), `sessions.log` row
  emit, parent-pid resolution, wrapper-state handoff format — adapted to the
  spt-store API like M1.
- **Clean-room (becomes the manifest/`AgentRuntime` mechanism):** everything that
  references `claude -p`, CC hooks, `.claude`, or session-binding-via-CC. The
  CC-specific command lines become **the mock adapter's opaque templates**, never
  spt-core code.

## Sequencing rationale

Schema before runtime (the runtime executes the parsed manifest); runtime before
the `api` surface (bind/listen need the perch + delivery primitives already in
M1, plus the runtime for spawn); the `api` surface before the mock E2E (the E2E
drives it). Within `api`: skeleton/dispatch → bind/seed/listen (startup) →
state/poll/inject (delivery) → worker/boundary/session-end (perch lifecycle) →
presence/history-log/emit/capability (reporting). Then the mock adapter + E2E,
then the activation sweep. Each task tags evidence + activates its reqs in the
same commit; `check` green before the next.

## New requirements to register first (TRACEABILITY rule 3)

**None.** Every M2a requirement is already registered in `traceable-reqs.toml`
(`REQ-MANIFEST-1`, `REQ-API-1/2/3`, `REQ-START-1/2/4`, `REQ-SEAM-*`) — they land
inactive and activate per task (rule 5).

## Tasks — `spt-runtime` (outbound contract)

| # | Task | Source | Reqs | Acceptance |
|---|------|--------|------|------------|
| T0 | Scaffold `spt-runtime` crate (members + deps: spt-proto/store/msg, serde, toml); activation-prep | new | — | crate compiles; `check` green |
| T1 | Manifest schema: typed structs for every `docs/MANIFEST.md` section (`[adapter]`, `[hooks.*]`, `[session.*]`, `[env]`, `[history]`, `[inject]`, `[session]` dirs, `[identity]`, `[update]`, Shell body) + parse + validate (helpful errors) | clean-room; schema = `docs/MANIFEST.md` | REQ-MANIFEST-1 | a fixture manifest round-trips; an invalid manifest yields a clear error, not a panic |
| T2 | `AgentRuntime` trait + `ManifestRuntime`: substitution-key catalog + opaque-template fill + **spawn-session** seam (spawn a process from a role template with `cwd`/env/detach) | clean-room (sister `start.rs` spawn mechanics) | REQ-SEAM-SPAWN; REQ-HAZARD-SUBPROCESS-TIMEOUT | given a manifest + keys, the runtime spawns the templated process; missing keys error before spawn; spawns carry a timeout |

## Tasks — `api` inbound surface (in the `spt` binary, over `spt-runtime`)

| # | Task | Source | Reqs / hazards | Acceptance |
|---|------|--------|----------------|------------|
| T3 | `spt api` dispatch group: `--manifest`/`--adapter` resolution, `adapter_name` on every call, exit codes; **local-api-auth** (each mutation authenticated to an endpoint/session) | adapt `cli.rs`; CONTEXT "Inbound api surface" | REQ-API-1; REQ-HAZARD-LOCAL-API-AUTH | `spt api --help` lists the surface; a call without a resolvable endpoint/auth is refused non-zero |
| T4 | Harness-hosted startup: `api seed` (interim seed-**file** by `parent_pid`) + `api listen <id>` (consume seed, validate `session_id` vs pid-recycle, bind perch skeleton→live, relay loop) + `api bind` (post-spawn) | clean-room; CONTEXT "Startup flows" | REQ-START-1, REQ-START-2, REQ-SEAM-POSTSPAWN, REQ-API-2; REQ-HAZARD-WINDOWS-PID-RECYCLE | seed→listen binds a live perch reachable by id; a stale/mismatched seed never binds; listen relays a delivered message |
| T5 | Delivery + activity: `api state <busy\|idle>` (+ echo-gate sentinel, `--no-gate`, `api echo-gate set/clear`) + `api poll [--include-deferred]` (hook delivery drain) + inject-method selection (hook/relay; PTY=M3) | sister sentinels + M1 spool | REQ-SEAM-ACTIVITY, REQ-SEAM-INJECT, REQ-API-2 | `api state idle` writes the perch sentinels; `api poll` drains non-deferred (deferred only with the flag) |
| T6 | Worker perches: `api worker-start`/`worker-stop` (nested create/teardown under parent) + `api worker-poll` + **cascade-wipe guard** (no hard-delete of a parent hosting non-empty children) | sister subagent hooks (clean-room) | REQ-API-2; REQ-HAZARD-CASCADE-WIPE-GUARD | a worker perch nests under its parent + is addressable; stopping a parent with live workers is refused/soft |
| T7 | Reporting + boundary: `api boundary <clear\|compact>` (rebind to new `session_id`, keep identity+parent_pid) + `api session-end` (soft teardown default; `--erase`) + `api presence` + `api history-log` (Path-B native store) + `api emit` (Shell stub) + capability (`hostable_types`) + env-alias supply | CONTEXT api surface; `docs/STORAGE.md` | REQ-API-2, REQ-API-3, REQ-START-4, REQ-SEAM-CAPABILITY; REQ-HAZARD-SOFT-CLEANUP | boundary rebinds without losing the perch; session-end preserves spool/history by default, `--erase` wipes; `hostable_types` is queryable |

## Tasks — mock adapter + integration + infra

| # | Task | Reqs | Acceptance |
|---|------|------|------------|
| T8 | **Generic mock adapter** (`adapters/mock/manifest.toml` + trivial cross-platform session scripts/helper bin) — zero CC conventions; declares `[session.self]`, `[hooks.*]`, watched dirs, capability | REQ-MANIFEST-1, REQ-API-3 | the mock manifest parses + validates; its templates spawn the helper |
| T9 | **Contract E2E:** load mock manifest → `ManifestRuntime` spawns a session → session fires `api bind` → `api state idle` → `api poll` delivers a spooled message; assert the round-trip | REQ-API-1/2 (`int`), REQ-START-2 (`int`) | a `tests/` integration test proves the inbound+outbound contract end-to-end via the mock adapter |
| T10 | Activation sweep: activate M2a reqs + hazards, activate `REQ-ARCH-2` (SDK surface gains `spt-runtime`), green `check`, CI green; amend ROADMAP/CONTEXT for the split | — | `check` green with all M2a reqs activated; CI matrix + gate pass |

## M2a requirement-activation map

Activate as each task lands (default `["impl","unit"]`; startup + api delivery add
`int` at T9):

- **Manifest/runtime:** REQ-MANIFEST-1 (`impl`+`unit`, T1), REQ-SEAM-SPAWN
  (`impl`+`unit`, T2), REQ-SEAM-CAPABILITY (`impl`+`unit`, T7).
- **api surface:** REQ-API-1 (`impl`+`unit`, T3), REQ-API-2 (`impl`+`unit`+`int`,
  T9), REQ-API-3 (`impl`+`unit`, T7 — commune/signoff *declared* as file-drops).
- **Startup:** REQ-START-1 (`impl`+`unit`, T4), REQ-START-2 (`impl`+`unit`+`int`,
  T9), REQ-START-4 (`impl`+`unit`, T7), REQ-SEAM-POSTSPAWN (`impl`+`unit`, T4).
- **Delivery/activity:** REQ-SEAM-ACTIVITY (`impl`+`unit`, T5), REQ-SEAM-INJECT
  (`impl`+`unit`, T5 — hook/relay; PTY method stays partial until M3).
- **Hazards:** REQ-HAZARD-LOCAL-API-AUTH (T3), REQ-HAZARD-CASCADE-WIPE-GUARD (T6),
  REQ-HAZARD-SUBPROCESS-TIMEOUT (T2). REQ-HAZARD-WINDOWS-PID-RECYCLE +
  REQ-HAZARD-SOFT-CLEANUP already active (M1) — reused, no change.
- **Architecture:** REQ-ARCH-2 (`impl`, T10) — the public SDK surface now includes
  `spt-runtime`. R-ARCH-1 gains a fourth crate (build-evidenced; no stage change).

**Stay `[]` (M2b):** REQ-SEAM-PSYCHE, REQ-SEAM-RESUME, REQ-SEAM-HISTORY (echo
source). **Stay `[]` (M3):** REQ-START-3 (spt-hosted), REQ-SEAM-UPDATE execution,
REQ-DAEMON-*, REQ-HAZARD-CONPTY-DSR, REQ-HAZARD-RESTART-IDEMPOTENT,
REQ-HAZARD-DROP-FILE-SINGLE-WRITER (enforced at M2b ingest / M3 daemon). **Stay
`[]` (M4/M5):** all R-NET/R-PAIR, REQ-EP-4 (PresenceChannel impl).

## Workspace change

Add `crates/spt-runtime` to `members`. Layering stays acyclic (R-ARCH-1):
`spt-proto → spt-store → spt-msg → spt-runtime`; the `spt` binary (top consumer)
gains an `api` module dispatching into `spt-runtime`.

## Risks carried into M2a

- **Interim seed-file vs daemon in-memory.** The seed file is throwaway glue M3
  replaces. Keep it behind a single small module so the M3 swap to in-memory is
  localized (mirror M1's `resolve_address` stale-clean discipline).
- **`api listen` is the interim long-running owner.** Same risk as M1's standalone
  listener — keep the surface thin; M3's `spt-daemon` consolidates.
- **Manifest schema drift vs `docs/MANIFEST.md`.** The doc is the contract;
  generation is CI-gated against drift (DOCS-STRATEGY). Tag `[doc->REQ-MANIFEST-1]`
  on the schema doc section and keep struct ↔ doc in lockstep.
- **Local-api-auth scope.** M2a authenticates each mutation to a local
  endpoint/session; the cross-node authz surface is M4. Don't over-build it now —
  the codex #13 hazard is the *local* mutation guard.
- **No architecture risk.** The FATALs were the daemon (ADR-0004, M3) and pairing
  (ADR-0005, M4). M2a is the harness contract on the proven M0/M1 substrate, in
  the same no-daemon interim model M1 validated.
