# Bag milestone — quiet the across-clear window, cover Bash reachability, teach knock

> JIT plan (AGENTS.md §Plans). Scope = the three Requests attached to milestone #6 in the private
> home. Gate for every task: `sh ci/run-gates.sh` PASS **and** `traceable-reqs check` exit 0.
>
> **Excluded from the public mirror** (added to `ci/publish/mirror-public.sh` EXCLUDES) — it names
> private-home issue numbers. Precedent: `TAG-VERDICT-PLAN.md`, `docs/plans/MIGRATION-RELEASE-PLAN.md`.

## Where this came from

Four bagged Requests, grilled with the operator 2026-08-04 (`/grill-with-docs`). One was **cut**;
three were greenlit with their design settled at the grill. The grill verdicts are recorded as
comments on each Request — this plan is the build order, not the rationale.

**Cut: the `--plugin-dir` distribution change.** It assumed the marketplace is the only way the
plugin reaches a session, but harness-hosted sessions already route through `claude-spt launch`,
which builds the `claude` argv. ADR-0001's volatility split is working as designed (skeleton 0.1.17
against adapter 0.25.32), and the marketplace is the *front door* — removing it removes the
mid-session entrypoint and the invisible-installer property with nothing to replace them. A
GSD-style static install was weighed and rejected: user-scope skills cannot namespace, which would
permanently kill the `sptc`→`spt` succession. **No ADR is needed** — this reaffirms ADR-0001 rather
than superseding it; the reasoning lives on the Request.

## Task 1 — the clearing latch (across-commune quiet window)

**The defect, read off the code.** `arm_wake` (`hook.rs:657`) marks the endpoint **idle** at a moment
when the agent is demonstrably mid-turn — `arm_wake` only ever runs from inside a PostToolUse or a
mid-turn tag dispatch. The `/clear` is typed at arm time but CC **queues it until turn end**, so the
window is "the rest of the current turn". The agent's next tool call reaches `handle_pre_tool_use`
(`hook.rs:2454`), which re-marks **busy**, then polls (`:2478`) and drains the msg park (`:2488`) —
delivering a peer message to an agent whose `/clear` is already queued. It answers, and often writes
a second across-commune. That is the reported symptom, exactly.

**The fix.** A `clearing` latch, adapter-local, keyed by eid, same custody as the wake park
(`state/wake/<eid>.park` → `state/clearing/<eid>.latch`): hook-side, so writer and reader are always
the same binary resolution.

- `arm_wake` **stops marking idle** and writes the latch. The endpoint stays BUSY, so core spools
  inbound rather than pushing it.
- Latched: `handle_pre_tool_use` still busy-marks but does **not** poll and does **not** drain the
  park. `handle_stop` does **not** mark idle.
- `SessionStart(clear)` drops the latch, marks idle, and the spool drains into the **fresh** session.
- **Outbound is unaffected** — mid-turn tag dispatch (`@<…@>`) keeps firing throughout. Inbound only.

**Why BUSY and not IDLE.** Suppressing only our own polling does not fix it: an idle endpoint still
gets messages *pushed*, typed into the input box through the translation binary without consulting
our hooks. Staying busy is what closes the door.

## Task 2 — the stranded-latch heal (hazard)

Suppressing Stop's idle mark means a lost `/clear` would strand the endpoint BUSY forever — the
stuck-ACTIVE black-hole class, which already has four entry points. Two trips:

- **Falsifier (primary).** The latch records that a Stop was seen. If a UserPromptSubmit or
  PreToolUse then fires with no `SessionStart(clear)` in between, **a new turn started — which proves
  the `/clear` never fired.** Drop the latch, re-assert idle, log loudly.
- **Backstop.** A wall-clock stamp on the latch, generous (~15 min), so a crash that fires no hook at
  all still heals on the next hook of any kind. Any `SessionStart` source drops the latch — `clear`
  is the success path, `startup`/`resume`/`compact` are the stale heal.

A short time cap alone was rejected: the window is an unbounded turn, so any N either expires
mid-window (re-opening the bug) or leaves a real black hole standing for N minutes.

## Task 3 — Bash reachability coverage

**Not a missing feature.** Both legacy-owl legs are already present: the general
`<spt_reachability_notice>` on every perched UserPromptSubmit (`hook.rs:1646`) and the targeted nudge
on PreToolUse for subagent spawns (`hook.rs:1644`, `is_agent_spawn_tool` = `Agent`|`Task`). Owl shipped
the same pair. Two real gaps:

1. **Bash is uncovered.** Long foreground Bash — cargo, gh, test suites, recursive greps — is the
   common unreachability cause and fires nothing. Observed during the grill: a foreground `grep` blocked
   120s and the harness backgrounded it; no nudge existed for that call.
2. **Advisory cannot convert the call it fires on.** PreToolUse returns `additionalContext` and the
   tool runs anyway, so the text lands *after* the blocking call started. It can only shape the next
   call. The general notice was in context on the turn that ran that grep.

Deny-and-steer (PreToolUse denying the call so the agent re-issues it backgrounded) was proposed as
the only lever that converts the current call and **ruled out by the operator**: advisory, no deny.
Recorded deliberately — this buys coverage, not enforcement.

**Build.** Extend the targeted nudge to Bash. Fires when tool is `Bash` **and** `run_in_background` is
not true **and** the call self-declares length (explicit long `timeout`) or matches a long-runner
shape (cargo, npm/pnpm/yarn, gh, pytest/jest, make, cmake, docker, recursive find/grep/rg, sleep).
**Capped at three per turn** (operator's number) — early enough to shape the turn, capped so it does
not become wallpaper. `nested(v, "tool_input", …)` already exists.

## Task 4 — `/sptc:knock` skill

Knock is **access control** (per-endpoint grants over MSG / RC_VIEW / RC_ATTACH) and is distinct from
`/sptc:subnet`, which is network membership. Verbs: `send` (bare target is send), `list`, `approve`,
`deny`, `new-code`, `redeem`.

**Knocks are quiet by design.** The published contract: *"it lands in that endpoint's inbox and is
never pushed at its agent — someone has to look."* I proposed surfacing pending knocks at
SessionStart; the operator corrected it — the quiet is a **security property**, and all three paths
are user-initiated (send, accept, mint a code). No surfacing, no polling, no brief line.

**Shape** mirrors `/sptc:subnet` exactly: a thin skeleton `plugin/sptc/skills/knock/SKILL.md`
(frontmatter + `argument-hint` + the one-tool-call operative note) plus a file-backed
`[strings.skills].knock` body UPS-injected at invocation. Plus two `[[hints]]`: one keyed on `knock`
for the verb set, one on the code prefix for the redeem path.

Structural plugin change ⇒ this milestone **does** need the cplugs skeleton step
(`ci/publish/package-skeleton.sh` + `claude plugin install sptc@cplugs`), unlike most releases.

## Requirements to mint (registry first, then satisfy)

| id | stages | covers |
|---|---|---|
| `REQ-ACROSS-QUIET-WINDOW` | impl, unit | Task 1 — latch, no idle at arm, inbound suppressed, outbound live |
| `REQ-HAZARD-QUIET-LATCH-STRANDED` | doc, impl, unit | Task 2 — new-turn falsifier + stamp backstop + any-SessionStart drop; `docs/KNOWN-HAZARDS.md` entry |
| `REQ-SKILL-KNOCK` | doc, impl, unit | Task 4 — skeleton + strings body + the two hints |

`REQ-DIST-REACHABILITY-NOTICE` is **amended**, not replaced (Task 3). Its current title asserts the
invariant being changed — *"Non-spawn tools with an empty drain stay silent — no per-Bash noise"* —
so the title must be rewritten to state the new Bash predicate and the three-per-turn cap. Leaving a
requirement whose text contradicts its own evidence is the failure this registry exists to prevent.

## Order and gate

1. Registry entries (all four: three new, one amended) — before any code.
2. Task 1 + Task 2 together (the latch and its heal are one change; shipping the latch without the
   heal introduces a black hole).
3. Task 3.
4. Task 4.
5. `sh ci/run-gates.sh` PASS + `traceable-reqs check` exit 0 → commit.
6. Release per `docs/RELEASE-RUNBOOK.md`, **including** the cplugs skeleton step (Task 4 is structural).

## Deferred, deliberately

- **Live field-verify of the latch.** Unit coverage cannot prove the quiet window on a real
  endpoint; this pipeline's failure mode is silent delivery loss. Needs a disposable endpoint driven
  through a real across-commune, same discipline as `REQ-STUB-WAKE`.
- **The docs finding on knock-code shape.** `sptkc` appears zero times in the published docs
  (`print.html` + search index) though codes are fully described. Route to doyle for
  `docs/SPT-CORE-FINDINGS.md`; the prefix itself is operator-confirmed, so it does not block Task 4.
- **`--plugin-dir` for hosted sessions.** The one real win inside the cut Request: a hosted session
  loading the adapter-packaged plugin is always lockstep with its own binary and needs no
  `/reload-plugins`. Strictly additive, unfiled, and blocked on an untested question — whether CC
  double-registers when the marketplace copy and a `--plugin-dir` copy are both present (duplicate
  skills, hooks firing twice). Needs a disposable endpoint; never test it by spawning `claude` from a
  live agent's shell.
