# 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
routes it straight into the session's durable context — 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 where spt-core routes
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** — spt-core owns the spawn, the keys, and the routing of the brief
> into the durable tiers. 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, stdout routed straight to the durable tiers, and the agent-owned commune_dir it never writes to -->
<!-- [doc->REQ-ECHO-BRIEF-DIRECT-ROUTE] the brief routes from the summarizer into the two-tier store on the fire; commune_dir is the agent's own channel and the echo never writes it -->

## 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 budget caps it, and a non-zero exit files
**nothing** (the failure is loud, never a half-written delta).

**Declare the budget.** The cap is
[`invocation_budget_secs`](manifest.md#sessionrole--outbound-templates) on this
role: absent it is 90 seconds, and it is clamped to 300. Declare it on any role
that is an LLM turn — spt-core cannot know what your model costs, and a
summariser whose real duration straddles the cap is killed by the load of the
moment rather than by anything about its work.

<!-- [doc->REQ-PSYCHE-INGEST-INTENT-MARKER] -->

**A bound kill is not a non-zero exit, and it is not silent.** Being killed at
the budget is a third outcome: the child dies *before* printing anything, so
there is nothing to route and no partial result to clean up — a brief that was
never produced leaves no trace to detect it by. spt-core therefore
records the **expectation** before it spawns, and an expectation that no brief
ever satisfies surfaces at the agent's next resume as a
`COMMUNE_NEVER_INGESTED` warning naming the fire it came from and the bound it
died at, stating plainly that the durable context does not include that work
and the agent may be resuming stale. The expectation is current-state only: a
later successful commune overwrites it and the warning stops.

A bound kill also does not count against the same budget as a real fault. A
psyche host latches as failed on consecutive **hard** failures; a timeout or
bound kill spends its own, softer budget, so a slow-but-healthy host is not
latched by load. Any success clears the stamp — **an ingest success included**,
not only a turn.

## 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 anchor-subnet label (`local` when unanchored) — 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"]
```

## Where the brief goes

spt-core routes the summarizer's output **straight into the durable context
tiers** at the moment of the fire, stamped `Source: echo-commune`, and publishes
it as a `COMMUNE` I/O event. The brief never becomes a file. Three invariants
define the contract:

**1. The echo routes; it does not file.** The brief goes from the summarizer's
stdout into the two-tier store in one step, through the same routing the
drop-file ingest uses. Nothing is written to disk on the way, so there is no
window in which the brief sits as a file — for anything else to overwrite, or to
be overwritten by.

**2. `commune_dir` is the _agent's_ channel, not the echo's.** The directory an
adapter declares under `[session] commune_dir` is where **the agent** drops its
own hand-written commune — the fixed filename `<endpoint_id>-commune.md` — which
spt-core then ingests and deletes. The echo does not write there.

> *spt-core filed its echo brief to that same path until v0.67.x. Two writers on
> one path with no arbitration meant an agent's boundary commune, authored at a
> `/clear` and not yet ingested, could be overwritten **unread** by the echo that
> fired seconds later. See KNOWN-HAZARDS 6.12.*

A `commune_dir` may be absolute or relative, and the **ingest** resolves it
per-endpoint *(hardened in v0.29.0)*:

- **Absolute** → used as-is.
- **Relative** → resolved against the **endpoint's own recorded working
  directory**, read fresh — never against the daemon's process cwd.
- **Relative with no recorded cwd** → spt-core **skips 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 dir once resolved
  there and failed with a permission error. 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.

An adapter that declares `[session.echo_commune]` but **no** `commune_dir` still
echoes — the echo needs no directory. A missing `[session.echo_commune]` role is
a **loud once-skip**, not a retried fault.

**Read the resolved dir; never re-derive it.** Because rule 2 resolves the
directory against the *endpoint's* recorded cwd — not the caller's — an agent
working in a git worktree, a subdirectory, or any other cwd cannot tell where
its own commune must land by looking around. Ask spt, in one command:

```console
$ spt endpoint list --json
{ "self": { "id": "todlando", ..., "drop_dir": "C:/repo/.claude" }, ... }
```

- `self.drop_dir` — where **your** commune must land.
- `local[].drop_dir` — the same fact for the node's **other** endpoints, which
  is the form the question usually takes: a stray `<id>-commune.md` is found by
  whoever trips over it, not by its owner.
- The human view prints the same fact as a `commune drop dir:` line under the
  SELF pin, so `spt endpoint list` answers it without `--json`.

Both fields resolve through the *same* resolver the ingest watches with, so the
answer cannot drift from the behaviour. An endpoint with no recorded cwd or
adapter, or a manifest declaring no `commune_dir`, **omits** the field rather
than guessing — an absent answer is honest, and a guessed path is worse than
silence. Subnet-remote rows carry no drop dir at all: that would be another
node's filesystem, and a path this machine cannot read is not an answer.

A drop file sitting in a dir that is *not* the one reported here was written
somewhere nothing watches: it will never ingest, and reading it is the recovery.
<!-- [doc->REQ-ENDPOINT-DROP-DIR-SURFACE] -->

**3. Ingest deletes the agent's drop.** On its next pulse tick spt-core reads
the agent's 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; it
stamps a provenance header (`Source: echo-commune`) and routes the result
into the durable tiers.

That body is parsed, at that moment, 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.

The **project anchor** is the endpoint's recorded working directory, the same
one rule 2 resolves a relative `commune_dir` against; there is no anchor when
no cwd is recorded or it lies inside the spt home (a psyche host, the engine room).

Every write is precedence-guarded — a stale snapshot arriving inside another
writer's protection window is suppressed (but still consumed). An endpoint with
**no project anchor** (a psyche host, the engine room) has no project tier to
fill, so a `<project-context>` slice in an echo brief is discarded with a loud
`ECHO_PROJECT_UNROUTABLE` line rather than held as a file; the `COMMUNE` I/O
event still carries the brief verbatim, so the funnel remains the record. 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**; let spt-core do the spawn and route the brief into the
durable tiers. That is the whole contract. `commune_dir` is a separate
declaration for your **agent's own** communes — declare it absolute — and the
echo neither needs it nor writes to it.
