# Adapter-update arc: resolve-not-execute hooks, composite `[update.post]`, translation-binary command

<!-- [doc->REQ-MANIFEST-SUBST] [doc->REQ-ADAPTER-UPDATE-POST] [doc->REQ-TRANSLATE-COMMAND] -->

## Status

accepted (2026-06-25; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.16.0**. Pairs with the downstream `spt-claude-code` ADR-0005 (name unification) + ADR-0006 (one-command update + consolidated binary) — this ADR is spt-core's half of the asks those raised. Builds on ADR-0022 (translation-binary idle delivery), ADR-0024/0025 (multi-platform `.spt` packaging + live daemon-coordinated adapter update), and the `[strings]` / `get-string` surface (REQ-MANIFEST-3/5). Full per-item spec: `docs/design/v0.16.0-update-arc-and-cli.md`.

## Context

The operator wants the whole claude-spt adapter kept current with effectively **one lever** (`spt adapter update claude-spt`). Two facts (ADR-0006) bound it: hook **logic must physically live in the CC plugin dir** (CC loads hooks there), outside `spt adapter update`'s reach; and raw skills/commands can't replace the plugin. Closing the gap surfaced three spt-core asks. The first — *generic hook dispatch* — was proposed as `spt api run-hook <adapter> <event>`: spt-core would **execute** the adapter's hook handler. That inverts the hooks contract (today `[hooks.<event>]` is purely outbound — the harness fires `fires`, spt-core never executes a hook) and adds a new spt-core execution surface, in tension with the harness-agnostic boundary ("spt-core itself contains zero Claude Code conventions" / "nothing in spt-core ever executes a string").

## Decision

Keep spt-core a **pure resolver** — it never grows a hook-execution surface. Three changes:

### 1. Resolve-not-execute (rejects `run-hook`)

Add two general substitution primitives so an adapter resolves+runs its **own** packed binary:
- **`{adapter_dir}`** (the registry record's precise `source_dir` — install dir, survives updates, the dir bare-program resolution already uses) and **`{adapter_name}`**, available wherever command/string substitution runs.
- **Lazy substitution inside `[strings]` values at `get-string` read time**, scoped to **adapter-static keys only** (`{adapter_dir}`, `{adapter_name}`). Session-scoped keys (`{id}`/`{session_id}`/…) are not available — `get-string` carries no session context; a `get-string --session-id` is a deferred, larger change.

The invariant holds: spt-core substitutes and **returns** a string; the adapter's own wrapper executes it. A CC hook dispatcher does `get-string claude-spt hook_cmd` → `"{adapter_dir}/claude-spt hook"` **once per session** (memoized into an env var, for the PostToolUse hot path), then runs that per-hook. Hook *logic* rides `spt adapter update` (it's in the adapter binary); the plugin's `hooks.json` + a thin static per-OS dispatch wrapper go static-forever.

### 2. `[message-idle-translation-binary]` takes a `command`

Add a sibling optional **`command`** (opaque; args + **adapter-static** `{adapter_dir}`/`{adapter_name}` substitution only; program token resolved against `install_dir` like `[digest].extractor`/`[session.psyche_init]`). **`path` deprecated** — keeps parsing (forward/back-compat), warns at registration. Exactly one of `{path, command}`. Spawn/stdin protocol unchanged. Folds `claude-spt translate` into the one consolidated binary (ADR-0006). **Subst scope ratified adapter-static (v0.16.0 W1), not session `{key}`:** the translation binary is a persistent process serving every session on the endpoint — session/event context arrives per-message over the stdin Init/Event protocol, never the spawn argv — and the live-update respawn site carries no session context (a `{id}`-bearing command would `MissingKey`→spool there). Session keys in the command would be both redundant and unsafe.

### 3. Composite `[update]` — `gh_release` + a delegated post-step

Add an **avenue-agnostic `[update.post]`** sub-table `{ command, self_verifies }`, run after the primary avenue resolves. Runs **unconditionally** (even on an adapter no-op — its own idempotent check decides). **`command` required** (empty refused); **`self_verifies` is attestation-only metadata** (defaults false), parallel to the existing `[update].self_verifies` on the delegated avenue — it records that the post-step verifies its own outcome but does **not** gate execution (the post-step is already failure-isolated + exit-orthogonal, so spt-core verifies nothing about it). Reserved for a future verification lever; inert at the post-step runtime in v0.16.0. **Reserved notice sentinel = `!!update-message!!`** (house `!!x!!` style; a published seam — a post-step emits it verbatim, alone, to fire the static message). Published **stdin JSON** seam (`adapter_applied`, `adapter_name`, `profile_name`, `version`, `previous_version`, `adapter_dir`; additive keys). **stdout decides the notice:** custom text **supersedes** `[update].message`; a reserved sentinel fires the static `[update].message`; empty = nothing. exit code orthogonal (0 ok / nonzero failed). Precedence: dynamic-stdout > sentinel/manifest-message > nothing; **no `[update.post]`** ⇒ today's `adapter_applied`→message; **post-step fails** ⇒ warn + fall back to `adapter_applied`→message. **Failure-isolated** — a committed `gh_release` pull is not rolled back if the post-step fails (independent channels).

## Considered and rejected

- **`spt api run-hook <adapter> <event>`** (spt-core executes the adapter's hook handler). Would work and keep `hooks.json` static-forever with no plugin-resident shell, but adds a new spt-core *execution* surface and inverts the outbound-only hooks contract. Rejected for **resolve-not-execute**: two reusable primitives (`{adapter_dir}` + `[strings]` substitution) over one narrow execution trick, and spt-core executes nothing. Cost accepted: a thin static per-OS dispatch wrapper stays plugin-resident, and hook dispatch is ~2 spawns unless the resolved path is memoized per session (it is).
- **Overloading `[message-idle-translation-binary].path` to mean "path or command".** Ambiguous; a distinct `command` mirrors the other seams. Rejected.
- **`post_update` field bolted onto `gh_release`, or an ordered-avenue list.** The first is over-coupled, the second over-general. An avenue-agnostic `[update.post]` (like `message` is avenue-agnostic) is the minimal shape.

## Consequences

- spt-core stays a pure resolver — strongest harness-agnostic posture; no new execution surface.
- `{adapter_dir}`/`{adapter_name}` + `[strings]` substitution are reusable beyond hooks (any string pointing at a packed binary/resource).
- The one-lever goal lands: `spt adapter update claude-spt` pulls the `.spt` **and** runs the plugin post-step; the only manual residual is `/reload-plugins` (an unautomatable CC TUI action), carried by `[update].message` (or the post-step's dynamic notice).
- ADR-0005's repo rename (`spt-claude-code`→`claude-spt`) is honored across `[update].repo` + install-dir derivation.
- Public docs (MANIFEST.md, reference.md via xtask, gh-pages integration-checklist/messaging) ride the v0.16.0 release.
