URL: https://sabermage.github.io/spt-releases/harness-contract/echo-commune.md
Content-Type: text/markdown
Method: text

---

# Echo-commune — the I/O contract

When a session ends **without** a graceful signoff, its context would be lost.
The **echo-commune** recovers it: spt-core runs the adapter's bounded
summarizer over the session, captures the brief the summarizer prints, and
files it as the session's context delta — the same delta a hand-written
[commune](../lifecycle/overview.md) would have carried.

This page is the **adapter-facing I/O contract** for that mechanism: the role
you declare, the keys spt-core fills, what it does (and does not) feed the
summarizer, how the summarizer locates the harness, and the drop-file
protocol spt-core uses to file the result. It is the companion to the
[`[session.echo_commune]` role](manifest.md#sessionrole--outbound-templates)
in the manifest reference.

> The echo-commune is spt-core's. The adapter supplies **one command template
> and one watched directory** — spt-core owns the spawn, the keys, the
> file-drop, and the ingest. Everything below is the seam between those halves.

<!-- [doc->REQ-DOC-ECHO-COMMUNE-CONTRACT] the full echo-commune I/O contract: the role + fields, the key catalog spt-core fills, the no-history-on-stdin rule, read-env self-locate, the single-writer/per-endpoint-resolver/ingest-deletes drop-file protocol, and stdout ingestion -->

## The role

`[session.echo_commune]` is one outbound role template. Its fields are the
standard role shape:

```toml
[session.echo_commune]
command = "my-harness run --agent summarize --session {session_id}"
recursion_guard_env = "SPT_ECHO_COMMUNE"     # set on the child so its own hooks bail
env_remove = ["MY_HARNESS_SESSION_ID"]        # stripped from the child's env
keys = ["id", "session_id"]                   # the keys this template expects filled
```

| Field | Required | Meaning |
|---|---|---|
| `command` | yes | Opaque command line with `{key}` placeholders. Model, tools, flags — all inside the string; spt-core never parses it. |
| `cwd` | no | Working directory for the child (substitutable). A role `cwd` wins over the endpoint default. |
| `recursion_guard_env` | no | Env var name set on the summarizer child so *its* harness hooks bail — no echo-of-an-echo. |
| `detach` | no (default `false`) | Spawn detached. |
| `env_remove` | no | Env vars stripped from the child's inherited environment. |
| `keys` | no | The substitution keys spt-core fills for this role (your declared expectation list). |

The child runs **bounded** — a timeout caps it, and a non-zero exit files
**nothing** (the failure is loud, never a half-written delta).

## Keys spt-core fills

For an echo-commune spawn spt-core fills its base catalog. Template only the
keys you are given; a `{placeholder}` spt-core does not supply for this role
fails the spawn with a one-line error naming the missing key.

| Key | spt-core fills it with |
|---|---|
| `{id}` | The endpoint id being summarized. Always filled. |
| `{session_id}` | The harness session id — filled when one is known. |
| `{node}` | This node's advertised label — filled only when non-empty (a `{node}` reference with no value fails loud rather than resolving an empty token). |
| `{subnet}` | The endpoint's home-subnet label (`local` when unhomed) — filled only when non-empty. |
| `{adapter_dir}` | The adapter's install dir — adapter-static, always available (lets the command point at the adapter's own packed summarizer binary). |
| `{adapter_name}` | The adapter's declared `name`. |
| `{VAR}` | Any manifest-declared [`[env] direction = "read"`](manifest.md#envvar--env-var-table) var captured at bind — see [Self-locating the harness](#self-locating-the-harness). |

This is the **same base catalog** the Psyche and notification roles build on;
it deliberately does **not** include `{session_name}` (a `[session.self]` key)
or the Psyche-only `{psyche_context_file}` / `{parent_session_id}`.

## History is not fed on stdin

spt-core has a stdin channel for the summarizer — but **you must not depend on
it carrying the transcript.** The rule, field-proven against the reference
Claude Code adapter:

- If the manifest declares a [`[history]`](manifest.md#history--transcript-access)
  strategy that yields records, spt-core normalizes them and pipes them to the
  summarizer on **stdin**.
- With **no `[history]` section**, or a `native` history store that is still
  empty, there are no records — so **stdin is empty**. This is the field case:
  the reference adapter's history is native and typically empty at echo time,
  so the summarizer receives an empty stdin.

The load-bearing consequence for an adapter author: **the echo-commune command
must self-source the session it summarizes** (locate and read the transcript
itself), exactly as a [`[digest]` `fetcher`](manifest.md#digest--session-digest-extractor)
extractor does. Do not write a summarizer that reads its transcript from
stdin. What spt-core reliably supplies is the **command template with the key
catalog filled** (and the child's env — see below); the transcript is the
summarizer's to find.

## Self-locating the harness

A summarizer that self-sources its transcript needs to find the harness's
config/log root. spt-core carries that in through the manifest's **read-env
allowlist**, so the value survives the daemon boundary (the echo child runs in
the daemon context, where the original launch environment is long gone).

```toml
[env.CLAUDE_CONFIG_DIR]
direction = "read"
value = "~/.claude"          # fallback when the launch env didn't set it
```

- Declare each locator var with `direction = "read"`. spt-core captures it
  **from the launch environment at bind** — an explicit allowlist, never the
  whole environment, and only when the ambient value is actually present.
- The captured value is written onto the perch record, so it is available when
  the echo (or digest, or Psyche) child spawns later.
- At spawn spt-core injects it as a `{VAR}` substitution key. **Resolution
  order:** the captured ambient value wins (a relocating profile — e.g. a
  wrapper that sets `CLAUDE_CONFIG_DIR` to a private dir) → else the
  directive's own `value` fallback (the base harness default, e.g.
  `~/.claude`) → else the var is **omitted**, so a `{VAR}` reference fails loud
  rather than resolving a wrong path. A leading `~` expands to the home dir.

Reference the captured key in the echo command the same way `[digest]` does:

```toml
[session.echo_commune]
command = "my-harness-summarize --session {session_id} --config-dir {CLAUDE_CONFIG_DIR}"
keys = ["session_id", "CLAUDE_CONFIG_DIR"]
```

## Drop-file protocol

spt-core files the summarizer's output as a **drop file** — the exact same
channel a hand-written commune uses — then ingests and deletes it. Three
invariants define the contract:

**1. spt-core is the single writer.** The filename is fixed by spt-core —
`<endpoint_id>-commune.md` — and the adapter declares only the *directory*
(`[session] commune_dir`). The mind never writes this file; spt-core writes it
atomically (with a bounded access-denied-only retry for transient
antivirus/indexer locks) and is the sole deleter. Never have the summarizer
write the drop file itself.

**2. The directory resolves per-endpoint** *(hardened in v0.29.0).* A
`commune_dir` may be absolute or relative:

- **Absolute** → used as-is.
- **Relative** → resolved against the **endpoint's own recorded working
  directory**, read fresh at drop time — never against the daemon's process
  cwd.
- **Relative with no recorded cwd** → spt-core **skips the drop and warns once**
  (per endpoint, per daemon run). It never guesses and never falls back to the
  daemon's cwd. This is the fix for a real outage: under a service-launched
  daemon whose process cwd was a system directory, a relative drop dir once
  resolved there and failed with a permission error on every write. The loud
  skip makes a mis-declared relative dir a diagnosable signal instead of a
  silent failure. Declare an **absolute** `commune_dir`, or ensure the
  endpoint's cwd is recorded, to avoid the skip.

A missing `[session.echo_commune]` role is likewise a **loud once-skip**, not a
retried fault.

**3. Ingest deletes the drop.** On its next pulse tick spt-core reads the
drop, routes it into the durable context tiers, and **deletes the file** —
whether the content was written or suppressed as a stale snapshot (both mean
"consumed"). A read/write *error* leaves the file in place to retry on the next
pass. The file disappearing is the success signal.

## What spt-core expects on stdout

The summarizer's **stdout is the brief** — the cheap-model synthesis of the
session, as plain text. spt-core does not require a structured format at write
time; it stamps a provenance header (`Source: echo-commune`) and writes the
result as the commune drop file.

On the later ingest tick that body is parsed with the **two-slice envelope**
grammar, the same one a hand-written commune uses:

- `<live-context>…</live-context>` → the **live tier** (who the agent is and
  what it is doing; follows the endpoint everywhere).
- `<project-context>…</project-context>` → the **project tier** (scoped to the
  current project).
- An **untagged body** routes whole to the live tier.

Every write is precedence-guarded — a stale snapshot arriving inside another
writer's protection window is suppressed (but still consumed and deleted). The
checkpoint sentinel `!!checkpoint!!`, if the brief carries one, is stripped
before both presentation and the durable write, so it never persists in the
stored context.

## In one line

Declare `[session.echo_commune]` with a command that **self-sources its
transcript** (found via a `direction = "read"` locator key) and **prints the
brief to stdout**; declare an **absolute** `commune_dir`; let spt-core do the
spawn, the file-drop, the ingest, and the delete. That is the whole contract.