# IO-FUNNEL-PLAN — claude-spt reports the turn's closing output (Release A of two)

Enabling half of `BigscreenVR/spt-pacer-tool#14` ("retire pacer's bespoke agent output
detection in lieu of the `AGENT_OUTPUT` io kind"). pacer cannot consume `AGENT_OUTPUT`
until an adapter emits one, and **claude-spt emits none today** — validated scan of 197
files, 0 `--payload-stdin`/`--payload-file` call sites against 151 control hits for
`state busy|idle`. Operator ruled the sequencing on 2026-08-28: **claude-spt first, verify
frames land, then pacer**. This plan is claude-spt's half only.

## Contract (read, not recalled)

Node-local docs, `shells/frames.html` + `harness-contract/manifest.html`, fetched at
core 0.64.0 (instrument validated: HTTP 200, 63243 B, control terms hit):

- `AGENT_OUTPUT` rides the activity report an adapter already makes —
  `spt api state idle <id> --payload-stdin` / `--payload-file`. Surface confirmed present
  on this node's 0.64.0 `spt api state --help`; passing both sources is refused by name
  (`STATE_PAYLOAD_AMBIGUOUS`).
- **Emission is per payload-carrying call, not per transition.** "spt-core does not
  deduplicate, so send one payload-carrying report per turn; that once-per-turn discipline
  is the adapter's to keep."
- `AGENT_OUTPUT` is the turn's **closing** output — a stop-hook-equivalent "never sees the
  lines that streamed while it ran". Mid-turn output belongs to `[digest]`.
- Bodies cap at 16 KB; a longer payload arrives cut with `truncated="1"`, on a character
  boundary.
- Payload reporting **leaves activity behaviour untouched** — the busy/idle sentinel and
  `since` are unchanged.

Two questions were put to core and answered by doyle (2026-08-28), both recorded because
each could have invalidated this release:

1. *Does a fetcher-pulled digest entry emit an io event by itself?* **No.** The emission
   population is exactly: payload-carrying `api state` calls, the `MSG_IN`/`MSG_OUT`
   delivery edges, and `COMMUNE`/`COMMUNE_FAIL`. The frames-page warning about "a second
   copy is a second event" targets an adapter double-**reporting** the same turn text
   through payload-carrying calls; it is not about the digest containing the text. Our
   `[digest] strategy = "fetcher"` does not create a second event.
2. *Is riding `api state idle` coupling AGENT_OUTPUT to the activity transition?* **No** —
   transport reuse. The stop hook is the one moment the closing output exists and the
   adapter is already calling core.

**Forward flag, deliberately not built for:** `releases#238` (operator-ruled, backlog) will
add core-side framed emission of mid-turn digest entries with inline payloads, and its scope
includes respecifying the dedup discipline and amending `frames.md` / `patterns.md` in the
same change. A release built to today's contract stays correct; the implication reaches us
through amended docs. **Design consequence we DO take now: never assume one `AGENT_OUTPUT`
per turn** — that assumption would be the thing #238 breaks.

## Scope

IN: the `AGENT_OUTPUT` payload at the Stop end-of-turn idle report, and the leak-guard
class fix that landing this plan file requires.

OUT, deliberately:
- `USER_INPUT` (the `state busy` payload). Not needed by #14, and a second payload seam is
  a second once-per-turn discipline to keep. Add it when something consumes it.
- `[io] compliance = true`. That declares our own tag parser deleted so core may parse our
  ingest for shortform — a different, larger change (`tag_scan.rs` is live and shipping).
  The published rule is "declare compliance in the release that deletes your parser, not
  before", so declaring it here would be wrong. Payload emission does not require it.

## Tasks

### A0 — the leak guard becomes a real class predicate (prerequisite)

`3201839`'s comment claims the root JIT-plan class "is excluded as a class instead of one
file at a time"; the code underneath it is still eight enumerated literals. Landing
`IO-FUNNEL-PLAN.md` would trip the guard exactly as `ATTR-PASSTHROUGH-PLAN.md` did — the
[[list-vs-predicate-assertions]] shape, a rule keyed on the instances that existed the day
it was written.

- Replace the eight root-plan literals with the pathspec `:(glob)*-PLAN.md`.
  Measured: `:(glob)*-PLAN.md` = 8 root files; bare `*-PLAN.md` = 42, because git pathspec
  globs match at any depth — the bare form would silently unpublish ~35 currently-public
  `docs/plans/` files. `:(glob)` is what stops `*` at the `/`.
- `set -f` around the `for p in $EXCLUDES` loops. Measured: the unquoted `$EXCLUDES`
  expansion is performed **by the shell against the working tree**, so a glob left
  unprotected resolves to whatever happens to exist on disk rather than reaching git as a
  pathspec over the committed tree — the guard would then read the working tree it was
  written not to trust.
- Keep `docs/plans/MIGRATION-RELEASE-PLAN.md` as its own literal; it is excluded for its
  own reason and is not root-class.
- Correct the comment to describe the mechanism that now exists.

### A1 — `turn_closing_output`

Extract the closing assistant text for the ending turn from the hook payload's
`transcript_path`, using the existing `tag_scan::assistant_texts_from_jsonl`.

- Independent of the tag scanner's cursor (`state/digest/<eid>.pos`). That cursor is a
  destructive, advancing read shared with tag dispatch; binding a second consumer to it
  risks the double-send class it exists to prevent.
- Cap at 16 KB on a **character** boundary (never split a UTF-8 sequence — core cuts on a
  char boundary and a cut we make must be no worse). Truncation is core's to announce; we
  simply do not hand it an oversized body.
- Empty/absent transcript, or no assistant text ⇒ `None` ⇒ the call is made exactly as it
  is made today, with no payload and no event.

### A2 — attach at exactly one site

`hook.rs:3653`, the Stop end-of-turn idle mark. **Not** the other seven `state idle` sites:
receive-heal, `Notification(idle_prompt)`, bind/bringup, compact/clear. Those are healers
and lifecycle marks, not turn ends; a payload on any of them is a phantom `AGENT_OUTPUT`.
This is the once-per-turn discipline the contract puts on us, expressed as a call-site rule.

Known, accepted gap: the across-clear quiet window returns before the idle mark, so a
commune-across turn emits no `AGENT_OUTPUT`. That turn ends in a `/clear`; a pacer code in
it is moot. Documented rather than worked around.

### A3 — proof

Units: payload present and correct on Stop; absent when the transcript yields nothing;
**absent on every non-Stop idle site** (the phantom-event guard); oversized input cut at
16 KB on a char boundary; `--payload-stdin` carried on stdin, never as an argv element
(the published reason is the Windows command-length limit).

Mutation-check each new test per the [[v0300-seal-carry]] lesson — a test that cannot fail
is not evidence.

### A4 — ship

Version bump + CHANGELOG, `sh ci/run-gates.sh` PASS **and re-run after the last file
lands**, `traceable-reqs check` exit 0, three triples fresh, tag, release, mirror.
Then verify at the consumer: an `AGENT_OUTPUT` frame actually reaching a shell's drain on
this node — that observation, not the green gate, is what unblocks Release B.

## Gate

`sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0, re-run after the final file
lands. The gate is the gate, and it is run *before* the commit, never after.
