# JIT plan — instrument-agnostic across-commune detection

**Status:** planned 2026-09-06, not started. **Gate:** `sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0.

## Scope

One change: the across-commune -> `/clear` boundary must fire regardless of which tool wrote the
commune drop. Nothing else moves — the wake/clear macro, the quiet-window latch, the wake park and
the translation-binary loopback are all untouched.

## Root cause

`hook.rs::is_commune_write` is `tool == "Write" && file_path.ends_with("<id>-commune.md")`. The
trigger inspects the **tool call's arguments**, so it is keyed on the authoring instrument. Agents
increasingly write the drop with Bash (heredoc, `cat >`, python) — and those get no boundary at all.
Same failure class as every list-keyed rule this project has already paid for: it stays green while
the case nobody enumerated walks past it.

spt-core's own model is the opposite. `harness-contract/api.md`: commune is a **file-drop**, "any
harness that can write a file can commune", and `[session].commune_dir` (`.claude`, resolved
per-endpoint against its cwd) is the declared seam. The drop is the contract; the tool is not.

## Why not the io funnel / a ResidentService

Both were considered and rejected for this change; the reasoning is worth keeping because it is not
obvious from the outcome.

- The funnel's `COMMUNE` kind is the *right* predicate — `api.md` even names "wake-marker-class
  constructs over commune and IO events" as its motivating case — but it fires when **ingest
  completes**, which the operator measures at >15s. By then no hook is running, so no hook-driven
  design can consume it. It is also an authenticated call, and the frame payload is capped at 16KB
  with `truncated`/`digest_seq`, which our communes routinely exceed.
- A `[service]` ResidentService is the only process alive when no turn is running, but the schema
  says it has "no perch, no identity, and no address" while `api io-events` requires
  `--session-id` or a capability `--token`. Filed as `spt-bs-releases#275`, explicitly **not
  blocking** — this plan is why.

**The assumption this design rests on, stated so it can be falsified:** core's ingest is slower than
a hook cycle, so the drop is still on disk when the next hook looks. If ingest ever becomes
sub-second, a Bash-written drop could vanish before any hook sees it and the funnel/service path
becomes necessary. This is an accepted risk, not a hazard requirement — it cannot be unit-tested,
because it is a property of core's timing, not of our code. It goes in the call-site comment.

## Design

**One predicate, three existing call sites, one stamp.**

Predicate: does `<cwd>/.claude/<id>-commune.md` exist, and does its content carry the wake marker? If
so, `arm_wake` exactly as today. The tool name and `tool_input` stop being read.

Call sites — all three already exist and already spawn, so **no new hook registration and no plugin
republish**:

| leg | when | why |
|---|---|---|
| `PostToolUse` (matcher stays `Write`) | immediately after a Write | keeps today's latency for the common instrument |
| `PreToolUse` | before the next tool call | catches Bash/`Edit`/python drops, still mid-turn |
| `Stop` | turn end | backstop for a drop written by the turn's final tool call |

Widening `PostToolUse` to `*` was rejected: the matcher lives in the plugin's static `hooks.json`,
so it costs a plugin republish, and it would add a second hook spawn to every tool call on top of
the `PreToolUse` one. `PreToolUse` + `Stop` already fire and already carry this exact
mid-turn-plus-backstop shape for the commune-tag path.

**The stamp is what makes it cheap and what makes it correct.** The drop sits on disk until core
ingests it (>15s, many tool calls), so a naive check re-arms on every hook.
`state/commune-seen/<eid>.stamp` records the **last examined** drop identity (`<mtime_ms>:<len>`):

- identity unchanged -> do nothing, and **do not read the file** (steady state is one `stat`)
- identity changed or absent -> read, scan for the marker, arm if present, write the stamp either way
- drop absent -> clear the stamp

Recording *examined* rather than *armed* is deliberate: a commune with no wake marker is the common
case and must not cost a file read per tool call for the next 15 seconds.

## Tasks

1. **New `HookEnv` seam** `file_stamp(path) -> Option<String>` (`<mtime_ms>:<len>`). The real impl
   stats; the Recorder must model it *truthfully* — a mock that answers the same for a present and
   an absent drop cannot fail when the wrong branch is taken.
2. `commune_drop_path(cwd, id)` + `examine_drop(env, cwd, id) -> Option<String>` (returns the
   content to arm on, `None` for no-change / no-drop / no-marker). Stamp read+write lives here.
3. Rewrite `handle_post_tool_use` to call it, dropping the `tool` / `tool_input` inspection.
4. Wire the same call into `handle_pre_tool_use` and `handle_stop`.
5. Delete `is_commune_write` and its unit test.
6. Stamp lifecycle: cleared on `SessionStart(clear)` alongside the clearing latch, and on drop absence.
7. Agent-facing brief text (`adapter/strings`) currently steers agents to `/sptc:commune` + Write.
   Reword to state that any instrument works — **doc stage, deferred** until the impl lands.

## The one real correctness risk

Arming from `handle_stop` interacts with the quiet-window falsifier. `arm_wake` writes the clearing
latch; `mark_clearing_stop_seen` (already called at `hook.rs:3647`) records that the arming turn's
`Stop` has been seen so that a *later* `PreToolUse` falsifies a stranded latch. If we arm at `Stop`
and the ordering is wrong, we either strand a latch or falsify one we just armed on the next hook.
The Stop leg must arm **and** mark `stop_seen` in the same pass, and
`REQ-HAZARD-QUIET-LATCH-STRANDED`'s existing tests must be re-run against the new leg rather than
assumed to still hold.

## Tests (each must fail before it passes — mutation-check every one)

- a drop written with `tool_name = "Bash"` arms (the whole point; fails today)
- N repeated hooks over one unchanged drop arm **exactly once** (mutation: remove the stamp -> fails)
- an unchanged drop is stat'd, **never read**, on the repeat passes (asserts the cost model, not just
  the behaviour)
- a drop with no wake marker never arms, at any leg
- a *second, different* drop after a first arm does arm again (the stamp must not be a permanent latch)
- the `Stop` leg arms and marks `stop_seen` in one pass, and a following `PreToolUse` does not
  falsify that latch

## Out of scope

The funnel/service path (`#275`), any change to the wake/clear macro or the translate binary, and
`COMMUNE_FAIL` handling — we have no authenticated reader for it, and a failed ingest leaves the
drop on disk where `psyche-download` still serves it as a pending-commune slice.
