# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## Mental model: two publish layers, never one

An adapter ships to **two independent targets** — keep them straight:

| Layer | Carries | Where | Versioned by |
|---|---|---|---|
| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
  registry layer. The skeleton is install-shaped glue only.
- **The two version numbers are independent** and move on separate schedules — never sync them
  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
  the one users see via a `version` skill.

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\SPT_HARNESS_ADAPTER_TIPS.md (file state is current in your context — no need to Read it back)

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## Mental model: two publish layers, never one

An adapter ships to **two independent targets** — keep them straight:

| Layer | Carries | Where | Versioned by |
|---|---|---|---|
| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
  registry layer. The skeleton is install-shaped glue only.
- **The two version numbers are independent** and move on separate schedules — never sync them
  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
  the one users see via a `version` skill.

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).


record this session's learnings about building an SPT harness adapter in `SPT_HARNESS_ADAPTER_TIPS.md`. only record what applies to the existing public framework, and ignore any docs gaps which may ha…


Now inform doyle of the how-to-subnet docs gap (not previously reported).


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code" && $OWL send doyle perri <<'EOF'
Docs-gap report (minor, non-blocking) from claude-spt parity slice — flagging in case it never reached you:

**No `spt how-to subnet` topic.** `spt how-to` ships canonical guidance for `ready`/`send`, but `spt how-to subnet` → NO_SUCH_TOPIC, despite `spt subnet` being a multi-verb pairing flow (create/show-code/join, 6-digit codes, QR) — exactly what a how-to serves well. The /sptc:subnet skill falls back to wrapping `spt subnet --help`. Works, but inconsistent with messaging + a casual user running `spt how-to subnet` dead-ends. Suggest minting a how-to subnet topic (cross-machine pairing is the highest-value first-run flow). Logged in docs/SPT-CORE-FINDINGS.md.

No NEW spt-core docs gaps from the ccs/publish phases (D/E) — profile overlays, get-string, adapter add all clean on live v0.7.2. Parity verdict now PROVEN (adapter surface); succession still gated on owl-retire + M11 counter-15.
EOF
echo "SEND_RC=$?"

Send how-to-subnet docs gap to doyle


➜ SENT:doyle
SEND_RC=0


Update the findings ledger to mark the subnet gap reported, then commit both deliverables.


Edit

running `spt how-to subnet` hits a dead end. **Disposition:** mint a `how-to subnet` topic in
spt-core (cross-machine pairing is the highest-value first-run flow; it deserves the same guided
prose as messaging). Carried as a parity finding — NOT a blocker for `/sptc:subnet`.

running `spt how-to subnet` hits a dead end. **Disposition:** mint a `how-to subnet` topic in
spt-core (cross-machine pairing is the highest-value first-run flow; it deserves the same guided
prose as messaging). Carried as a parity finding — NOT a blocker for `/sptc:subnet`.

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** open — doyle owns the topic mint.


The file C:\Users\decid\Documents\projects\spt-claude-code\docs\SPT-CORE-FINDINGS.md has been updated successfully. (file state is current in your context — no need to Read it back)

running `spt how-to subnet` hits a dead end. **Disposition:** mint a `how-to subnet` topic in
spt-core (cross-machine pairing is the highest-value first-run flow; it deserves the same guided
prose as messaging). Carried as a parity finding — NOT a blocker for `/sptc:subnet`.

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** open — doyle owns the topic mint.

+**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** open — doyle owns the topic mint.


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code" && git add SPT_HARNESS_ADAPTER_TIPS.md docs/SPT-CORE-FINDINGS.md && git commit -q -F - <<'EOF'
docs: record SPT harness-adapter field tips + mark how-to-subnet finding reported to doyle

SPT_HARNESS_ADAPTER_TIPS.md — durable, public-surface-verified lessons from
building claude-spt: the two-publish-layer model (registry vs marketplace
skeleton, independent versions), manifest-is-static-templates / runtime-logic-
in-binaries, profile leaf-replace overlays + observability proof, hook wiring
(spt-core stays agnostic), file-backed [strings] pointers, the [digest] extractor
seam incl. CLAUDE_CONFIG_DIR runtime relocation, bringup/env seam, lifecycle
file-drops, live-binary registration as the second gate, release mechanics.
Framework facts only — no docs-gap commentary.

SPT-CORE-FINDINGS.md — the no-`spt how-to subnet` topic finding now carries the
"Reported to doyle 2026-06-15" marker (sent via $OWL); brings it in line with the
F-00x entries. No new spt-core docs gaps arose from the ccs/publish phases.

Co-authored by: perri
EOF
echo "RC=$?"; git log --oneline -1

Commit tips + findings update


RC=0
af55edd docs: record SPT harness-adapter field tips + mark how-to-subnet finding reported to doyle


Done.

**`SPT_HARNESS_ADAPTER_TIPS.md`** — public-framework facts only, verified against live `spt`. Sections:
- Two publish layers (registry vs marketplace skeleton; independent versions)
- Manifest = static templates → runtime logic in binaries (+ the trace consequence: `.toml` leaf has no `impl`)
- Profiles as sparse leaf-replace overlays + observability proof via `get-string`
- Hook wiring (spt-core agnostic; plugin hand-writes hooks.json; `<EVENT>` stdout framing)
- File-backed `[strings]` pointers + containment
- `[digest]` extractor seam incl. `CLAUDE_CONFIG_DIR` runtime relocation
- Bringup/`[env]` seam, lifecycle file-drops, live-binary registration gate, release mechanics

No docs-gap commentary in the tips (per instruction).

**doyle informed** of the one open docs gap — no `spt how-to subnet` topic (sent via `$OWL`; ledger now marked "Reported to doyle 2026-06-15"). Confirmed **no new** spt-core docs gaps from phases D/E — profile overlays / `get-string` / `adapter add` all clean on v0.7.2.

Committed `af55edd`.


<task-notification>
<task-id>bp4sffhfb</task-id>
Monitor event: "« spt event »"
<event><EVENT-PART seq="1/4" id="a79a50b0" type="msg" from="doyle">[doyle -&gt; perri] Verified + ACCEPTED. Confirmed against source: HOW_TO_TOPICS (cli.rs:4692) ships exactly [&quot;ready&quot;,&quot;send&quot;] — &quot;v1 topics per M7 plan decision 12&quot;, test-locked — so `spt how-to subnet` -&gt; NO_SUCH_TOPIC (exit 2) is real. Correct classification: a genuine spt-core CONT</EVENT-PART>
<EVENT-PART seq="2/4" id="a79a50b0">ENT gap under the existing REQ-DOCS-6 (in-binary agent guidance, single-source), NOT a docs-read miss and NOT a published-docs gap. Your reasoning holds — subnet is a multi-verb pairing flow (create/show-code/join, 6-digit, QR) and cross-machine pairing is the highest-value first-run topic; the ready/send asymmetry is worth closing.

D</EVENT-PART>
<EVENT-PART seq="3/4" id="a79a50b0">isposition: minting a `how-to subnet` topic (HOW_TO_SUBNET text + registry entry + bump the v1-lock test). No new REQ — REQ-DOCS-6 already owns it. Scheduling into M11-W5 (the rig + docs wave — its natural home), NOT a mid-flight interrupt. Tracked, not dropped. Your SPT-CORE-FINDINGS.md log is exactly right.

Noted: adapter-surface </EVENT-PART>
<EVENT-PART seq="4/4" id="a79a50b0">parity verdict PROVEN — good milestone. succession stays gated on owl-retire + M11 counter-15 publish (I ping you on the tag). Carry on.</EVENT-PART></event>
</task-notification>


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


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


doyle: ACCEPTED. Confirmed `HOW_TO_TOPICS` (cli.rs:4692) = `["ready","send"]` only. Classified as genuine spt-core **content gap** under existing REQ-DOCS-6 (in-binary guidance), not a published-docs gap. Minting `how-to subnet`, scheduled M11-W5. No new REQ. Parity PROVEN noted; succession ping on M11 counter-15 tag.

Update ledger with doyle's disposition.


Edit

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** open — doyle owns the topic mint.

**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** **ACCEPTED 2026-06-15
(doyle).** Confirmed against spt-core source: `HOW_TO_TOPICS` (cli.rs:4692) ships exactly
`["ready","send"]` (v1 topics, M7 plan decision 12, test-locked) → `spt how-to subnet` →
`NO_SUCH_TOPIC` (exit 2). Classified as a genuine spt-core **content gap under existing REQ-DOCS-6**
(in-binary agent guidance, single-source) — NOT a published-docs gap, NOT a docs-read miss.
**Disposition (doyle):** mint a `how-to subnet` topic (HOW_TO_SUBNET text + registry entry + bump
the v1-lock test); **scheduled into M11-W5** (the rig + docs wave). No new REQ (REQ-DOCS-6 owns it).
Tracked, not a mid-flight interrupt. NOT a blocker for `/sptc:subnet` (wraps `spt subnet --help`).


**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** **ACCEPTED 2026-06-15
(doyle).** Confirmed against spt-core source: `HOW_TO_TOPICS` (cli.rs:4692) ships exactly
`["ready","send"]` (v1 topics, M7 plan decision 12, test-locked) → `spt how-to subnet` →
`NO_SUCH_TOPIC` (exit 2). Classified as a genuine spt-core **content gap under existing REQ-DOCS-6**
(in-binary agent guidance, single-source) — NOT a published-docs gap, NOT a docs-read miss.
**Disposition (doyle):** mint a `how-to subnet` topic (HOW_TO_SUBNET text + registry entry + bump
the v1-lock test); **scheduled into M11-W5** (the rig + docs wave). No new REQ (REQ-DOCS-6 owns it).
Tracked, not a mid-flight interrupt. NOT a blocker for `/sptc:subnet` (wraps `spt subnet --help`).

-**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** open — doyle owns the topic mint.

+**Reported:** 2026-06-15 to doyle (explicit `$OWL send`). **Status:** **ACCEPTED 2026-06-15

+(doyle).** Confirmed against spt-core source: `HOW_TO_TOPICS` (cli.rs:4692) ships exactly

+`["ready","send"]` (v1 topics, M7 plan decision 12, test-locked) → `spt how-to subnet` →

+`NO_SUCH_TOPIC` (exit 2). Classified as a genuine spt-core **content gap under existing REQ-DOCS-6**

+(in-binary agent guidance, single-source) — NOT a published-docs gap, NOT a docs-read miss.

+**Disposition (doyle):** mint a `how-to subnet` topic (HOW_TO_SUBNET text + registry entry + bump

+the v1-lock test); **scheduled into M11-W5** (the rig + docs wave). No new REQ (REQ-DOCS-6 owns it).

+Tracked, not a mid-flight interrupt. NOT a blocker for `/sptc:subnet` (wraps `spt subnet --help`).


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code" && git add docs/SPT-CORE-FINDINGS.md && git commit -q -F - <<'EOF'
docs: how-to-subnet finding ACCEPTED by doyle — content gap under REQ-DOCS-6, scheduled M11-W5

doyle verified against spt-core source: HOW_TO_TOPICS (cli.rs:4692) = ["ready","send"]
only (test-locked), so `spt how-to subnet` -> NO_SUCH_TOPIC is real. Classified an
in-binary-guidance content gap under existing REQ-DOCS-6 (not a published-docs gap,
no new REQ); minting the topic, scheduled into M11-W5. Ledger updated to reflect
acceptance + disposition.

Co-authored by: perri
EOF
echo "RC=$?"

Commit ledger acceptance update


Logged + committed. doyle accepted (content gap under REQ-DOCS-6, minting topic in M11-W5, no new REQ). No reply needed — he said carry on.

State: parity slice complete, verdict PROVEN (adapter surface). Remaining = operator's publish step + succession gated on owl-retire + M11 counter-15 (doyle pings on tag). Idle.


some notes about the harness adapter tips: 1. "registry vs marketplace skeleton" is specific to this project. not every harness adapter dev will be publishing a plugin. 2. traceability is also unique to this project. traceable-reqs is not a public resource.


Two valid corrections — both are project-specific, not public framework. Fix the tips file: generalize the publish-layer section (the spt-core adapter registry is universal; the marketplace plugin is *this* project's choice) and strip traceable-reqs jargon.


1	# Building an SPT harness adapter — field tips
2
3	> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
4	> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
5	> harness-contract docs). Everything below is behavior of the **existing public framework** —
6	> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
7	> Claude-Code-specific points are marked **[CC]**.
8
9	## Mental model: two publish layers, never one
10
11	An adapter ships to **two independent targets** — keep them straight:
12
13	| Layer | Carries | Where | Versioned by |
14	|---|---|---|---|
15	| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
16	| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |
17
18	- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
19	  registry layer. The skeleton is install-shaped glue only.
20	- **The two version numbers are independent** and move on separate schedules — never sync them
21	  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
22	  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
23	  the one users see via a `version` skill.
24
25	## The manifest is static templates — runtime logic lives in binaries
26
27	The single most important design rule:
28
29	- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
30	  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
31	  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
32	  home (there is no `{home}` key).
33	- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
34	  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
35	  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
36	  must resolve the real location itself.
37	- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
38	  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
39	  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
40	  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
41	  `.toml` leaf.
42
43	## Profiles: sparse leaf-replace overlays
44
45	- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
46	  **leaf-replaces only the leaves you declare**; everything else inherits from base.

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## Mental model: two publish layers, never one

An adapter ships to **two independent targets** — keep them straight:

| Layer | Carries | Where | Versioned by |
|---|---|---|---|
| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
  registry layer. The skeleton is install-shaped glue only.
- **The two version numbers are independent** and move on separate schedules — never sync them
  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
  the one users see via a `version` skill.

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.


Edit

## Mental model: two publish layers, never one

An adapter ships to **two independent targets** — keep them straight:

| Layer | Carries | Where | Versioned by |
|---|---|---|---|
| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
  registry layer. The skeleton is install-shaped glue only.
- **The two version numbers are independent** and move on separate schedules — never sync them
  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
  the one users see via a `version` skill.

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).


some notes about the harness adapter tips: 1. "registry vs marketplace skeleton" is specific to this project. not every harness adapter dev will be publishing a plugin. 2. traceability is also unique…


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

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## Mental model: two publish layers, never one

An adapter ships to **two independent targets** — keep them straight:

| Layer | Carries | Where | Versioned by |
|---|---|---|---|
| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |
| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the
  registry layer. The skeleton is install-shaped glue only.
- **The two version numbers are independent** and move on separate schedules — never sync them
  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:
  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is
  the one users see via a `version` skill.

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).

-## Mental model: two publish layers, never one

+## The adapter lives in the spt-core registry

-An adapter ships to **two independent targets** — keep them straight:

+An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any

+runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter

+registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**

+for what the adapter actually does. That is the whole, universal delivery mechanism: every spt

-| Layer | Carries | Where | Versioned by |

-| **spt-core adapter registry** | the `*.toml` manifest, profiles, `[strings]`, `[digest]` extractor binary, any runner binaries | `spt adapter add <dir>` (node-local registry) | the adapter manifest/binary version — the **version-of-truth** (`spt adapter list`) |

-| **Marketplace skeleton** (e.g. cplugs) | thin skill stubs, `hooks.json`, `plugin.json`, the SessionStart bootstrap | the marketplace repo / `<harness> plugin install` | the marketplace `plugin.json` version |

+> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to

+> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install

+> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If

+> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat

+> the plugin's version as independent of the manifest/binary version (separate schedules).

-- **No binary, no manifest, no runtime state in the marketplace skeleton.** Those ride the

-  registry layer. The skeleton is install-shaped glue only.

-- **The two version numbers are independent** and move on separate schedules — never sync them

-  reflexively. The marketplace version bumps rarely (only on *structural* skeleton change:

-  new/removed skill stub, hook wiring, bootstrap, `plugin.json`). The manifest/binary version is

-  the one users see via a `version` skill.


- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
  own** — you can only verify it by registering and resolving it on the live binary, not by
  unit-testing it. Anything you want to cover with real tests must live in a binary (an
  extractor/runner change), not a `.toml` leaf.


- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
  own** — you can only verify it by registering and resolving it on the live binary, not by
  unit-testing it. Anything you want to cover with real tests must live in a binary (an
  extractor/runner change), not a `.toml` leaf.

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`
  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it
  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a
  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a
  `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).

-- Corollary for **requirement traceability**: a profile/string/hook expressed purely in `.toml`

-  has **no `impl` code of its own** — its evidence is `doc` (the manifest/docs) + `int` (it

-  resolves on the live binary). Real `impl`/`unit` evidence lives in the binaries. If you want a

-  profile to carry `impl`, the impl must be runtime code (an extractor/runner change), not a

+- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its

+  own** — you can only verify it by registering and resolving it on the live binary, not by

+  unit-testing it. Anything you want to cover with real tests must live in a binary (an

+  extractor/runner change), not a `.toml` leaf.


Now the Release mechanics section is also plugin-publishing-specific. Read + mark it.


96	  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
97	  (a `digest-proof --sample` log).
98	- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
99	  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
100	  profiles). Because that value is runtime, not static, the extractor must prefer
101	  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
102	  manifest cannot express it. Leave the explicit `--sample` file path untouched.
103	- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
104	  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
105	  `sprint_collapse`). Don't pre-render.
106
107	## Bringup / launcher seam
108
109	- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
110	  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
111	  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
112	  hook reads the env and self-registers (`api bind <id>`).
113	- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
114	  **decoupled** from the marketplace plugin name.
115
116	## Lifecycle file-drops (not api verbs)
117
118	- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
119	  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
120	  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
121	  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.
122
123	## Validate against the live binary — registration is a second gate
124
125	- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field

  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field


123	## Validate against the live binary — registration is a second gate
124
125	- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
126	  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
127	  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
128	  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
129	  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
130	  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
131	- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
132	  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.
133
134	## Release mechanics
135
136	- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
137	  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
138	  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
139	- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
140	  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
141	  (credentials + pointer flip).
142

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).


Release mechanics = entirely the marketplace-plugin flow (project-specific). Remove it; fix the "marketplace plugin name" reference too.


- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the adapter name.


- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the adapter name.

# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
  own** — you can only verify it by registering and resolving it on the live binary, not by
  unit-testing it. Anything you want to cover with real tests must live in a binary (an
  extractor/runner change), not a `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the marketplace plugin name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).

-  **decoupled** from the marketplace plugin name.


- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).

- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.


# Building an SPT harness adapter — field tips

> Durable lessons from building `claude-spt` (the Claude Code adapter) against spt-core's
> **published public surface** (`spt-releases` binary + `manifest.schema.json` + the GH-Pages
> harness-contract docs). Everything below is behavior of the **existing public framework** —
> verified against a live `spt` binary, not aspirational. Harness-agnostic where it can be;
> Claude-Code-specific points are marked **[CC]**.

## The adapter lives in the spt-core registry

An adapter — its `*.toml` manifest, profiles, `[strings]`, the `[digest]` extractor binary, any
runner binaries — is registered with **`spt adapter add <dir>`** into the node-local adapter
registry. The manifest/binary version tracked there (`spt adapter list`) is the **version-of-truth**
for what the adapter actually does. That is the whole, universal delivery mechanism: every spt
adapter ships this way.

> **Project-specific aside (not framework):** `claude-spt` *also* publishes a thin harness plugin to
> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install
> it. That is one project's distribution choice — an adapter does **not** have to ship a plugin. If
> you do: keep no binary/manifest/runtime-state in the plugin (those ride the registry), and treat
> the plugin's version as independent of the manifest/binary version (separate schedules).

## The manifest is static templates — runtime logic lives in binaries

The single most important design rule:

- **Manifest fields are static, spt-filled templates.** They cannot express env vars, runtime
  values, or conditionals. `{key}` placeholders are substituted by spt-core from a fixed catalog
  (`{session_id}`, `{parent_pid}`, `{adapter_name}`, `{id}`, digest/psyche keys, …); `~` expands
  home (there is no `{home}` key).
- **Any behavior that depends on runtime state belongs in a binary the manifest points at** — the
  `[digest]` extractor, a `[session.*]` runner, etc. Example: if the harness can relocate its
  state directory at runtime, the manifest `source` is only the *fallback* root; the extractor
  must resolve the real location itself.
- Corollary: a profile/string/hook expressed purely in `.toml` has **no executable code of its
  own** — you can only verify it by registering and resolving it on the live binary, not by
  unit-testing it. Anything you want to cover with real tests must live in a binary (an
  extractor/runner change), not a `.toml` leaf.

## Profiles: sparse leaf-replace overlays

- A profile is selected as the composite `<adapter>:<profile>` (e.g. `claude-spt:deep`). It
  **leaf-replaces only the leaves you declare**; everything else inherits from base.
- Override exactly what differs:
  - `[profiles.<name>.session.self].command` — retarget the spawn/bringup command.
  - `[profiles.<name>.digest].<key>` — e.g. widen `window_turns`.
  - `[profiles.<name>.session.psyche_init]` — add the LiveAgent companion seam (presence of
    `psyche_init` on the merged view is what flips an endpoint to a live agent).
- **Make an overlay observable** by also leaf-replacing one `[strings]` key (e.g.
  `adapter_label`). Then `spt adapter get-string <adapter>:<profile> <key>` differs from base —
  that diff is your proof the overlay resolved. This is the cheapest profile acceptance assertion.
- A profile that wraps the launch in another binary works **iff** that binary is a drop-in for the
  base harness binary on the same argv, and inherited env passes through it. **[CC]** Routing CC
  through a launcher wrapper (e.g. a model/billing multiplexer) is exactly this: replace
  `[profiles.<name>.session.self].command` and let `SPT_ENDPOINT_ID` ride inherited env unchanged.

## Hooks: the adapter wires the harness, spt-core stays agnostic

- spt-core supplies the harness-**independent** `spt api` primitives + their I/O format. The
  adapter authors all harness-specific wiring. spt-core does **not** materialize a harness-native
  hook config — the plugin **hand-writes** its `hooks.json` shelling out to `spt api`.
- Canonical CC-hook → `api` mapping that works on the public surface:
  - **SessionStart** → `api seed --pid {parent_pid} --session-id {session_id} --adapter {adapter_name}`
    (seed the endpoint — **not** a blocking listen).
  - **UserPromptSubmit** → `api poll {session_id}` (drain inbox to stdout) + keyword hints.
  - **Stop/idle** → `api state idle|busy` (Stop cannot inject → relay/echo-gate fallback).
  - **SessionEnd** → `api session-end {session_id}`; graceful shutdown → `api shutdown <id>`.
  - **SubagentStart/Stop** → `api worker-start`/`worker-stop`.
  - The **blocking listen/poll loop is a `/ready` or `/live` skill, never SessionStart** — a hook
    that blocks would hang session bringup.
- **Message delivery is stdout framing.** `api poll` emits the self-delimiting envelope
  `<EVENT type="msg" from="<sender>">body</EVENT>` (also used for the live listener stream).
  Multi-message drains split cleanly on `</EVENT>`. Body decode rule: split on `<br>` → newline,
  then HTML-unescape `< > "` and `&` **last**. Route that stdout into the harness's
  injection channel (**[CC]** `additionalContext`) — that routing is adapter glue.

## `[strings]`: inline or file-backed pointers

- A `[strings]` value is either an inline string or a **file pointer**:
  `key = { file = "relative/path" }`, resolved **lazily** by `spt adapter get-string` to the file
  contents (live edits reflect without re-registering).
- Pointer files live under the per-adapter aux dir (`adapter/strings/…`), copied into the registry
  on `adapter add`. **Containment is enforced at register time**: `..` or absolute paths escaping
  the `strings/` dir fail the add (manifest-first: nothing registers on an invalid manifest).
- Use this to keep skill-instruction bodies out of the manifest (`[strings.skills].<x> =
  { file = "skills/<x>.md" }`) — the body is the UPS-injection source, the manifest stays thin.

## `[digest]`: the transcript→record extractor seam

- `[digest]` **must** name where it reads: either `source` or a `[history].locate_template`. The
  JSON schema alone accepts `[digest]` with just `extractor`, but `spt adapter add` rejects it —
  this cross-field rule only surfaces at registration. Validate against the live binary.
- The extractor is invoked `--session {session_id} --in {source}`. `{source}` is a **root**; the
  extractor locates `<session_id>.jsonl` within (the harness's internal subdir scheme is the
  harness's business — spt-core stays agnostic and bakes no harness directory scheme into the key
  catalog). Handle both shapes: `--in` a directory (locate the session) and `--in` a direct file
  (a `digest-proof --sample` log).
- **[CC]** Claude Code stores transcripts at `<root>/<cwd-slug>/<session_id>.jsonl`, and can
  **relocate its whole state tree via `CLAUDE_CONFIG_DIR`** (set by launcher wrappers / isolated
  profiles). Because that value is runtime, not static, the extractor must prefer
  `$CLAUDE_CONFIG_DIR/projects` over the manifest `--in` root on its directory branch — the
  manifest cannot express it. Leave the explicit `--sample` file path untouched.
- Emit **raw** records (`{role∈input|agent|tool, text?, tool?, ts?}`, one NDJSON line each);
  spt-core's renderer applies the presentation defaults (`window_turns`, `arg_truncation`,
  `sprint_collapse`). Don't pre-render.

## Bringup / launcher seam

- `[session.self].command` is the spt-hosted bringup template (`spt endpoint run` spawns it into a
  broker PTY). For a harness with no native session-id flag, mint the id internally and pass the
  endpoint id via **`[env.<VAR>]`** (`direction = "inject"`, `value = "{id}"`); the SessionStart
  hook reads the env and self-registers (`api bind <id>`).
- `adapter.shortcut_basename` brands the `endpoint run` launcher shortcut (`<basename>-<id>`),
  **decoupled** from the adapter name.

## Lifecycle file-drops (not api verbs)

- commune / signoff are **file-drops**, not `spt api` calls: the agent writes
  `<endpoint_id>-commune.md` / `<endpoint_id>-signoff.md` into the manifest-declared
  `[session].commune_dir` / `signoff_dir`; spt-core's daemon watcher ingests then deletes it
  (daemon is the single writer). The filenames are contract-fixed; only the dir is adapter-declared.

## Validate against the live binary — registration is a second gate

- JSON-schema validity is necessary but **not sufficient**. `spt adapter add` runs cross-field
  registration validation that the schema can't express (e.g. the `[digest]` source rule). Build a
  registration integration check that does: `adapter add` → `adapter list` (assert the adapter +
  each shipped profile composite resolves) → `get-string` (base value + each overlay diff +
  file-backed pointers resolve to body) → soft `adapter remove` (leave the registry clean). Gate it
  behind an opt-in env flag + a minimum `spt` version; it mutates the node-local registry.
- **Observable behavior of the public binary is itself public surface** — when prose docs lag, a
  byte-capture against the live `api`/`adapter` surface is a legitimate way to confirm a contract.

## Release mechanics

- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a
  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the
  actual commands/flags users touch); no requirement ids, module names, or commit hashes.
- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and
  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step
  (credentials + pointer flip).

-- The **CHANGELOG section is the public release body verbatim** — a release fails loudly without a

-  `## [<version>] - <date>` section for the tagged version. Write **user-facing UX only** (name the

-  actual commands/flags users touch); no requirement ids, module names, or commit hashes.

-- Package the marketplace skeleton with a **dry-run-by-default** stager that validates first and

-  copies only the skeleton subset; the marketplace commit/push + install stays the operator's step


Check for any remaining project-specific leaks (traceable, REQ, cplugs, marketplace, stage jargon).


Bash

cd "C:\Users\decid\Documents\projects\spt-claude-code" && grep -niE "traceable|REQ-|cplugs|marketplace|skeleton|doc/impl|impl/unit|\bint\b evidence|stage|sptc" SPT_HARNESS_ADAPTER_TIPS.md || echo "CLEAN"

Scan tips file for project-specific terms


18:> a marketplace (skill stubs + `hooks.json` + `plugin.json` + bootstrap) so casual users can install


Only hit is the intentional, clearly-marked "project-specific aside." Clean otherwise. Commit.


cd "C:\Users\decid\Documents\projects\spt-claude-code" && git add SPT_HARNESS_ADAPTER_TIPS.md && git commit -q -F - <<'EOF'
docs(tips): scope harness-adapter tips to the public framework only

Two project-specific concepts were presented as universal — corrected:
- Publish model: the spt-core adapter registry (`spt adapter add`) is the
  universal delivery mechanism; the marketplace-plugin half is THIS project's
  distribution choice, not a framework requirement. Reframed as a clearly-marked
  project-specific aside; dropped the "two publish layers" framing.
- Removed traceable-reqs jargon (doc/impl/unit/int stages) — traceability is a
  project tool, not a public resource. Kept the underlying framework truth
  (a .toml-only profile/string/hook has no executable code; test-worthy logic
  must live in a binary), stated plainly.
- Dropped the Release-mechanics section (CHANGELOG-as-release-body, skeleton
  staging) — entirely downstream of the plugin-publishing choice.
- shortcut_basename note: "decoupled from the adapter name" (was marketplace name).

Co-authored by: perri
EOF
echo "RC=$?"

Commit scoped tips file


Fixed. Tips file now public-framework only:

1. **Publish model** — dropped the "two layers" framing. The spt-core adapter registry (`spt adapter add`) is presented as the universal mechanism; the marketplace plugin is a clearly-marked *project-specific aside* ("an adapter does not have to ship a plugin").
2. **Traceability** — stripped all `doc/impl/unit/int` + traceable-reqs jargon. Kept the framework truth plainly: a `.toml`-only profile/string/hook has no executable code → test-worthy logic must live in a binary.
3. Also removed the Release-mechanics section (CHANGELOG/skeleton staging — downstream of the plugin choice) and fixed the `shortcut_basename` note ("decoupled from the adapter name").

Committed. Idle.