summaryrefslogtreecommitdiff
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-07-23-esp-target-debug-design.md102
-rw-r--r--docs/superpowers/specs/2026-07-28-hil-test-refactor-design.md141
-rw-r--r--docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md179
-rw-r--r--docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md343
-rw-r--r--docs/superpowers/specs/2026-08-15-ci-hs-reset-edges-design.md162
-rw-r--r--docs/superpowers/specs/2026-08-16-drop-ep0-prime-verify-design.md90
-rw-r--r--docs/superpowers/specs/2026-08-18-claude-doc-audit-design.md135
-rw-r--r--docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md522
-rw-r--r--docs/superpowers/specs/2026-08-21-hil-report-module-design.md144
-rw-r--r--docs/superpowers/specs/2026-08-24-rtt-skill-design.md164
10 files changed, 1982 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-07-23-esp-target-debug-design.md b/docs/superpowers/specs/2026-07-23-esp-target-debug-design.md
new file mode 100644
index 000000000..491466992
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-23-esp-target-debug-design.md
@@ -0,0 +1,102 @@
+# esp-target-debug Skill Design
+
+Backend skill for debugging TinyUSB firmware on Espressif targets (rig:
+`espressif_p4_function_ev`, `espressif_s3_devkitm`) via the chips' **built-in
+USB-Serial-JTAG**, with external JTAG documented as a TODO until the rig has
+an adapter. Companion to `target-debug`, which keeps the architecture-neutral
+methodology (intrusiveness ladder, board locks, dual-side capture, diagnosis
+standards) — this skill is the Espressif toolchain/probe backend, the same
+boundary that makes `usb-kernel-debug` its own skill.
+
+## Goals
+
+- An agent can attach, halt, backtrace, set breakpoints/watchpoints, list
+ FreeRTOS threads, and capture logs on the rig's P4 **while TinyUSB device
+ traffic is live** — every recipe hardware-verified before landing unmarked
+ (the `target-debug` ethos).
+- The S3's USB-SJ/OTG PHY conflict is mapped precisely, not hand-waved:
+ verified working via `board_test` (TinyUSB off — PHY free), verified failure
+ mode with a USB device example, external-JTAG escape hatch documented as
+ TODO.
+
+## Non-goals (deferred)
+
+- External JTAG bring-up (no adapter on the rig) — TODO section with S3 JTAG
+ pin notes (GPIO39-42) and openocd-esp32 adapter support pointers.
+- Xtensa/S3 full parity under live USB traffic (needs external JTAG).
+- ETM-class instruction trace; SystemView tooling beyond an apptrace spike.
+
+## Architecture
+
+New skill `.claude/skills/esp-target-debug/SKILL.md`; two integration edits:
+
+- `target-debug` gains a 2-line pointer under the probe-mapping bullets:
+ Espressif boards use a different toolchain, probe model, and trace story —
+ read `esp-target-debug`.
+- `target-debugger` agent table gains an `esp-target-debug` row (name-only,
+ aligned columns, per the established conventions).
+
+Skill content (order):
+
+1. **Role + defer line** — methodology lives in `target-debug`; this file is
+ the Espressif backend. Built-in USB-SJ now; external JTAG TODO.
+2. **PHY-conflict map** —
+ - S3: USB-SJ and OTG share one PHY (GPIO19/20). TinyUSB claiming the PHY
+ drops JTAG-over-USB mid-session: JTAG works for non-USB examples
+ (`board_test`), dies for USB device examples (verified boundary, exact
+ symptom recorded). External JTAG = the future escape hatch (TODO).
+ - P4: OTG-HS has a dedicated HS PHY; USB-SJ is separate — JTAG and the
+ TinyUSB DUT port coexist (verified). USB-SJ doubles as a live log
+ console during device traffic — the TU_LOG-equivalent channel.
+3. **Toolchain & attach** — `. $HOME/code/esp-idf/export.sh` provides
+ `openocd-esp32` + `riscv32-esp-elf-gdb` (P4) / `xtensa-esp32s3-elf-gdb`
+ (S3). Rig path is raw openocd (HIL firmware isn't an idf project on disk):
+ `openocd -f board/esp32p4-builtin.cfg` with `adapter serial <uid>` (USB-SJ
+ is VID 303A:1001; uid = the `flasher.uid` already in `tinyusb.json`), gdb
+ on :3333. `idf.py openocd` / `idf.py gdb` noted for idf-project work.
+4. **Technique mapping table** (aligned) — ARM technique → Espressif
+ equivalent:
+
+ | target-debug technique | Espressif backend |
+ |---|---|
+ | GDB autopsy, bp/wp | same flow; RISC-V trigger module (P4) / Xtensa 2 bp + 2 wp (S3); budget read verified on P4 |
+ | Vector catch | none — breakpoint the panic handler; decode `mcause`/`mepc`/`mtval` (P4) |
+ | SWO / DWT data trace | none — apptrace over JTAG is the analog (gated spike; lands `(untested)` if it fails) |
+ | RTT / TU_LOG | USB-SJ console — on P4 it coexists with DUT traffic |
+ | FreeRTOS threads | native in openocd-esp32 — `info threads` out of the box |
+ | verifybin | `esptool.py verify_flash` |
+
+5. **Rig discipline deltas** — same `board_lock.py` protocol; flasher is
+ esptool (serial-port-by-uid); reflash pristine before release; one client
+ per USB-SJ device.
+6. **External JTAG — TODO** — S3 JTAG pins, adapter classes openocd-esp32
+ supports, and the efuse caveat (JTAG pin selection), unverified.
+
+## Verification gates (execution order)
+
+All under board locks, serial, evidence in commit messages:
+
+1. **P4 coexistence (headline)**: flash a device example, confirm enumeration
+ + traffic on the DUT port, then attach openocd+gdb over USB-SJ →
+ halt, `bt`, resume — device stays functional after resume.
+2. **P4 budget**: read trigger/watchpoint counts via openocd/gdb; set a
+ hardware watchpoint on a TinyUSB variable, confirm hit.
+3. **P4 threads**: `info threads` lists ESP-IDF tasks (usbd task visible).
+4. **P4 console**: capture USB-SJ console log output during device traffic.
+5. **P4 apptrace spike (gated)**: bounded attempt; verified recipe or
+ `(untested)` tag.
+6. **S3 boundary**: `board_test` flashed → attach works (halt+bt); then a USB
+ device example → record the exact JTAG failure symptom when the PHY
+ switches. No further S3 work (external JTAG TODO).
+
+## Constraints
+
+- Worktree `claude/improve-debug-skill-agent`; commit per gate; pre-commit
+ before each; no Co-Authored-By trailers.
+- Formatting conventions already established: aligned table columns,
+ skill-name-only cross references, bullets over run-on paragraphs.
+- Espressif builds need `export.sh` first (CLAUDE.md); P4/S3 examples build
+ via idf.py — reuse existing HIL-built firmware where possible instead of
+ rebuilding.
+- Hardware-verify-before-landing: unverified content ships tagged
+ `(untested)` or not at all.
diff --git a/docs/superpowers/specs/2026-07-28-hil-test-refactor-design.md b/docs/superpowers/specs/2026-07-28-hil-test-refactor-design.md
new file mode 100644
index 000000000..3cd202d95
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-28-hil-test-refactor-design.md
@@ -0,0 +1,141 @@
+# hil_test.py refactor: test core + infra helpers
+
+**Date:** 2026-07-28
+**Branch:** `claude/hil-test-split` (based on `claude/hil-pool-check`, which adds `pool_check.py`)
+
+## Motivation
+
+`test/hil/hil_test.py` is 2370 lines mixing five concerns: board-lock protocol, per-controller
+scheduling permits, flash/reset backends, the actual per-example tests, and orchestration/report/CLI.
+The lock protocol additionally exists in three copies (`hil_test.py`, `board_lock.py`,
+`.claude/skills/hil/pool_check.py`), which has already produced drift (pool_check's copy lacks
+hil_test's fail-open and error guards). Splitting the infrastructure out makes `hil_test.py`
+test-focused and gives external tools (pool_check) one canonical import for locks, permits, and
+flashing.
+
+## Goal / non-goals
+
+**Goal:** behavior-preserving code motion. `hil_test.py`'s CLI, arguments, output, report format,
+and runtime behavior stay byte-identical. One deliberate user-visible change: the operator lock CLI
+moves from `board_lock.py` to `hil_lock.py` (same subcommands, same behavior); `board_lock.py` is
+deleted.
+
+**Non-goals (explicit follow-ups, not this change):**
+- The 15 pool_check findings from the 2026-07-28 code review (exception isolation, park-on-failure,
+ espressif coverage, probe-recovery criterion, etc.).
+- pool_check adopting `flash_permit` controller budgeting (enabled by this split).
+- Any change to lock semantics, permit widths, flash behavior, or test logic.
+
+## Resulting layout (`test/hil/`)
+
+| File | ~Lines | Role |
+|---|---|---|
+| `hil_test.py` | 1600 | tests + orchestration + report + CLI (unchanged interface) |
+| `hil_lock.py` (new) | 420 | board-lock protocol + controller permits + operator CLI |
+| `hil_flash.py` (new) | 250 | `run_cmd` + flash/reset backends + `find_firmware` |
+| `board_lock.py` | deleted | superseded by `hil_lock.py` |
+
+Import graph: `hil_test` → {`hil_lock`, `hil_flash`}; the helpers import nothing local (no cycles).
+`pool_check.py` imports all three.
+
+## hil_lock.py
+
+Docstring states the scope: board locks + controller flash/battery permits; the CLI manages board
+locks only (permits are in-process semaphores with no CLI meaning).
+
+**Flock core** (protocol defined once; moved from `board_lock.py`/`hil_test.py`):
+- `BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks'`, `lock_path(board)`
+- `CI_REASON = 'hil_test.py'` — the release-protected holder tag (release refuses to kill it)
+- `flock_nb(board) -> fh` — `os.open(O_RDWR|O_CREAT, 0o666)` **without O_TRUNC** (a losing racer
+ must not wipe the winner's record), `fdopen('r+')`, `LOCK_EX|LOCK_NB`; raises `OSError` when held
+- `write_record(fh, reason)` — truncate+seek+`json.dump({pid, reason, since})`+flush
+- `clear_record(fh)` — truncate(0), swallow OSError (records stay truthful on release)
+- `read_record(board) -> dict | None` — today's `board_lock.read_info`
+- `acquire_board_lock(board, reason=CI_REASON) -> fh | None` — today's `hil_test.acquire_board_lock`
+ with a `reason` parameter: `HIL_NO_BOARD_LOCK=1` bypass, fail-open with warning on lock-dir
+ OSError, `RuntimeError` carrying holder info on conflict
+
+**Controller permits** (moved verbatim from `hil_test.py`):
+- `FLASH_PARALLEL`, `USBTEST_PARALLEL`, `CONTROLLER_SLOTS` (env-overridable as today)
+- `controller_of(uid)`, `controller_slot(pci)`, `controller_permit`, `flash_permit(uid)`,
+ `usbtest_permit(uid)`
+- Per-worker globals (`usbtest_sems`, `flash_sems`, `controller_map`, `controller_meta`,
+ `controller_hints`) set by a new `init_scheduling(sems, fsems, cmap, cmeta, hints)` hook that
+ `hil_test.init_worker` calls from the Pool initializer. `controller_permit`'s PROFILE logging
+ calls back through a module-level `log = print`-style hook that `hil_test` points at `log_line`
+ during `init_scheduling` (keeps helpers free of hil_test imports). The `PROFILE` env flag
+ (`HIL_PROFILE=1`) is read independently in `hil_lock` at import, same derivation as today.
+
+**Operator CLI** (moved verbatim from `board_lock.py`): `hold`/`release`/`status` subcommands with
+the daemon-holder machinery (double-fork, setsid, stdio detach, success pipe, SIGTERM bow-out),
+release policy (probe the flock; protect `CI_REASON` holders; SIGTERM other recorded pids),
+`is_locked` pid-liveness, `--all`/`--config` roster handling. The hold/release/status internals
+switch to the flock-core helpers above; observable behavior unchanged.
+
+## hil_flash.py
+
+Moved verbatim from `hil_test.py`:
+- `CMD_TIMEOUT` (env-overridable), `run_cmd(cmd, cwd, timeout)`, `cmd_stdout_text(out)`
+- `OPENCOD_ADI_PATH`, `TINYUSB_ROOT`
+- All backends: `flash_jlink`/`reset_jlink`, `flash_stlink`/`reset_stlink`,
+ `flash_stflash`/`reset_stflash`, `flash_openocd`/`reset_openocd`,
+ `flash_openocd_wch`/`reset_openocd_wch`, `flash_openocd_adi`/`reset_openocd_adi`,
+ `flash_wlink_rs`/`reset_wlink_rs`, `flash_esptool`/`reset_esptool`,
+ `flash_uniflash`/`reset_uniflash`, `flash_lm4flash`/`reset_lm4flash`
+- `find_firmware(variant, example)`
+- `get_serial_dev(id, vendor_str, product_str, ifnum)` — moves here (not hil_test) because
+ `flash_esptool` calls it; keeping it test-side would create a helper→hil_test import cycle.
+ Tests call `hil_flash.get_serial_dev`.
+- Module globals `build_dir = 'cmake-build'` and `verbose = False`, set by callers exactly as the
+ `hil_test` globals are today (`hil_test.main` sets them from argparse; pool_check sets them
+ directly). `run_cmd`'s verbose echo reads `hil_flash.verbose`.
+
+Dispatch in callers stays string-based: `getattr(hil_flash, f'flash_{flasher["name"].lower()}')`.
+
+## hil_test.py (what remains)
+
+Config TypedDicts (`Board`, `FlasherCfg`, …), device-node lookup except `get_serial_dev`
+(`get_disk_dev`, `get_hid_dev`, `get_alsa_capture_dev`, `open_serial_dev`, `serial_write_all`,
+`read_disk_file`, `open_mtp_dev`, `get_printer_dev`/`open_printer_dev`), enum-timeout globals +
+`wait_until`,
+`log_line`/print-lock, `compact_output`, all `test_*` functions, test lists, `test_example`,
+`build_board`, `test_board`, report rendering/accumulation, `main`. Call sites use explicit
+module-qualified names (`hil_lock.flash_permit(...)`, `hil_flash.run_cmd(...)`) so provenance is
+greppable; no `from … import *`-style mirroring.
+
+`init_worker` keeps its signature (Pool initargs unchanged) and forwards the scheduling state to
+`hil_lock.init_scheduling(...)`.
+
+## Consumer updates (same commit)
+
+- **`.claude/skills/hil/pool_check.py`** — drop its private `lock_board`/`unlock_board` in favor of
+ `hil_lock.flock_nb` + `write_record(fh, 'pool_check')` (+ `clear_record` on release; deliberately NOT `acquire_board_lock`, whose HIL_NO_BOARD_LOCK bypass and fail-open behavior pool_check must not inherit); import
+ flashers/`find_firmware`/`get_serial_dev`/`cmd_stdout_text`/`TINYUSB_ROOT`/`build_dir` from
+ `hil_flash`; `BOARD_LOCK_DIR` references move to `hil_lock`. pool_check then imports **only**
+ `hil_lock` + `hil_flash` (no `hil_test`), so its `pymtp` stub shim is deleted — that shim existed
+ solely because importing `hil_test` pulls in libmtp.
+- **`test/hil/hil_ci.sh`** — the scp list is currently `hil_test.py`, `pymtp.py`, `$CONFIG`; add
+ `hil_lock.py` and `hil_flash.py` (hil_test cannot even import without them). `board_lock.py` was
+ never in the list.
+- **Docs rename `board_lock.py` → `hil_lock.py`** (live docs only): `.claude/skills/hil/SKILL.md`,
+ `.claude/agents/hil-operator.md`, `.claude/agents/target-debugger.md`,
+ `.claude/skills/etm-trace/SKILL.md`, `.claude/skills/usb-kernel-recover/SKILL.md`,
+ `.claude/skills/target-debug/SKILL.md`. Historical `docs/superpowers/{plans,specs}` stay as
+ records.
+- **CI workflow** — untouched (invokes `hil_test.py` CLI only).
+
+## Verification
+
+1. `python3 -m py_compile` on all three modules + pool_check.
+2. `hil_lock.py hold/status/release` interplay: hold, conflicting hold, status listing, release,
+ protection of a `CI_REASON` record, stale-record cleanup.
+3. `pool_check.py --scan-only`, then a single flash board (e.g. `-b stm32f407disco`).
+4. Full `hil_test.py -b stm32f407disco -B examples tinyusb.json` on the rig; compare the report
+ row and log shape against a pre-refactor run.
+5. `pre-commit run` on all touched files.
+
+## Sequencing
+
+Lands on top of `claude/hil-pool-check`. After merge, fix the pool_check review findings as a
+separate change on the new module boundaries, and update agent-memory references to
+`board_lock.py`.
diff --git a/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md b/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md
new file mode 100644
index 000000000..898b3c8ab
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md
@@ -0,0 +1,179 @@
+# PR-scoped HIL selection: helper/hil_select.py
+
+**Date:** 2026-07-29
+**Branch:** `claude/hil-select` (based on `claude/hil-pool-check`, which carries the
+hil_lock/hil_flash split and the current rig rosters)
+
+## Motivation
+
+Every PR currently builds and runs the full HIL matrix (both rigs, every roster board, every
+test). Most PRs touch one port or one class: a `dcd_rp2040` change cannot affect an STM32 board,
+a `cdc_device.c` change cannot affect an MSC-only example, and a device-stack change cannot
+affect host tests. Scoping HIL to the affected boards/tests cuts CI wall time and rig wear
+without losing relevant coverage.
+
+## Goal / non-goals
+
+**Goal:** a shared selector that maps a PR diff to (boards, per-board test lists), wired into
+CI's `set-matrix` on `pull_request` events (pruning both `hil-build` and the rig jobs) and
+callable locally (pre-pr, manual runs). Scoping may only shrink coverage when the mapping is
+confident; every uncertainty widens to the full matrix.
+
+**Non-goals:**
+- Variant-level selection (all variants of a selected board run).
+- Scoping the non-HIL build jobs (cmake/CircleCI one-per-family builds are independent build
+ coverage and stay untouched).
+- Scoping push/master/scheduled runs (always full).
+- Changing hil_test.py behavior (the selector only *composes* existing `-b`/`-bt` args).
+
+## Component: `test/hil/helper/hil_select.py`
+
+Stdlib-only, importable and CLI. Lives beside the harness so `hil_ci.sh` copies are unaffected
+(it runs on the GitHub runner / dev PC, not on the rig). It must NOT import `hil_test.py`
+(which drags pyserial/pymtp onto the bare GitHub runner): the three test lists
+(`device_tests`, `dual_tests`, `host_test`) move verbatim into the stdlib-only
+`test/hil/helper/hil_util.py` that both `hil_test.py` and `hil_select.py` import (behavior
+preserving; `hil_ci.sh` copies the whole `helper/` directory).
+
+```
+python3 test/hil/helper/hil_select.py --base <ref> [--diff-file <path>] CONFIG.json [CONFIG.json...]
+```
+
+- `--base REF`: changed files = `git diff --name-only $(git merge-base HEAD REF)..HEAD`
+ (mirrors pre-pr). `--diff-file`: newline-separated file list instead of git (unit tests, CI
+ reuse of a precomputed diff).
+- Output (stdout, JSON):
+
+```json
+{
+ "full": false,
+ "boards": {"raspberry_pi_pico": "all", "stm32f407disco": ["device/cdc_msc", "device/cdc_dual_ports"]},
+ "args": {"tinyusb.json": "-b raspberry_pi_pico -b stm32f407disco -bt stm32f407disco:device/cdc_msc,device/cdc_dual_ports",
+ "hfp.json": ""},
+ "reasons": ["src/portable/raspberrypi/rp2040/dcd_rp2040.c: port rp2040 -> family rp2040 -> boards [raspberry_pi_pico, ...] (device role)"]
+}
+```
+
+- `full: true` ⇒ `boards`/`args` cover the entire rosters (identical to today's behavior).
+- `args` maps each input config file to the hil_test.py argument string for that rig: `-b` per
+ selected board on that roster, plus `-bt BOARD:t1,t2` for boards with a restricted test list
+ ("all" boards get bare `-b`). An empty string means: nothing on this rig is affected — the
+ rig job is skipped for this PR.
+- Per-file reasoning lines (`file → rule → contribution`) go in `reasons` and to stderr, so the
+ CI log answers "why did/didn't HIL run X" without archaeology.
+
+## Classification rules
+
+Each changed file yields a contribution; the selection is the union. Any file matching no rule
+sets `full: true` (fail-open). Rules, first match wins:
+
+1. **Non-code:** `docs/**`, `.claude/**` (except the workflows below via rule 8), `*.md`,
+ `*.rst`, `LICENSE*` → contributes nothing.
+2. **Port:** `src/portable/<vendor>/<ip>/**` (or single-level `src/portable/<name>/**`).
+ Role from basename: `dcd_*`/`*_device*` → device; `hcd_*`/`*_host*` → host; anything else
+ (shared port files, e.g. `dwc2/dwc2_common.c`) → both. Families = directories of
+ `hw/bsp/*/family.cmake|family.mk` whose text references `<vendor>/<ip>` (pre-pr's grep),
+ boards = those families' entries on the input rosters. Tests = all tests of that role
+ (device_tests / host_test from hil_test.py's lists; dual_tests count as both roles).
+3. **Class:** `src/class/<c>/*_device.*` → all device-capable roster boards; tests = the
+ device/dual examples in hil_test.py's lists whose `examples/<role>/<ex>/src/tusb_config.h`
+ defines `CFG_TUD_<C>` with a nonzero value (derived at runtime; `<C>` = upper-cased class
+ dir, with the map `musb→n/a`-style exceptions NOT needed — class dirs and config macros
+ share names: cdc, msc, hid, midi, audio, video, vendor, usbtmc, mtp, printer. Two
+ exceptions: in class dir `dfu`, `dfu_rt_device.*` maps to CFG_TUD_DFU_RUNTIME and
+ `dfu_device.*` to CFG_TUD_DFU; class dir `net` maps to CFG_TUD_ECM_RNDIS|CFG_TUD_NCM.) `*_host.*` analogously via `CFG_TUH_<C>`. Shared class files (e.g. `cdc.h`) →
+ both roles' matching examples. A class with zero matching examples contributes nothing
+ (known path, does not force full).
+4. **Core role:** `src/device/**` → all device-capable boards, all device tests (+dual);
+ `src/host/**` → all host-capable boards, all host tests (+dual).
+5. **Core common:** `src/common/**`, `src/osal/**`, `src/tusb.c`, `src/tusb.h`,
+ `src/tusb_option.h` → full.
+6. **BSP:** `hw/bsp/<family>/**` → that family's roster boards, all their tests;
+ `hw/bsp/<family>/boards/<board>/**` narrows to that board if it is on a roster, and
+ contributes nothing when it is not (an off-rig board cannot be HIL-tested; known path,
+ does not force full).
+ Family-agnostic BSP files (`hw/bsp/board_api.h`, `hw/bsp/board.c`, ansi_escape.h) → full.
+7. **Example:** `examples/<role>/<ex>/**` → all roster boards, tests = that example if present
+ in hil_test.py's lists, else contributes nothing. `examples/build_system/**`, top-level
+ `examples/CMakeLists.txt` → full. `examples/device/board_test/**` → full: it is the park
+ firmware hil_test.py flashes on every board (variant boundary + teardown), not a test.
+8. **Harness/infra:** `test/hil/**`, `.github/workflows/build*.yml`,
+ `.github/actions/**`, `tools/build.py`, `tools/get_deps.py`, `tools/cmake/**`,
+ `hw/mcu/**`, `lib/**` → full.
+9. **Everything else** (`test/unit-test/**`, `tools/**` not above, unknown paths) → full.
+ (Unit-test-only changes could safely skip HIL, but per the fail-open stance anything not
+ explicitly classified widens; narrowing rule 9 is a later refinement.)
+
+**Role pruning:** after the union, if only device-role contributions exist, host-only boards
+drop out and host tests are stripped from mixed boards (vice versa for host-only changes).
+Dual tests survive either role. Board capability (device/host) comes from the roster entry's
+`tests` flags/only-list, same logic hil_test.py uses.
+
+**No-rig-coverage case:** a cleanly classified change whose boards intersect a roster to the
+empty set yields an empty `args` string for that rig and a stderr line saying so — the rig job
+is skipped, not widened (running unrelated boards would test nothing relevant).
+
+**Roster source:** `config['boards']` only (boards-skip stays parked).
+
+## CI wiring (`.github/workflows/build.yml`)
+
+- `set-matrix` (PR events only): after generating today's matrices, run
+ `helper/hil_select.py --base origin/${{ github.base_ref }} test/hil/tinyusb.json test/hil/hfp.json`
+ (checkout with enough history to reach the merge base: `fetch-depth: 0` on this one job, or
+ an explicit `git fetch origin $BASE_REF`). New job outputs: `hil_select_full`,
+ `hil_args_tinyusb`, `hil_args_hfp`, plus the selected-board list consumed by the matrix
+ generator. Non-PR events: skip the selector, outputs default to full/empty-args-means-all.
+- `hil_ci_set_matrix.py` gains `--select '<json>'`: when given and `full` is false, it emits
+ build entries only for selected boards (per config). Untouched otherwise.
+- `hil-tinyusb` job (one matrixed job covering both rigs, selected by `matrix.hil_json`): a
+ step picks the rig's selector args in shell (`case "$HIL_JSON" in ...`) from the set-matrix
+ outputs and either appends them to the `hil_test.py` invocation or exits the step early with
+ a "HIL skipped by selection" log line when that rig has nothing to run (`run` flag output
+ false). The separate `hil-tinyusb-esp` job (esptool split) gets the same treatment with the
+ tinyusb args. Non-PR events: outputs default to run=true with empty args (today's behavior).
+- The `--flasher`/`--exclude-flasher` split in the existing matrix `test_args` composes fine
+ with `-b` (hil_test.py applies both filters).
+
+## Local use
+
+- pre-pr's "Map changes to boards" step delegates to
+ `python3 test/hil/helper/hil_select.py --base $BASE test/hil/tinyusb.json` and derives its
+ one-board-per-family sample from the selector's board set (its capping/sampling policy is
+ unchanged — the selector provides the affected set, pre-pr samples it).
+- Manual: `python3 test/hil/hil_test.py -B examples $(python3 test/hil/helper/hil_select.py --base master test/hil/tinyusb.json | jq -r '.args["tinyusb.json"]') test/hil/tinyusb.json`
+ — documented in the hil skill.
+
+## Testing
+
+`test/hil/test/test_hil_select.py` — stdlib `unittest`, no hardware, injected diffs via
+`--diff-file`/API. Cases (the acceptance examples):
+1. `src/portable/raspberrypi/rp2040/dcd_rp2040.c` → only rp2040-family roster boards, device
+ tests only, host-only boards absent, `full` false.
+2. `src/device/usbd.c` → every device-capable board on both rosters, all device tests + dual,
+ no host-only board, no host tests.
+3. `src/class/cdc/cdc_device.c` → only examples with CFG_TUD_CDC enabled (must include
+ device/cdc_msc and device/cdc_dual_ports; must exclude device/msc_dual_lun and all
+ host tests).
+4. `src/class/msc/msc_host.c` → host-capable boards only, host examples with CFG_TUH_MSC.
+5. `tools/random_new_script.py` → `full: true`.
+6. `docs/foo.rst` alone → contributes nothing ⇒ empty selection, `full` false, all `args`
+ empty (CI additionally has check-paths gating; the selector's answer is still honest).
+7. `hw/bsp/rp2040/family.cmake` → rp2040-family boards, all their tests.
+8. Mixed device+host diff → no pruning (both roles present).
+The suite runs in `set-matrix` before the selector is used, and locally via
+`python3 test/hil/test/test_hil_select.py`.
+
+## Safety properties
+
+- Fail-open: unknown/infra paths ⇒ full matrix; selector crash in CI ⇒ job fails visibly
+ (never silently skips HIL).
+- Only `pull_request` events are scoped.
+- The selection JSON + per-file reasons are printed in the job log for audit.
+- hil_test.py errors on `-b` names not in the config — the selector only emits roster names,
+ and the unit suite locks that invariant.
+
+## Sequencing
+
+Lands on `claude/hil-select` on top of the pool-check/split stack. Follow-ups it does not
+include: narrowing rule 9 for unit-test-only changes; variant-level selection; pre-pr skill
+text update ships in the same change (its mapping section shrinks to a selector call).
diff --git a/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md b/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md
new file mode 100644
index 000000000..a34848f06
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-30-hil-usbtest-fleet-wedge-design.md
@@ -0,0 +1,343 @@
+# HIL fleet-wedge containment
+
+Date: 2026-07-30
+Status: implemented, then superseded in part, then TRIMMED (2026-08-25 — see the
+addendum at the end). Last checked against the shipped code 2026-08-25; where they
+disagree the CODE and the usb-kernel-recover skill win, never this document.
+
+- **Pool guard.** A single constant, not the flat 4200s below and not a derivation:
+ `POOL_TIMEOUT = pos_int_env('HIL_POOL_TIMEOUT', 3600)`. A per-controller model briefly
+ lived here and was removed -- it under-modelled the flash phase and could INVERT
+ (adding a usbtest board lowered the guard, because the derived value fell below the
+ baseline it was meant to raise). The guard's only job is to stop a wedged pool short
+ of the job ceiling so the report still gets written; predicting a healthy run's
+ duration is a different problem. `pos_int_env` warns only on a non-integer or a value
+ <= 0: there is NO upper clamp and no warning above any threshold, so a pin larger than
+ a job ceiling silently restores the inversion this work removed.
+- **Job ceilings.** 90/90/120 min (build.yml), not 60/60/90 and not the 85/115 below.
+ They must clear the 3600s guard plus the pre-pool checkout/artifact merge and the
+ post-guard sweep and report upload. No job pins `HIL_POOL_TIMEOUT`.
+- **Battery budgets.** `USBTEST_BATTERY_BUDGET` 260s. The recovery reserve is no longer a
+ constant: `usbtest.recovery_reserve(flasher)` derives it per flasher (RP-target openocd 390s,
+ other openocd/jlink/stlink 190s, esptool/lm4flash 150s) — see the trim addendum.
+ The 200s-with-a-197s-floor derivation recorded here was never shipped; the floor
+ assertion was removed with it.
+- **HUNG recovery.** Reflash of the DUT through its roster flasher
+ (`usbtest.py --recover-board/--recover-fw`), not the root-cycle-first recovery in
+ section 1d — replaced after the 2026-08-11 ppps measurement (uhubctl never cuts
+ VBUS; root-cycle is probe-only). Since 2026-08-12 the reflash is SKIPPED
+ when `hil_flash.convoy_safe(board['flasher'])` is false (usbtest.py:675): the flasher
+ would enumerate by opening usbfs nodes, block on the same convoy, and become a second
+ stray rather than clear the first. A holder that owns the device lock inside a driver
+ ioctl is terminal either way -- a reflash only produces a disconnect, and
+ `usb_disconnect()` needs that same lock -- and that state needs a reboot.
+
+Step 0 done — the host was rebooted 2026-07-30 14:11 and the rig
+came back clean. The device that triggered this incident was removed from the rig, so
+only the containment work remains relevant.
+Rig: `ci.lan` (Proxmox guest on `pve.lan`)
+
+## Problem
+
+On 2026-07-29/30 every board in the `ci.lan` usbtest fleet failed, `openocd` processes
+landed in uninterruptible sleep, and no subsequent HIL run could start. Two GitHub
+Actions runs were stranded: `30484641269` sat `in_progress` for over eight hours
+(past GitHub's own 360-minute default), and `30485082274` sat `queued` behind it from
+2026-07-29 19:35 UTC onward. Both report directories were written empty.
+
+A reboot of the `ci` guest at 10:48 did not clear the condition: the same kernel state
+re-formed at 10:52:23.
+
+## Root cause
+
+Five layers, each independently observable.
+
+### 1. A permanently wedged hub worker holds a root-hub device lock
+
+A device that repeatedly re-asserts connect while failing to enumerate keeps
+`hub_event()` busy, and `hub_event()` holds `usb_lock_device(hdev)` on its hub for its
+whole run (hub.c:5896/5989). The `usb_hub_wq` worker sits in `hub_port_reset`, so that
+hub's `device_lock` is effectively never released:
+
+```
+kworker/14:6+usb_hub_wq (state D, 400+ s)
+ msleep+0x2b
+ hub_port_reset+0x1a4 [usbcore]
+ hub_event+0x727 [usbcore]
+```
+
+`usb usbN-portM: Cannot enable. Maybe the USB cable is bad?` is logged every four seconds
+for as long as it lasts.
+
+Verified against hub.c v6.12.96 rather than inferred: the kernel does **not** retry
+without bound, and root and downstream ports are bounded identically —
+`hub_port_reset()` tries `PORT_RESET_TRIES` then logs that message (hub.c:3149),
+`hub_port_connect()` wraps it in `PORT_INIT_TRIES` = 4 and disables the port on give-up
+(hub.c:5455/5619). A count in the thousands is therefore that many separate connect
+events, not one runaway loop, and it indicts the device rather than the port.
+
+### 2. A parked board storms the second controller
+
+`ra6m5_ek` (`test/hil/tinyusb.json`, uid `8419032D32363657364EF4622D294B4E`, at
+`13-3.3`) runs dfu firmware (`cafe:400b`) and re-enumerates every 1-2 seconds
+continuously, wrapping the entire bus-13 devnum space (`...120 -> 127 -> 4 -> 6 -> 10`).
+This is standing `hub_event` and Address-Device pressure on controller `03:00.0`,
+concurrent with parallel usbtest batteries on the same silicon.
+
+The board is already listed in `boards-skip`, which is precisely why it storms:
+`boards-skip` stops testing a board but never parks it, so it keeps running whatever
+firmware it last received. Park-flash only runs as teardown of a board that actually
+executed tests.
+
+### 3. The kernel `usbtest` control-queue case waits without a timeout
+
+`test_ctrl_queue` blocks on an untimed `wait_for_completion()` while `usbdev_ioctl`
+holds the DUT's `device_lock`:
+
+```
+wait_for_completion+0x8a <- no _timeout variant
+test_ctrl_queue+0x4ab [usbtest]
+usbtest_do_ioctl+0x501 [usbtest]
+usbdev_ioctl+0x6b8 [usbcore]
+```
+
+`--timeout 60` in `test/hil/usbtest.py` is a subprocess timeout only. `SIGKILL` is not
+delivered to a task in uninterruptible sleep. `usbtest.py` already recognises this and
+reports `HUNG`, then calls `usb_recover.sh root-cycle`.
+
+### 4. openocd inherits the convoy and the whole fleet dies
+
+Once a device lock is stuck, `port_event()` takes a child device's lock to warm-reset
+it and blocks while still holding its hub's lock. Any later
+`open("/dev/bus/usb/BBB/DDD")` against such a device blocks uninterruptibly:
+
+```
+usbdev_open+0xdc [usbcore] -> __mutex_lock
+chrdev_open -> do_sys_openat2 -> __x64_sys_openat
+```
+
+That is the state of the three `openocd` processes at 04:16:51 (pids 207921, 207987,
+208034) — the flasher, unkillable. Because one controller carries two buses, a single
+convoy takes out every board on both, which is why the failure presents as the entire
+fleet.
+
+The existing `HUNG` recovery cannot help here. A root-port VBUS cycle frees a
+*device-lock* holder; it cannot free a lock held by a stuck *hub worker*, and on this
+rig the cycle lands on the controller that is already wedged.
+
+### 5. Nothing bounds the damage, so one bad run becomes a CI outage
+
+- `hil-tinyusb` and `hil-tinyusb-esp` in `.github/workflows/build.yml` carry no
+ `timeout-minutes`. Only `hil-hfp-iar` does.
+- `ci.lan` runs a single runner service, so there is one job slot.
+- `test/hil/hil_test.py` bounds the pool with `POOL_TIMEOUT` (4200 s), and that guard
+ fires correctly — but the recovery path does not survive a D-state worker:
+
+```python
+with Pool(processes=os.cpu_count() or 1, initializer=init_worker, initargs=initargs) as pool:
+ async_ret = pool.map_async(test_board, config_boards)
+ try:
+ mret = async_ret.get(timeout=POOL_TIMEOUT)
+ except MpTimeoutError:
+ pool.terminate()
+ pool.join() # blocks forever: a D-state worker never reaps
+ raise RuntimeError(f'HIL worker pool timed out after {POOL_TIMEOUT}s')
+```
+
+`multiprocessing` joins workers unbounded, so both `pool.terminate()` and
+`pool.join()` hang, as does the `with Pool(...)` exit on the success path. Normal
+`hil-tinyusb (tinyusb.json)` runs take 10-20 minutes; one recent run took 71.3
+minutes, which is the 70-minute guard firing and succeeding. The eight-hour run is the
+pathological case.
+
+## Design
+
+### Step 0 — recovery (manual prerequisite)
+
+Power-cycle the PVE **host**, not the `ci` guest. A guest reboot is not sufficient;
+hubs latch up across the PCIe reset, which the 10:48 reboot demonstrated. Nothing
+below can be verified until the rig is clean.
+
+### Section 1 — CI containment
+
+**1a. Two layered timers.** An inner guard inside `hil_test.py` (`POOL_TIMEOUT`, 70 min)
+that fails gracefully -- it writes a report naming the timeout and the dispatched boards,
+shuts the pool down and exits -- and an outer `timeout-minutes` per rig job (85 for the
+hil-tinyusb jobs; 115 for hil-hfp-iar, which also builds four boards with IAR in the same
+job) as the backstop for when even exiting cannot free the runner. The ceiling must stay
+ABOVE the inner guard, or GitHub kills the job before the report is written.
+
+> **Corrected after measurement.** An earlier revision cut the guard to 30 min on the
+> reading that real runs take 9-17 min and everything longer was the old guard firing.
+> That was wrong. `hil_lock.py` records 22.2/14.3/12.5/10.8 min at usbtest width 1/2/3/4,
+> and raising the per-battery budget to 380s made hung boards cost more again. The 30 min
+> guard then fired on 5 of the last 8 HIL job executions across both rigs, and because
+> `map_async` is all-or-nothing each of those runs published a banner instead of any
+> per-board result. Restored to 4200s, the value whose original rationale -- usbtest
+> batteries are serialized fleet-wide, lengthening the tail -- was correct.
+
+**1b. Bound the pool shutdown.** Add a helper to `test/hil/hil_test.py`:
+
+```python
+def _shutdown_pool(pool, grace=30):
+ """terminate() a Pool without ever blocking forever: multiprocessing joins its
+ workers unbounded, and a worker in uninterruptible sleep (wedged usbfs) never
+ reaps -- which would hold the runner's only job slot indefinitely."""
+ t = threading.Thread(target=pool.terminate, daemon=True)
+ t.start()
+ t.join(grace)
+ return not t.is_alive()
+```
+
+On the `MpTimeoutError` path: write the report first, recording the boards that never
+reported so the run stops producing an empty report directory; then `_shutdown_pool`;
+then `os._exit(1)` if it did not return. The hard exit is the point — it is the only
+way past a kernel-side unkillable child. Use the same helper for the `with Pool(...)`
+exit path.
+
+**1c. Pre-flight rig health check.** `check_rig_health()` runs before the build and
+**never aborts**. It probes `/proc` unprivileged (dmesg is restricted on the rig) for a
+wedged `usb_hub_wq` worker, and reports a `/proc` too restricted to trust as its own
+distinct cause rather than as a diagnosed fault.
+
+It is deliberately non-fatal: the rig is unattended and every remedy for a real wedge is
+manual, so aborting would not fix anything -- it would discard the per-board results the
+run can still collect and leave CI red until a human noticed. It emits a GitHub
+`::error::` annotation and continues. The automatic containment is 1a and 1b, which bound
+a stuck run and explain it without anyone touching the rig.
+
+**1d. Order the recovery correctly.** In `test/hil/usbtest.py`, attempt
+`usb_recover.sh root-cycle` FIRST on a `HUNG` case, and only check for a wedged hub worker
+*afterwards*.
+
+> **Corrected during implementation.** This section originally said to check for a wedged
+> worker *before* the cycle and skip it on a hit. That is backwards. Our own stuck
+> `testusb` holds the DUT's device lock, so any port event drives a hub worker into
+> `usb_lock_device()` on it -- uninterruptible, so it reads `D` in ~100% of samples and the
+> confirmation window makes the wrong verdict *more* confident, not less. Cutting VBUS is
+> precisely what completes the in-flight URB, returns the ioctl and frees that worker, so
+> gating on that signature would suppress the recovery in the exact ordering it exists for.
+> A worker still wedged after the cycle is the genuinely unrecoverable case, and that is
+> what the code now reports.
+
+## Verification
+
+- Unit-test `shutdown_pool` and the `hil_health` detectors against a synthetic `/proc`.
+ A real wedge cannot be manufactured on demand, so they are tested against fabricated
+ inputs rather than live hardware.
+- Confirm the detectors flag a genuinely wedged rig, and return clean on a healthy one.
+- One clean full-fleet `hil_test.py` run to prove `check_rig_health` does not
+ false-abort.
+
+## Out of scope
+
+- **`ra6m5_ek` park and its dfu reset loop.** Dropped by decision. Consequence: the
+ layer-2 devnum storm remains as standing pressure on controller `03:00.0`. Unplugging
+ the board or flashing `board_test` by hand resolves it without any code change.
+- **An unattended PVE watchdog** that detects the wedge and power-cycles the host.
+ Declined: more moving parts, and it can cut a running CI job.
+
+---
+
+## Trim addendum — 2026-08-25
+
+The containment above grew past what one maintainer could hold. This records what was
+removed and, more importantly, the rule that decided it, so the next reader does not
+re-derive the deleted layers from the incident above.
+
+### The dividing principle
+
+**The CI job ceiling bounds how long a run can burn. It does nothing about state that
+outlives the run.** Cut what the ceiling contains; keep what it does not.
+
+- Contained by the ceiling: a worker blocked on a wedged device. `drain_pool` keeps the
+ boards that finished, `_write_failed_spec` names the one in flight, `_abandon_exit`
+ writes and uploads the report, and the job dies at `timeout-minutes` regardless. The
+ cost is one pool slot.
+- **Not** contained: a D-state holder left on a usbfs node, or an unswept stray still
+ holding a probe. The job dies and those survive it, on a self-hosted runner, into the
+ next run. That is the original incident.
+
+### Removed
+
+- **The sysfs blindness subsystem.** `SYSFS_UNKNOWN`, the `_SysfsUnknown` sentinel, the
+ path→inode strand memo with its `_STRAND_MISS` miss-sentinel, the four-credit blindness
+ cap, `sysfs_blind()`/`sysfs_blind_note()`, `note_sysfs_strand()`, `bounded_open()`,
+ `usb_scan`'s `(list, bool)` return, usbtest's `inconclusive` abort, and `_blind_note`'s
+ report banner. `read_sysfs` is an ordinary `open().read()` returning `str | None`.
+
+ It was a three-valued contract five files had to reason about, and misreading unknown as
+ absence was silent — a healthy board reported as a firmware regression. It existed for
+ exactly one attribute that can block. Verified against v6.12.96 `sysfs.c`: only
+ `usb_string_attr` (`product`/`manufacturer`/`serial`, sysfs.c:141-143) takes
+ `usb_lock_device_interruptible`; `idVendor`, `idProduct`, `bcdDevice`, `busnum`,
+ `devnum` and `speed` are lock-free `sysfs_emit` from cached fields. Two of the five
+ `read_sysfs` call sites read attributes that cannot block at all.
+
+ **The bound stayed, and it is not opt-in.** An early cut of this trim made `read_sysfs`
+ unbounded on the theory that a blocked worker costs one pool slot. That is false:
+ `usb_scan` reads `serial` on every device matching the VID to find the one it wants, and
+ `hil_lock.controller_of` does exactly that from `controller_permit`, on essentially every
+ board — so one wedged DUT would stall *every* worker and the pool guard would take the
+ whole run. `read_sysfs` and `usb_scan` are bounded by `SYSFS_READ_GRACE` by default;
+ three call sites forgot an opt-in version within a single sitting, and a unit test now
+ pins the default.
+
+ What is gone is the *contract*, not the bound: no third value, no process-wide blindness
+ latch, no `(list, bool)` return, no report banner. A give-up reads as None like any
+ unreadable attribute, and the cost is confined to the device that is actually wedged.
+
+ **`hil_pool_check` is why the memo has to be exact.** It is a standalone
+ ThreadPoolExecutor tool with no guard behind it, run precisely when a device is suspected
+ wedged, and it polls (`wait_device` re-scans every 0.5 s). The bounded read gives up and
+ remembers
+ the path so a poll loop cannot leak a thread and an fd per pass. That memo is keyed by
+ **kernfs inode, not by path**: a busport does not change when a board returns to the same
+ physical port, so a path-only blacklist would outlive the wedge and make the tool's own
+ recovery flow (reset/reflash → `wait_device` polls for the new inode) never see the board
+ again. A changed inode is the all-clear; `os.stat` is safe on a wedged device because it
+ does not invoke `->show()`. A give-up reads as None
+ — the same as unreadable — and `sysfs_stranded()` lets the footer warn that a "missing"
+ row may be the tool losing sight of healthy hardware. One local bound with a warning
+ line, not the five-file three-valued contract that was removed.
+
+- **The recovery budget arithmetic.** `recovery_steps()`, `_time_left()` and its three
+ per-step gates. The reserve was an independent 250s — one number for the whole fleet —
+ that could not contain the ladder it
+ reserved for (reset 30 + reflash 90 + Rescue-DP POR 90 + retry 90 + settles), which is
+ why the child re-decided before every step — with a bare `- 35` for downstream costs
+ that nobody could re-derive. Between them they produced a recovery that skipped its own
+ steps for most real hangs. The reserve now counts `hil_util.REAP_GRACE` **per bounded
+ step** — `run_cmd` spends that reaping a child it had to SIGKILL, on top of the step's own
+ timeout — which is what the `- 35` was standing in for. Undersizing it is worse than not
+ recovering at all: the outer killpg lands mid-reflash and orphans the flasher on the
+ probe. A unit test asserts the reserve covers the ladder. `USBTEST_RECOVERY_BUDGET` is now derived from
+ `usbtest.RECOVER_*` **per flasher and per target**: the Rescue-DP legs are openocd-only
+ (`rescue_openocd` refuses anything else) and a stub reset is screened out, so an esptool
+ board no longer reserves 200s it can never spend. The child runs the ladder straight
+ through, and `--outer-timeout` — parsed but unused once the gates went — is deleted.
+
+### Deliberately kept
+
+- The pool guard, `drain_pool`, the re-run spec, `_abandon_exit`, the CI ceilings.
+- `hil_health`'s sweep **including** `_kill_and_confirm`. SIGKILL is queued, not delivered,
+ for a task in uninterruptible sleep, and a healthy in-flight testusb sits in exactly that
+ state — so `os.kill` returning success proves nothing, and the recheck is the only honest
+ answer to "is the rig dirty for the next job?".
+- usbtest's reset→check→reflash ladder and the `convoy_safe` gate. This is the only thing
+ that unpoisons the rig mid-run, and PR #3832 extends it from 11 to 18 of 27 boards.
+- `mtp_test.py` as a separate process — one job, a clean boundary, and runnable by hand
+ against a board while debugging.
+
+### Structural changes with no behaviour change
+
+- Blocking device IO now runs in a child process everywhere, not just where it was noticed
+ first. The printer WRITE half joined the read half (`usblp_open` ignores `O_NONBLOCK` and
+ stalls in `usb_autopm_get_interface()` holding the driver-global `usblp_mutex`), and the
+ HID echo followed (`hid.enumerate()` reads `manufacturer`/`product` for every HID device
+ it lists, both under the device lock). `test_device_midi_test` is NOT in that set: ALSA
+ rawmidi honours `O_NONBLOCK` on open (v6.12.96 rawmidi.c:489), unlike usblp.
+- `main()`'s two abort paths were near-identical 40-line blocks; `_abort_report` holds that
+ shape once. The controller-hint cache and pool construction moved to their own helpers.
+- The unit suite stopped sleeping 54 of its 78 seconds — mostly one named-and-zeroable
+ post-flash settle paid by ten tests against a fake rig.
diff --git a/docs/superpowers/specs/2026-08-15-ci-hs-reset-edges-design.md b/docs/superpowers/specs/2026-08-15-ci-hs-reset-edges-design.md
new file mode 100644
index 000000000..e01831d34
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-15-ci-hs-reset-edges-design.md
@@ -0,0 +1,162 @@
+# Bus-reset edge events + review fix wave — design
+
+Date: 2026-08-15
+Branch: `fix-ci-hs` (unpushed, 6 commits over master `53fef2833`)
+
+## Problem
+
+A max-effort review of the branch produced 15 findings. Four are regressions the branch
+itself introduced; the rest are pre-existing or cross-cutting. The load-bearing one:
+
+`dcd_ci_hs.c` now runs the RM-prescribed reset cleanup at the URI (reset-start) interrupt
+but does not tell usbd until the Port Change Detect that ends the reset. For the whole
+reset window — a minimum of 3 ms, typically 10–50 ms — usbd still believes the device is
+configured while the DCD's queue heads have been zeroed. A class driver writing in that
+window (`tud_hid_n_report()`, `tud_cdc_write_flush()`) primes a disabled endpoint over a
+zeroed dQH, *after* the cleanup's flush, so the stale prime survives re-enumeration over a
+buffer usbd has already released. On a 600 MHz M7 that window is enormous. Master had no
+gap: cleanup and event were adjacent statements.
+
+The stack has no way to express "reset started" — `DCD_EVENT_BUS_RESET` carries the
+negotiated speed, which does not exist until the reset ends. That missing vocabulary is
+the actual defect; the driver-level workarounds considered (deferring the memclr, guarding
+primes with a private flag) only shrink the window.
+
+## Design
+
+### 1. Stack: split the bus-reset event into two edges
+
+`src/device/dcd.h`:
+
+```c
+DCD_EVENT_BUS_RESET_START, // reset signaling detected; bus unusable, speed unknown
+DCD_EVENT_BUS_RESET_END, // reset complete; .bus_reset.speed is final
+...
+#define DCD_EVENT_BUS_RESET DCD_EVENT_BUS_RESET_END // backward compatibility
+```
+
+No new helper: `dcd_event_bus_reset(rhport, speed, in_isr)` keeps its name and emits
+`_END`, so every other port is bit-identical to today; `_START` uses the existing
+payload-free `dcd_event_bus_signal()`. The alias keeps unit-test/fuzz references
+compiling.
+
+**Contract (documented in `dcd.h`):** `_START` is optional. A DCD that cannot distinguish
+the two edges emits only `_END`, which stays self-sufficient — it performs the full
+teardown with or without a preceding `_START`.
+
+`src/device/usbd.c`:
+- `case DCD_EVENT_BUS_RESET_START:` → `usbd_reset(rhport)` only; speed untouched.
+- `case DCD_EVENT_BUS_RESET_END:` → unchanged (`usbd_reset()` + latch speed).
+- `_usbd_event_str[]` gains both names.
+- `TODO:` note that a DCD signalling both edges should not pay for two teardowns — track
+ a per-rhport "start seen" flag and skip the redundant `usbd_reset()` in `_END`, keeping
+ the unconditional teardown for the legacy single-event path.
+
+Cost, accepted deliberately: one extra queued event and one extra `usbd_reset()` per
+enumeration on ci_hs only, bounded at one per reset against a default
+`CFG_TUD_TASK_QUEUE_SZ` of 16 (queue pressure is the failure PR #3817 fixed, hence the
+explicit note).
+
+### 2. ci_hs: split `bus_reset()` along the register/software line
+
+- **`bus_reset_begin()` — at URI, inside the reset window (UM10503 25.10.3):** ENDPTCTRL
+ type-reset loop, `ENDPTNAK`/`ENDPTNAKEN`, `ENDPTSETUPSTAT` and `ENDPTCOMPLETE`
+ write-back clears, bounded `ENDPTPRIME` drain, `ENDPTFLUSH` all. Emit `_START`.
+ Registers only — nothing in `_dcd_data` is touched, so no software structure is pulled
+ out from under a task mid-`dcd_edpt_xfer`.
+- **`bus_reset_complete()` — at the PCI ending the reset:** re-flush, `tu_memclr(&_dcd_data)`,
+ EP0 queue-head re-init, dcache clean. Emit `_END` with the final PSPD speed.
+
+Two properties fall out: the re-flush kills any prime armed during the window without a
+new state flag, and the memclr now happens at the same instant usbd is told, so the
+"configured over zeroed queue heads" mismatch is eliminated rather than shrunk. Residual
+exposure (a task priming exactly as the ISR memclrs) equals master's.
+
+The reason-dispatch (`pci_reason`, suspend/URI ordering) is unchanged; only the reset
+case's body moves.
+
+### 3. ci_hs: one bounded-flush helper
+
+Extract `flush_endpoints(dcd_reg, mask)` — writes `ENDPTFLUSH = mask`, spins bounded by
+`CI_HS_BUSY_SPIN` until those bits clear, returns `true` if they cleared — and route all
+five flush sites through it (`bus_reset_begin`, `bus_reset_complete`, `dcd_deinit`,
+`dcd_edpt_iso_activate`, the setup-time EP0 flush). The unified part is the mechanism
+(one bound, one spin idiom, one return convention); callers keep their existing reactions,
+all of which currently proceed regardless, and that stays true here — no caller gains new
+error handling in this wave. Without this, §2 adds a fifth site to a file that already
+carried four hand-rolled variants.
+
+### 4. Mechanical fixes
+
+`dcd_ci_hs.c`
+- Setup-time EP0 flush waits for completion (via §3's helper) before the SETUP event is
+ queued, so the flush can no longer still be asserted when the task primes the response —
+ which also dissolves its interaction with the post-prime verify. This adds a bounded
+ spin in ISR context; the RM notes a flush waits out any packet already in progress, so
+ the wait is one packet time (microseconds at HS) and the existing `CI_HS_BUSY_SPIN`
+ bound caps the pathological case, consistent with the file's other flush sites.
+- `dcd_set_address()` writes `DEVICEADDR` only if the status-ZLP prime took. A refused
+ prime means a newer SETUP superseded the transfer; staging an address whose ACK will
+ never arrive is wrong.
+- Emit `DCD_EVENT_RESUME` only when `!(PORTSC1 & PORTSC1_SUSPEND)` (restores master's
+ hardware guard, lost in the rework).
+
+`dcd_lpc_ip3511.c`
+- Deliver the setup copy only when known-good:
+ `if (latch still set) { INTSETSTAT = TU_BIT(0); } else { dcd_event_setup_received(...); }`.
+- `TODO:` token on the USB.13 deferral so backlog sweeps surface it.
+
+`usbd.c`
+- The DCD-refusal path in `usbd_edpt_xfer` stops routing through the breakpoint-carrying
+ assert: a DCD declining a prime is documented and self-healing, not a programming error,
+ and `TU_BREAKPOINT()` is not gated on `CFG_TUSB_DEBUG` — with a probe attached (always,
+ on the rig) it halts the target. Log and return false instead.
+
+BSP
+- Delete the seven-line RHPORT block in `lpcxpresso55s28/board.cmake` (byte-identical to
+ `family.cmake`'s own guards; `board.mk`'s `?=` stays as the idiomatic Make form).
+- `lpc11u37.ld`: correct the stale comment (nothing lands in RamUsb2 in either build
+ system now — the stack owns the whole bank) and keep the ASSERT, re-labelled as
+ future-proofing.
+
+## Findings improved for free (documented, no code)
+
+A reset that starts and never completes — cable pulled mid-reset — now delivers `_START`
+and tears usbd down, where before usbd stayed configured on a dead bus. This softens both
+the adjudicated UNPLUGGED-removal finding and the deferred aborted-reset item: a stray
+later PCI delivering `_END` becomes harmless (usbd already torn down, just latches a
+speed) instead of deconfiguring a live device. True detach detection still requires OTGSC
+B-session-valid VBUS sensing — board-dependent, still a follow-up.
+
+## Explicitly deferred
+
+- Prime verification generalized to all endpoints and all causes (RM 25.10.8.2); the
+ EP0/SETUP-gated form stays, its flush interaction fixed by §4.
+- usbd discards `usbd_control_xfer_cb`/`tud_control_xfer` returns — cross-DCD behavior
+ change needing its own regression pass, despite `usbd.c` being open here.
+- Timed-out flush still proceeds to the memclr (now confined to one helper).
+- LPC55S2x USB.3 FORCE_FS workaround; iso-IN 1023 enforcement; 8-byte OUT-spill
+ enforcement; USB.13 INTONNAK workaround.
+- Gating `TU_BREAKPOINT()` on `CFG_TUSB_DEBUG` stack-wide.
+- Unguarded `set()` RHPORT knobs in ~14 sibling `board.cmake` files.
+
+## Verification
+
+1. `pre-commit run --all-files`; builds for mimxrt1064_evk, lpcxpresso18s37,
+ lpcxpresso11u37, lpcxpresso55s28, plus Make link checks for the two previously-broken
+ targets (`host/cdc_msc_hid` on 55s28, `device/cdc_msc_throughput` on 11u37).
+2. Cross-DCD build guard: one non-ci_hs, non-ip3511 board (e.g. `stm32f407disco`) to prove
+ the `DCD_EVENT_BUS_RESET` alias keeps legacy ports compiling untouched.
+3. HIL on byte-verified flash (`verifyfile` on every J-Link load — the 1064's silent
+ flash no-op has struck twice): usbtest 30/30 on mimxrt1064_evk, lpcxpresso55s28,
+ lpcxpresso11u37; 50× case-9/10 loops on the 1064; 10× case-11/12/24 unlink loops.
+4. Reset-path specific: confirm HS enumeration (480) and, with `LOG=2`, that a single
+ enumeration shows exactly one `_START`/`_END` pair and no spurious RESUME.
+5. Suspend/resume exercise on the 1064 (host-side autosuspend on the port) confirming
+ `SUSPEND`/`RESUME` pairing and no reset misclassification.
+
+## Success criteria
+
+All four regressions closed, no new findings in a scoped re-review of the wave diff, every
+listed HIL result green on verified flash, and legacy DCDs provably untouched (alias build
+check + unchanged `_END` semantics).
diff --git a/docs/superpowers/specs/2026-08-16-drop-ep0-prime-verify-design.md b/docs/superpowers/specs/2026-08-16-drop-ep0-prime-verify-design.md
new file mode 100644
index 000000000..cc1840972
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-drop-ep0-prime-verify-design.md
@@ -0,0 +1,90 @@
+# Drop the EP0 post-prime verify — design
+
+Date: 2026-08-16
+Branch: `fix-ci-hs` (unpushed, 19 commits over merge-base `53fef2833`)
+
+## Context
+
+The branch grew while chasing a wedge on `mimxrt1064_evk`: the board would stop answering a
+host transfer, the URB would never complete, `testusb` would block uninterruptibly and the
+whole rig would follow it down. Eight occurrences over four days, across the Linux usbtest
+battery's queued control and bulk tests.
+
+The cause turned out to be silicon: **Errata i.MX RT1064_A / RT1060_A ERR050101**. While an
+isochronous IN endpoint is active, an IN token addressed to that same endpoint number on
+another device sharing the host silently unprimes one of this device's OUT endpoints —
+control, bulk, interrupt or isochronous. NXP states it cannot be detected by software and
+raises no interrupt. Moving the usbtest example's iso IN endpoint from 3 to 7 (commit
+`42870b15b`) cleared it: 340 consecutive wedge-free runs, where the board previously
+re-wedged within hours.
+
+Before that was known, an earlier theory — a SETUP arriving mid-prime silently cancelling an
+EP0 prime — produced a post-prime verification block in `qhd_start_xfer()`. That theory's
+supporting capture (EP0's status ZLP armed but unprimed, the device a control transfer ahead
+of the host) is explained by ERR050101 just as well, because the errata explicitly covers
+*control* OUT endpoints and a control status stage **is** an OUT endpoint. The generalized
+version of that verify was already reverted (`565bb0d99`) as both regression-prone and aimed
+at a failure the vendor documents as undetectable in software. This spec removes what
+remains of it.
+
+## Change
+
+Delete the post-prime block in `qhd_start_xfer()` (`src/portable/chipidea/ci_hs/dcd_ci_hs.c`):
+the bounded `ENDPTPRIME` drain, the `ENDPTFLUSH`-on-timeout, and the
+`ENDPTSTAT | ENDPTCOMPLETE` / `ENDPTSETUPSTAT` verdict. The tail becomes:
+
+```c
+ // start transfer
+ dcd_reg->ENDPTPRIME = TU_BIT(epnum + (dir ? 16 : 0));
+ return true;
+```
+
+This removes two register spins and four volatile reads from every EP0 transfer, and with
+them the false-fail path a reviewer flagged: a transfer the interrupt handler has already
+completed reads identically to a cancelled prime.
+
+## Deliberately kept
+
+- **The pre-prime setup-lockout guard** directly above it — UM10503 25.10.8.1.1 step 4
+ verbatim ("Before priming for status/handshake phases ensure that ENDPTSETUPSTAT is '0'"),
+ and older than the wedge theory. It also keeps `qhd_start_xfer()` returning `bool`, so
+ `dcd_set_address()`'s gating and the usbd breakpoint removal stay meaningful — no cascade.
+- **The setup-time EP0 flush and its completion wait** — the flush is the 25.10.8.1.1 step-3
+ remark; the wait exists because an unfinished flush can retire a freshly primed response,
+ an interaction independent of the verify.
+- **The `BUS_RESET_START`/`END` split** and the rest of the review-driven hardening.
+- Everything hardware-proven: the rf_tv fix, the lpc11u37 stack move, the lpc55s28
+ onboarding, the lpc55 Make OHCI link, and the ERR050101 endpoint move itself.
+
+The commit message records the corrected attribution of the handoff capture, so the next
+reader does not re-derive the superseded theory from the same evidence.
+
+## Validation
+
+The "with it" arm is already banked from 2026-08-16: 10x 30/30 batteries plus 15x TEST 27,
+15x tests 9/10 and 10x tests 11/12/24, all clean. This is the second half of an A/B.
+
+1. **Rebase onto current master first** (master has moved: midi2/usbtmc/video), then rebuild —
+ otherwise the validated tree is not the tree that merges.
+2. **Software gates:** `pre-commit run --all-files`; full example builds for
+ mimxrt1064_evk, lpcxpresso18s37, lpcxpresso11u37, lpcxpresso55s28; the two Make link
+ canaries (`host/cdc_msc_hid` on lpcxpresso55s28, `device/cdc_msc_throughput` on
+ lpcxpresso11u37); `ceedling test:all`.
+3. **Hardware — mimxrt1064_evk only.** It is the only ci_hs board on the rig; the other two
+ run ip3511, which this change does not touch. Preconditions: CI idle
+ (`pgrep -f "hil_test.py [-]-retry"`), board lock held for the whole run. Flash with
+ `loadfile` (its built-in Program & Verify — JLinkExe V9.66 has no `verifyfile`), then
+ confirm re-enumeration as `cafe:4010` with serial `BAE96FB95AFA6DBB8F00005002001200`, and
+ confirm `lsusb -v` still reports the iso IN endpoint as **0x87** so a stale image cannot
+ masquerade as a pass.
+4. **Runs:** 5x the full 30-case battery, then 15x `--tests 9,10,14,21` (queued control, ch9
+ subset, both ctrl_out cases) — the control paths the verify actually protected, which a
+ plain battery samples only once per run. Print a `testusb` D-state scan after every
+ iteration.
+
+**Acceptance:** 5/5 batteries at 30/30, 15/15 loops, and no `testusb` D-state outliving its
+case runtime.
+
+**Rollback trigger:** any control-case failure (errno 110 or 71 on cases 9, 10, 14, 21) or a
+lingering D-state means the verify was load-bearing after all — restore it and record that
+result in the commit message. A negative result is a finding, not a setback.
diff --git a/docs/superpowers/specs/2026-08-18-claude-doc-audit-design.md b/docs/superpowers/specs/2026-08-18-claude-doc-audit-design.md
new file mode 100644
index 000000000..5b150dc7b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-18-claude-doc-audit-design.md
@@ -0,0 +1,135 @@
+# Audit of the `.claude/` instruction surface — design
+
+**Date:** 2026-08-18
+**Branch:** `claude/hil-doc-audit`
+
+## Why
+
+`hil-operator.md` told an operator two incompatible things at once: one rule forbade
+pre-holding a board lock because `hil_test.py` self-locks, while a rule added in the same
+revision made the lock the thing that keeps concurrent operators off each other's hardware —
+so an operator following the second would take a hold that made its own run fail fast against
+it. Both statements were fixed before this branch was folded, so neither survives in history;
+what survives is the lesson that nothing checks these files against the code they describe.
+
+That is not an isolated slip. A scan of the 36 repo paths cited across `.claude/` flags 8
+that do not resolve. Five are legitimate — placeholders (`docs/changelog/X.Y.Z.md`,
+`src/portable/x/dcd_x.c`, a `test_*.py` glob), a generated file
+(`examples/cmake-build-pvs/compile_commands.json`), and a per-host gitignored config
+(`test/hil/local.json`, whose absence the skill already handles). Three are drift:
+`usbtest/SKILL.md:24,50` cites `src/usb_descriptors.h` and `src/tusb_config.h`, which are
+example-relative but read as repo paths, and `:101` cites `tools/usb/testusb.c`, a Linux
+kernel path presented like a repo file.
+
+Cross-references are in better shape: every `agentType` in a workflow resolves to an agent
+in `.claude/agents/`, every `.claude/skills/<name>` referenced by an agent or workflow
+exists, and the workflow scripts call only harness functions that exist. The drift is in
+**prose claims about behavior** — the class that made `hil-validate` parallelize at the
+wrong layer, on top of a `hil_test.py` that already schedules boards across host
+controllers under per-controller permits (`hil_lock.py:7,133-134`; `hil_test.py:2249,2419`).
+
+## Scope
+
+**In:** `.claude/agents/*.md` (7), `.claude/workflows/*.js` + `check.sh` (7),
+`.claude/skills/*/SKILL.md` (16) and their 8 helper scripts, and the repo `CLAUDE.md`.
+~4,700 lines (2,874 of prose, the rest helper scripts and `etm-trace/boards.md`).
+
+**Out:** `docs/superpowers/**` (historical records — correcting them rewrites history
+rather than fixing what a future session executes), `.claude/settings*.json` and hooks, the
+memory index, and any behavior change to the scripts themselves.
+
+## Claim taxonomy
+
+Only falsifiable classes get a verdict. Guidance ("bias toward caution") is checked solely
+for contradiction with the classes below.
+
+| Class | Settled by | Example |
+|---|---|---|
+| Path | `ls`/`find`, with the base dir made explicit | `src/tusb_config.h` — example-relative, reads as repo-relative |
+| Interface | argparse/grep in the named source | `-b` is `action='append'` (`hil_test.py:2249`) |
+| Behavior | reading the implementing code, cited `file:line` | "permits are in-process semaphores" (`hil_lock.py:7`) |
+| Number | the constant's definition | `FLASH_PARALLEL=4` (`hil_lock.py:133`) |
+| Rig state | read-only `ssh ci.lan` probe | bus map, probe uids, sudoers entries, installed tools |
+| Cross-doc | diffing the same rule's two statements | `hil-operator.md:18` vs `:37` |
+
+### Verdicts
+
+- **CONFIRMED** — current source says so. Cite `file:line`. Leave alone.
+- **REFUTED** — current source says otherwise. Cite, correct the doc.
+- **EARNED** — no source in scope settles it, and it is hard-earned rig knowledge. Stays in
+ the docs untouched; see the rule below.
+- **UNVERIFIABLE** — no source in scope settles it and it is not earned knowledge either
+ (a placeholder, a generated file, a claim about something outside the repo).
+
+### Hard-earned evidence is source of truth
+
+A claim with no code backing is **not** a cut candidate when it is earned rig knowledge:
+an observed hardware quirk, a failure mode paid for in rig downtime, a workaround whose
+rationale lives only in the incident that produced it. Code is authoritative about code;
+experience is authoritative about hardware, and the hardware does not document itself.
+
+Consequences:
+
+- Only a claim the **current source actively refutes** gets corrected. "I could not find
+ backing" is never grounds for deletion.
+- Rig-state claims that have gone stale (a bus map, a probe uid) are **re-derived and
+ updated**, or converted into a derivation recipe ("buses renumber every boot — re-derive
+ with X"), never dropped.
+- Where earned knowledge and current code disagree, that is a **finding to report**, not an
+ edit to make: one of them is a bug, and deciding which is out of this audit's scope.
+
+## Passes
+
+1. **Extraction (fan-out, 9 agents, no verdicts).** One agent per cluster, each writing a
+ ledger to the scratchpad and returning only a count and the ledger path. Per claim:
+ `file:line`, verbatim claim, class, what source would settle it, and a flag for
+ suspected hard-earned evidence. Agents return no judgments, so nothing arrives as a
+ verdict that would have to be unwound.
+2. **Verification (mine).** Every claim checked against source myself: scripted checks for
+ paths/interfaces/numbers, code reading for behavior, read-only `ssh ci.lan` for rig
+ state (`ls`, `--help`, `which`, `lspci`, `lsusb`, `hil_lock.py status`, `sudo -l`,
+ `uname -r` — no locks, no flashing, no `uhubctl`, no recovery). Nothing acted on is
+ taken on an extractor's word.
+3. **Cross-doc consistency (mine).** Build a rule inventory — board locks, timeouts,
+ output contracts, retry policy, config selection, forcing — and diff every place each
+ rule is stated. No per-file agent can do this pass; it is where the `hil-operator`
+ failure lived.
+4. **Edits.** Delete only what is refuted by source, restates the command it precedes, or
+ duplicates a rule that has a canonical home elsewhere (keep one, reference it). Keep
+ every claim source confirms that changes behavior, every hard-earned observation, and
+ the "why" behind non-obvious rules. Structure stays as is.
+5. **Gate.** Re-run the path and interface scans; `check.sh` on every workflow; `bash -n`
+ and `py_compile` on all 8 helper scripts; the four `test/hil` suites;
+ `pre-commit run --all-files`.
+
+## Extraction clusters
+
+| # | Cluster | Lines |
+|---|---|---|
+| 1 | `.claude/agents/*.md` (7 files) | 313 |
+| 2 | `.claude/workflows/*.js` + `check.sh` | 659 |
+| 3 | `hil`, `hil-pool-check` | 223 |
+| 4 | `usb-kernel-recover`, `usb-kernel-debug` + 2 scripts | 253 + scripts |
+| 5 | `target-debug`, `esp-target-debug` | 496 |
+| 6 | `usbtest`, `usbmon`, `usb-sniffer` + `usbcap.sh` | 382 + script |
+| 7 | `etm-trace` + `boards.md` + 2 scripts | 203 + files |
+| 8 | `build-doc`, `code-size`, `pvs`, `make-release`, `read-doc`, `pre-pr` + 2 scripts | 345 + scripts |
+| 9 | `CLAUDE.md` | 139 |
+
+## Deliverables
+
+Commits split by surface (agents / workflows / skills / CLAUDE.md) so review stays
+tractable, on `claude/claude-doc-audit`. A findings report covering every REFUTED claim
+with its citation, and every earned-knowledge-vs-code disagreement found in pass 2.
+
+A refuted claim whose *code* is the wrong half does not get a silent code edit: it becomes
+a handoff doc under `docs/superpowers/followup/`, per the repo's deferred-work rule.
+
+## Success criteria
+
+- Every falsifiable claim in scope carries a verdict with a citation.
+- No claim that current source refutes survives in the tree.
+- No hard-earned observation is deleted; stale rig state is re-derived or turned into a
+ derivation recipe.
+- No rule is stated in two places with two different meanings.
+- The gate in pass 5 passes.
diff --git a/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md b/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md
new file mode 100644
index 000000000..799c83c23
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-19-ci-build-family-filter-design.md
@@ -0,0 +1,522 @@
+# PR-scoped CI selection: promoting hil_select to tools/ci_select.py
+
+**Date:** 2026-08-19
+**Branch:** `build-filter`
+
+## Motivation
+
+Every PR builds every example on one board per family, on both CI providers: **74 legs /
+2494 example-builds** on the GitHub Actions `cmake` job, and 129 family-legs per build system
+on CircleCI (which runs cmake *and* make, plus clang/IAR). Most PRs touch one port, one class,
+or one example, and a `hid_host.c` change cannot break an MSC device example on msp430.
+
+`hil-build` is worse in a different way: it builds **1702 example-builds** (37 board-builds ×
+46 examples, `--target all`) to run a test suite that needs at most **515**. The HIL example
+universe is only 21 of the 46 examples in tree, and the median board needs 15 of them.
+
+`test/hil/helper/hil_select.py` already maps a PR diff to affected boards and per-board test
+lists for HIL, and already owns both mappings the build matrix needs: port-to-family, and
+class-macro-to-example
+(`docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md`). That design listed
+"scoping the non-HIL build jobs" as an explicit non-goal; this is that follow-up.
+
+## Goal / non-goals
+
+**Goal:** promote the selector to a repo-wide `tools/ci_select.py` whose single classification
+of a diff drives **all three** CI axes from one rule table — which families to build, which
+example targets to build on each, and which rig boards run which tests — wired into
+`ci_set_matrix.py` and `hil_ci_set_matrix.py` so both providers and the rig filter from one
+source. Scoping applies to `pull_request` events only; push, release and `workflow_dispatch`
+keep the full matrix.
+
+**Non-goals:**
+- Variant-level or board-level selection below one-board-per-family on the build axis (all
+ variants of a selected HIL board still build and run).
+- Changing `hil_test.py` behaviour. The selector only *composes* existing `-b` / `-bt` args.
+- Changing which tests HIL decides to run. The HIL board/test decision is preserved except for
+ the single rule-7 change called out below.
+
+## The rule table
+
+One classification, three outputs. Every rule yields build families, build examples, and HIL
+boards/tests. Pairs are unioned **per family** (build) and **per board** (HIL), so a mixed diff
+never inflates one axis with another's breadth.
+
+`DEV` = 33 `examples/device/*`, `HOST` = 9, `DUAL` = 3, `TYPEC` = 1, `ALL` = 46.
+`FAM` = the families whose `family.cmake` references the changed path (CMake only — see below).
+"roster boards" = boards on `test/hil/{tinyusb,hfp}.json`.
+
+| # | Changed path | Build families | Build examples | HIL boards → tests |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| 1 | `docs/`, `.claude/`, `*.md`, `*.rst`, `LICENSE` | — | — | — |
+| 1b | `.gitignore`, `.clang-format`, `.idea/**`, `test/{fuzz,unit-test}/**`, `test/hil/test/**`, non-build `.github/**`, packaging manifests | — | — | — |
+| 2 | `test/hil/**` (not `test/hil/test/**`) | — | — | all boards → all tests |
+| 2b | `tools/metrics.py`, `.github/scripts/metrics_*.py` | `ALL` (unchanged — `tinyusb_metrics` runs `metrics.py` as a build target) | `ALL` | — (nothing on the rig runs it) |
+| 3 | `src/portable/<port>/dcd_*`, `*_device.[ch]` | `FAM` | `DEV`+`DUAL` | `FAM`'s device-role boards → device+dual tests |
+| 4 | `src/portable/<port>/hcd_*`, `*_host.[ch]` | `FAM` | `HOST`+`DUAL` | `FAM`'s host-role boards → host+dual tests |
+| 5 | `src/portable/<port>/**` (anything else) | `FAM` | `ALL` | `FAM`'s boards → all their tests |
+| 5b | `src/portable/<port>/**` where `FAM` is empty | — | — | — (empty resolves to nothing on BOTH axes) |
+| 6 | `hw/bsp/<family>/**` | that family | `ALL` | that family's boards → all tests (a `boards/<board>/` path narrows to that board) |
+| 7 | `hw/mcu/<vendor>/**` | `FAM` — empty resolves to nothing (maintainer ruling) | `ALL` | `FAM`'s boards → all tests; empty resolves to nothing (maintainer ruling) ⚠ *see below* |
+| 8 | `src/class/<cls>/*_device.[ch]` | `ALL` | examples enabling `CFG_TUD_<CLS>` | device-role boards → HIL tests enabling `CFG_TUD_<CLS>` |
+| 9 | `src/class/<cls>/*_host.[ch]` | `ALL` | examples enabling `CFG_TUH_<CLS>` | host-role boards → HIL tests enabling `CFG_TUH_<CLS>` |
+| 10 | `src/class/<cls>/**` (shared header) | `ALL` | either, **plus include-edge classes** | both roles → same, plus include-edge classes |
+| 11 | `src/device/**` | `ALL` | `DEV`+`DUAL` | device-role boards → device+dual tests |
+| 12 | `src/host/**` | `ALL` | `HOST`+`DUAL` | host-role boards → host+dual tests |
+| 12b | `src/typec/**` | `ALL` | examples enabling `CFG_TUC_ENABLED` | — (no rig board runs a typec test) |
+| 13 | `examples/<role>/<name>/**` | `ALL` | just `<name>` | if `<name>` is a HIL test: all boards → that test; else nothing |
+| 14 | `examples/device/board_test/**` | `ALL` | just `board_test` | all boards → all tests (HIL parking firmware) |
+| 15 | `examples/build_system/**`, `examples/CMakeLists.txt`, `examples/<role>/CMakeLists.txt` | `ALL` | `ALL` | all boards → all tests |
+| 16 | `src/common/`, `src/osal/`, `src/tusb.[ch]`, `src/tusb_option.h`, `tools/{build,build_utils,ci_select}.py`, `tools/cmake/**`, `src/CMakeLists.txt`, `src/tinyusb.mk`, `hw/bsp/{family_support.{cmake,mk},family_rules.mk,zephyr_board_aliases.cmake,board.c,board_api.h,ansi_escape.h}`, `.github/**`, `.circleci/**` | `ALL` | `ALL` | all boards → all tests |
+| 16a | `lib/<name>/**` | `ALL` | examples whose own `CMakeLists.txt`/`Makefile` names `lib/<name>` | those examples that are HIL tests, on all boards; empty resolves to nothing |
+| 16b | `tools/get_deps.py` | families whose `deps_mandatory`/`deps_optional` entries changed | `ALL` | those families' boards → all tests; a logic change, an `'all'` entry, no base content or a changed token naming no family → full |
+| 17 | anything unclassified (no tracked file reaches this — TestNoTrackedFileIsUnclassified) | `ALL` | `ALL` | all boards → all tests (fail-open) |
+
+**Rule 2 is deliberately asymmetric.** A `test/hil/**` change is invisible to the family matrix
+but is exactly what the rig exercises, so it builds nothing and runs everything.
+
+`test/hil/test/**` is carved out to rule 1b: it holds the harness's own unit tests, which
+nothing on the rig runs (pre-commit does, and `build.yml` runs `test_ci_select.py` as the
+gate before trusting a selection). A bare `test/hil/` prefix was booking the full 27-board
+rig for diffs that cannot reach it. The carve-out is a claim about that directory's
+contents, so a test pins its file list: add anything the rig reads and it fails.
+
+**Rule 7 is the one HIL-side behaviour change in this design.** Today `hw/mcu/` sits in
+`hil_select`'s `_FULL_RE` and forces the full HIL matrix. Since the build axis now resolves
+those paths to a family through the same scan, forcing full on the rig is inconsistent. The
+path fires rarely — 4 commits in 3 years — so this is low-risk either way; if you would rather
+keep the HIL view untouched, rule 7's HIL column becomes "all boards → all tests" and nothing
+else in this design changes.
+
+Rules 8–10 reuse machinery `hil_select` already has — `class_macros`, `_config_enables`,
+`class_include_edges` — applied over all 46 examples' `src/tusb_config.h` for the build axis
+and over the HIL test list for the HIL axis. The include edges are why an `audio.h` change also
+selects the MIDI examples (`midi{,2}_{device,host}.h` include `class/audio/audio.h`) and a
+`cdc.h` change the net one.
+
+### Buildability post-filter (build axis)
+
+After the pairs are unioned, every `(family, examples)` pair is pruned with
+`build_utils.skip_example(example, <family's first board>)` — the same `skip.txt` / `only.txt`
+data CMake's `family_filter` uses (40 `skip.txt`, 13 `only.txt` in tree). Examples the family
+cannot build are dropped; a family left with none is dropped entirely.
+
+This is where most of the host-side saving comes from: only 23 of 75 CI families can build
+`host/bare_api` at all, and 2 can build `typec/power_delivery`.
+
+### Measured effect
+
+GHA `cmake` job, baseline **74 legs / 2494 example-builds**; `hil-build`, baseline **1702
+example-builds** across 37 board-builds.
+
+| PR shape | Build legs | Build ex-builds | HIL boards | hil-build ex-builds |
+| ---------------------------- | ---------: | --------------: | ---------: | ------------------: |
+| `dcd_rp2040.c` | 1 | 35 | 2 | 32 |
+| `hcd_max3421.c` | 1 | 10 | 7 | 36 |
+| `hw/bsp/stm32f4/**` | 1 | 45 | 1 | 15 |
+| `dcd_dwc2.c` | 20 | 646 | 10 | 184 |
+| `hid_host.c` | 24 | 68 | — | — |
+| `msc_host.c` | — | — | 9 | 40 |
+| `usbh.c` | 25 | 217 | 10 | 57 |
+| `msc_device.c` | 74 | 350 | — | — |
+| `cdc_device.c` | 74 | 588 | 27 | 192 |
+| `examples/device/cdc_msc/**` | 73 | 73 | 25 | 60 |
+| `usbd.c` | 74 | 2297 | 27 | 472 |
+| `src/common/**` (full) | 74 | 2494 | 30 | 515 |
+| `test/hil/**` only | 0 | 0 | 30 | 515 |
+
+The full-matrix row is the headline for `hil-build`: even with **no** PR narrowing, per-board
+example selection takes it from 1702 to 515.
+
+### Why "empty means empty"
+
+Both views answer an empty `FAM` the same way (rule 5b): nothing. The HIL view used to force
+the full 30-board rig there, on the theory that an empty result might be a scan miss — but the
+build view answered the identical condition with zero families for the same path, so the rig
+ran every board to validate a file that nothing compiled. In the build view that theory costs
+74 legs, and the
+evidence does not support it: of the 28 `src/portable/*/*` directories, **26 resolve to at
+least one family**. The two that do not are both real orphans as far as CI is concerned:
+`microchip/pic` (only `dcd_pic.c` and a README, with no `hw/bsp/pic` family at all) and
+`microchip/pic32mz` (`hw/bsp/pic32mz` has only a `family.mk`, and `pic32mz` is in neither
+provider's family list, so no CI job builds it today). A file no CI job compiles cannot be
+validated by building anything.
+
+The safety this gives up is recovered structurally: a unit test asserts every
+`src/portable/*/*` and every tracked `hw/mcu/<vendor>` resolves to ≥1 family, with an explicit
+allowlist of known orphans (`microchip/pic`, `microchip/pic32mz`). Adding a port without
+wiring a family then fails
+pre-commit instead of silently building nothing on every later PR. Same enforcement style as
+the existing `test_hil_util.BottomLayer` structural tests.
+
+Fail-open survives where it belongs: an *unclassified* path or any exception widens to `ALL` on
+every axis.
+
+### A class no example enables selects nothing
+
+`src/class/bth` is the live instance: no example's `tusb_config.h` sets `CFG_TUD_BTH`, so
+rules 8-10 resolve to no examples and a bth-only PR builds nothing and runs nothing. That is
+the empty-means-empty ruling applied to classes, and it is deliberate — nothing compiles the
+file, so nothing can validate it, and the master-push build is the net.
+
+Worth stating plainly because the exposure changed: GHA used to rebuild everything for such
+a PR by accident, through the empty-`families` bug in `build.yml`. With that fixed, both
+providers now correctly build nothing, so `tud_bt_*` can be broken by a green PR.
+`TestClassesWithNoEnablingExample` pins the set to `{bth}` so a second class cannot enter
+this state unnoticed.
+
+### Why `hw/mcu/**` is rule 7 and not "full"
+
+`hw/mcu` is overwhelmingly dependency territory — `tools/get_deps.py` has 87 entries under it,
+and those paths are gitignored, so they can never appear in a diff. Only 51 files survive
+in-tree, touched 4 times in 3 years, and they resolve through the same scan the ports use:
+
+| Tracked directory | In `get_deps`? | Resolves to |
+| ----------------------------- | ------------------------------------------- | ----------- |
+| `hw/mcu/dialog/` (`da1469x`) | **no** — real in-repo MCU support, 21 files | `da1469x` |
+| `hw/mcu/nordic/` (`nrf5x`) | beside the `nrfx` dep | `nrf` |
+| `hw/mcu/sony/` (`cxd56`) | beside the `spresense-exported-sdk` dep | `cxd56` |
+| `hw/mcu/bridgetek/` (`ft9xx`) | beside the `ft90x-sdk` dep | `ft9xx` |
+
+Rule 7 is therefore not a mechanism of its own — it is rules 3–5's scan pointed at a second
+tree, because `src/portable/<port>` and `hw/mcu/<vendor>` ask the same question.
+
+Unlike the port rule, an `hw/mcu` path that resolves to no family contributes *nothing* on
+either axis (maintainer ruling): if no family's build references it, no build compiles it. The
+table above is kept honest by `test_tracked_mcu_vendors_resolve`, which fails pre-commit if a
+tracked vendor directory stops resolving.
+
+### Why `lib/**` is rule 16a and scanned per example
+
+Its tracked contents (`SEGGER_RTT`, `networking`, `rt-thread`, `embedded-cli`, 22 commits in
+3 years) are wired in at `examples/build_system` and per-example `CMakeLists.txt`, not per
+family — so the family scan the ports use is the wrong instrument here: it would *wrongly*
+narrow `SEGGER_RTT` to the three families that name the path in their `family.cmake`, while
+the path is not compiled by any of them by default (it is reached only through `LOGGER=rtt`).
+That scan stays applied to `src/portable/` and `hw/mcu/` only.
+
+Rule 16a asks the per-example question instead (maintainer ruling: only the examples that use
+the lib need building): `lib_examples()` reads each example's own `CMakeLists.txt` and
+`Makefile` and keeps the ones naming `lib/<name>` at a directory boundary. Every family stays
+in play — any of them can build those examples — while the example list collapses:
+
+| Tracked lib | Examples that build it | HIL tests among them |
+| -------------- | ----------------------------------------------------------- | -------------------- |
+| `embedded-cli` | `host/msc_file_explorer`, `host/msc_file_explorer_freertos` | both |
+| `networking` | `device/net_lwip_webserver` | none (test disabled) |
+| `SEGGER_RTT` | — | — |
+| `rt-thread` | — | — |
+
+`SEGGER_RTT` and `rt-thread` resolve to nothing, and "empty means empty" applies: no CI build
+compiles them, so there is nothing to validate by building.
+
+### Why `tools/get_deps.py` is rule 16b
+
+`deps_mandatory` / `deps_optional` are data: `path -> [url, commit, 'fam1 fam2 ...']`. A commit
+bump therefore affects exactly the families listed in that entry, and building the other 70+ is
+pure waste. `get_deps_changed_families()` parses both sides of the file with `ast` (never
+`exec` — this is PR content), diffs the two dict literals **separately**, and unions the family
+tokens of every added, removed or edited entry, from **both** sides (a removed entry has only a
+base side; an edited family list must cover the families that lose the dep as well as the ones
+that gain it). Separately, because merging the dicts before diffing hides a *move* between
+`deps_mandatory` and `deps_optional` — the value is untouched, but mandatory deps are fetched
+for every family, so demoting one stops families fetching it.
+
+It falls open to the full matrix whenever the entries are not the whole answer:
+
+* anything outside the two dict assignments differs — a logic change to `get_deps` can change
+ what every family fetches (compared as `ast.dump(..., annotate_fields=False)` of the module
+ with those two assignments removed, so comments and reformatting alone are not a logic
+ change);
+* an `'all'` entry (every mandatory dep) changed;
+* the file will not parse;
+* there is no base content: `--diff-file` mode has no git, so no merge-base blob;
+* a changed entry carries a family token that names no `hw/bsp/<dir>` and is not one of the
+ known aliases. "Changed but unmappable" is not "nothing changed": reading it as the
+ latter empties the whole build matrix for a dep bump.
+
+The six known aliases (`sam3x`, `samd21`, `samd51`, `same5x`, `stm32l1`, `stm32l5`) are
+pinned in `_DEPS_ALIAS_TOKENS` and select nothing. `get_deps` matches a token against a
+requested family name verbatim (`f in entry[2].split()`), so these tokens match nothing
+there either — four are pre-rename spellings listed beside the current name in the same
+entry, and two name no family in the tree. (`fc100s` and `spresense` were on this list
+until they were corrected in `get_deps.py`; those two were the only ones that left a
+real dep unreachable for its own family.) A seventh appearing fails `TestOrphanInvariant`.
+
+## Component: `tools/ci_select.py`
+
+`git mv test/hil/helper/hil_select.py tools/ci_select.py` (history preserved). The HIL
+classifier is unchanged apart from rule 7; a second, independent build classifier is added
+beside it. One diff read, two classifiers, one unit suite.
+
+```
+python3 tools/ci_select.py --base <ref> [--diff-file <path>] [CONFIG.json ...]
+```
+
+`configs` becomes `nargs='*'`. With rosters it emits everything it emits today plus the new
+keys; with none it emits only the build view, so CircleCI never needs to know HIL exists.
+
+```json
+{
+ "full": false,
+ "boards": {"raspberry_pi_pico": "all"},
+ "families": ["rp2040"],
+ "args": {"tinyusb.json": "-b raspberry_pi_pico"},
+ "args_flasher": {"tinyusb.json": {"openocd": "-b raspberry_pi_pico"}},
+ "hil_examples": {"raspberry_pi_pico": ["device/cdc_msc", "device/board_test"]},
+ "build": {
+ "full": false,
+ "families": ["rp2040"],
+ "family_examples": {
+ "rp2040": ["device/cdc_msc", "device/hid_composite", "dual/dynamic_switch"]
+ }
+ },
+ "reasons": ["src/portable/raspberrypi/rp2040/dcd_rp2040.c: port rp2040 -> ..."]
+}
+```
+
+`build.families` is the build family axis. `build.family_examples` maps a family to its example
+list; **a family absent from the map builds all its examples**, so the common "narrow families,
+all examples" case carries no payload. `build.full` true means no build narrowing at all.
+
+`hil_examples` is the new HIL build axis: per roster board, the examples `hil-build` must
+produce. It is `board_tests(board)` — which the selector already computes — **plus
+`device/board_test`**, which `hil_test.py` flashes to park every board at each variant boundary
+and at end-of-board teardown (`hil_test.py:1798`, `:1866`). It is emitted even when
+`full: true`, because the HIL example universe is 21 of 46 examples regardless of any diff.
+
+All pre-existing keys keep their exact meaning, so `.github/scripts/hil_ci_set_matrix.py`, the
+HIL legs in `build.yml` and `.claude/skills/pre-pr/SKILL.md` need only a path update.
+
+### Shared helper change
+
+`port_families(port_dir, repo_root)` generalizes to a path-to-families reference scan over a
+second tree (`hw/mcu/`). Its existing **CMake-only** behaviour is kept unchanged and is now the
+rule for every axis: it scans `hw/bsp/*/family.cmake` plus the espressif component
+`CMakeLists.txt`, and never `family.mk`.
+
+CMake is the first-class build system; Make follows whatever CMake decides. A family that
+CMake does not wire up to a port is not a consumer of that port, and the Make legs on CircleCI
+build the same families CMake does. Scanning `family.mk` as well would only ever *widen* the
+selection to families CMake never builds, which is coverage nobody asked for — and it would
+resolve `microchip/pic32mz` to a family that appears in no CI family list.
+
+One consequence to keep in view: because HIL and build now share one scan, there is no
+per-caller flag, no second cache key, and no way for the two axes to disagree about which
+families own a port.
+
+**Boundary matching.** A directory reference must match at a directory boundary — a trailing
+`/` *or* end-of-token — not as a bare substring. Both traps are live: `hw/bsp/nrf/family.cmake`
+writes `${TOP}/hw/mcu/nordic/nrf5x` with no trailing slash, while the existing port scan
+requires a trailing `/` precisely to stop `microchip/pic` matching `microchip/pic32mz`.
+
+### Move fallout
+
+All mechanical, all one-line:
+
+| File | Change |
+| ---------------------------------- | ----------------------------------------------------------------- |
+| `tools/ci_select.py` | `sys.path` walk 4 levels → 2 |
+| `test/hil/hil_ci.sh` | drop from the scp list (nothing on the rig imports it) |
+| `test/hil/test/test_hil_select.py` | rename to `test_ci_select.py`, import path |
+| `test/hil/test/test_hil_util.py` | `BottomLayer` stdlib-closure allowlist + module list |
+| `.pre-commit-config.yaml` | both hooks (`hil-select-test` → `ci-select-test`, `files:` globs) |
+| `.github/workflows/build.yml` | selector path, step name |
+| `.claude/skills/pre-pr/SKILL.md` | selector path |
+
+The test file stays in `test/hil/test/` — it still consumes the rig rosters and `hil_util`.
+
+The selector gains one non-stdlib-but-local import: `tools/build_utils.skip_example` for the
+buildability post-filter. `build_utils` imports only `subprocess`, `pathlib` and `re`, so the
+stdlib closure the bare GitHub runner depends on is preserved; `BottomLayer` must be extended
+to cover it.
+
+Because a wrong parents-count already broke this module once (there is a comment in the source
+recording it), the moved module gets a guard test asserting its derived repo root contains
+`src/` and `hw/bsp/`.
+
+## Component: `tools/build.py --example`
+
+`build.py` has no example filter today. `-T/--target` exists and maps to
+`cmake --build --target <name>`, but it hard-fails on a target that does not exist, and absent
+targets are routine (40 `skip.txt`, 13 `only.txt`).
+
+New repeatable `-e/--example <role>/<name>`:
+
+- Default (none given) keeps today's behaviour exactly: `--target all`.
+- Given, each board's list is intersected with `build_utils.skip_example(example, board)`, then
+ passed as one `--target <name>` per example. Example target names are the directory names and
+ are unique across all four roles (verified: 46 examples, zero collisions).
+- A board whose intersection is empty is reported **skipped**, not failed.
+- `--target tinyusb_metrics` must stay last so metrics run after the examples that feed them.
+- The espressif path already builds per example via `get_examples` + `skip_example`; it takes
+ the same filter.
+
+Both the family matrix and `hil-build` use this one flag.
+
+## CI wiring
+
+### `.github/scripts/ci_set_matrix.py`
+
+Two mutually exclusive optional flags. **Output shape is unchanged** — `{toolchain: [family]}`,
+just fewer families. With no flags the output is byte-for-byte today's, so push, release and
+`workflow_dispatch` are untouched.
+
+| Flag | Caller | Behaviour |
+| --------------- | -------- | -------------------------------------------------------- |
+| `--select JSON` | GHA | consumes the selector JSON the workflow already computes |
+| `--base REF` | CircleCI | runs `tools/ci_select.py` itself |
+
+`build.full` true, or any exception, prints the full matrix with a warning on stderr.
+
+### `.github/scripts/hil_ci_set_matrix.py`
+
+Already takes `--select` and already scopes boards. It additionally appends `-e <example>` per
+board from `hil_examples`, so each `hil-build` entry builds only what its board will run plus
+`board_test`. When `hil_examples` is absent (hand-runs), it falls back to today's `--target all`.
+
+### The example map is a side channel, not a matrix entry
+
+The build example list deliberately does **not** ride inside the family matrix entry string. On
+CircleCI the `family` parameter is also passed to `python tools/get_deps.py
+<< parameters.family >>` and tested with `if [ << parameters.family >> == "rp2040" ]` — a value
+carrying `-e` flags breaks both — and CircleCI matrix parameters form a cartesian product, so a
+parallel `example-args` parameter would multiply the jobs rather than zip with them.
+
+So `build.family_examples` travels as one JSON blob and each build job resolves its own entry:
+
+- **GHA:** `set-matrix` exposes it as an output; `build_util.yml` gains an optional
+ `example-map` input (default `''`); a step resolves `-e` flags for `matrix.arg` with `jq`.
+- **CircleCI:** `set-matrix` writes `example_map.json` and `persist_to_workspace`s it; the
+ `build` job gains `attach_workspace` and resolves the same way.
+
+Consequences of keeping the matrix shape: the metrics artifact name stays `metrics-<family>`,
+and CircleCI's generated `config2.yml` does not inflate to one entry per family. `hil-build`
+needs none of this — its matrix entries are already compound per-board strings from
+`hil_ci_set_matrix.py`, so `-e` flags go straight in.
+
+### `.github/workflows/build.yml`
+
+The existing `HIL selection (PR only)` step in `set-matrix` is already gated on
+`pull_request` — exactly the gate wanted. It is renamed, repointed at `tools/ci_select.py`, and
+its `select` output is threaded into `ci_set_matrix.py --select`, so the filter costs zero extra
+selector invocations.
+
+`build_util.yml`'s `if: inputs.build-args != '[]'` already skips a toolchain leg whose list is
+empty, and a partially-skipped matrix aggregating to success is the documented pattern
+`hil-build` already relies on. When every leg is empty (a `test/hil`-only PR), the `cmake` job
+has nothing to build. Accepted: GitHub treats a skipped job as satisfying a required status
+check, and HIL is unaffected because `hil-build` is a separate matrix. `code-metrics` still
+runs (`!cancelled()` plus `cmake` success-or-skipped) and posts a "built no families on this
+push" marker, so the sticky size comment never shows a stale table from an earlier push.
+
+### `.circleci/config.yml`
+
+The `set-matrix` job passes `--base origin/master` when `CIRCLE_PULL_REQUEST` is set, after
+`git fetch --no-tags origin master || true`; unfiltered otherwise. CircleCI does not expose the
+PR base branch, so `master` is assumed — true for essentially every tinyusb PR, and any ref or
+clone problem falls back to the full matrix.
+
+Two fixes the GHA side does not need:
+
+- `gen_build_entry` must **skip** a toolchain whose family list is `[]`. An empty matrix
+ parameter is a hard CircleCI config error, not a skipped job.
+- `BUILD_ALIASES` must collect only aliases that were actually generated, or `code-metrics`'
+ `requires:` names a job that does not exist.
+
+## Code metrics
+
+`tools/metrics.py` averages per-file sizes across every build, and the per-family
+`metrics-<family>` artifact stores only that average — over whichever examples were built. Both
+build axes therefore break the comparison: a 3-family PR against master's 64-family average,
+and an 11-example average against master's 46-example one.
+
+The fix is to make the artifact carry per-example detail and compare the intersection.
+
+1. **`metrics.py combine --by-example`** additionally writes `metrics_by_example.json`,
+ `{example: {files: [...]}}`. The example name is the map.json's parent directory
+ (`<build>/<role>/<example>/*.map.json`).
+2. `examples/CMakeLists.txt`'s `tinyusb_metrics` target emits both files; `build_util.yml`
+ uploads both under the existing `metrics-<family>` artifact name.
+3. `combine` learns to expand a by-example JSON into one data entry per example and an
+ `--only-examples` filter, so a subset can be averaged on demand.
+4. `code-metrics` computes the **intersection of `(family, example)` pairs present on both
+ sides**, averages each side over exactly those pairs, and compares. Dropped pairs are named
+ in the PR comment. An empty intersection skips the compare with an explicit note.
+
+`search_artifacts: true` is required on the base-side download: a docs-only master push
+produces no per-family artifacts — which is why `metrics-carry-forward` exists for the
+aggregate — so per-family baselines may come from different master runs. That is still a valid
+per-family baseline.
+
+Today's `metrics-tinyusb` aggregate keeps being produced for the unfiltered path, releases and
+`metrics-carry-forward`. The filtered path never falls back to it — that is precisely the
+mismatched compare this section exists to prevent. `hil-build` uploads no metrics, so its
+narrowing does not touch any of this.
+
+For narrow PRs this is sharper than today: a `dcd_rp2040` PR's size delta stops being diluted
+by a 64-family, 46-example average.
+
+**Size check the plan must run first:** the by-example JSON is ~46× the entries of today's
+average. If it proves too large as an artifact, drop per-symbol detail from the by-example file
+(sizes only) — symbols are only needed in the aggregate. The plan must also verify that
+`dawidd6/action-download-artifact@v11` supports `name_is_regexp`; the fallback is a
+`gh run download` loop.
+
+## Testing
+
+Extended in `test/hil/test/test_ci_select.py` (stdlib-only, ~0.1 s, already a pre-commit hook
+and already gating CI's selector step):
+
+- One case per rule 1–17, asserting all three outputs.
+- Per-family union: a mixed diff (`dcd_rp2040.c` + `cdc_device.c`) gives `rp2040` the device
+ list and every other family the CDC list — not the cross product of both.
+- Include edges: an `audio.h` change selects the MIDI examples; a `cdc.h` change the net one.
+- `hil_examples` always contains `device/board_test` for every selected board, including when
+ `full: true`, and is otherwise exactly `board_tests(board)`.
+- `hil_examples` never exceeds the 21-example HIL universe.
+- The scan is CMake-only: a port referenced solely from a `family.mk` (`microchip/pic32mz`)
+ resolves to no family, and no `family.mk` is ever read.
+- Boundary matching: `microchip/pic` does not inherit `microchip/pic32mz`'s families, and
+ `hw/mcu/nordic/nrf5x` resolves despite having no trailing slash at its reference site.
+- Buildability post-filter: `typec/power_delivery` prunes to 2 families, `host/bare_api` to 23.
+- Structural invariant: every `src/portable/*/*` and every tracked `hw/mcu/<vendor>` resolves
+ to ≥1 family, allowlist `{microchip/pic, microchip/pic32mz}`.
+- Every name in `build.families` is a real `hw/bsp/<dir>`; every example name on either axis is
+ a real `examples/<role>/<name>` directory.
+- Repo-root guard for the moved module.
+- `ci_set_matrix.py`: no flags → byte-identical to today; `--select` with `build.full` →
+ identical; `--select` narrow → a subset; malformed `--select` → full plus a warning.
+- `hil_ci_set_matrix.py`: no `hil_examples` → today's args byte-for-byte; with it → `-e` flags
+ appended per board, `board_test` always present.
+- `build.py`: `-e` with an example the board skips builds nothing and reports skipped, not
+ failed; no `-e` still passes `--target all`.
+
+## Known gaps
+
+- **CircleCI size comparison.** CircleCI stores only the combined `metrics.json`, so the
+ intersection compare is unavailable there; when filtered it prints a note and copies
+ `metrics.md`. Its `metrics_compare.md` is a stored artifact that nothing reads in review — the
+ PR comment comes from GHA. Making CircleCI store per-example metrics is a follow-up.
+- **HIL re-run attempts.** A re-run spec is a subset of the original selection, so the
+ firmware `hil-build` produced already covers it. This holds only while re-run specs stay
+ subsets; a future "re-run with extra tests" feature would need `hil-build` re-run too.
+- **Membrowse** receives rows for fewer families and fewer examples on filtered PRs. If that
+ service misbehaves, the escape hatch is keeping the membrowse upload leg unfiltered.
+- **`typec/power_delivery`** is reached only through rules 5 and 13 (`src/portable/st/typec`
+ has neither a `dcd_` nor an `hcd_` prefix, so it selects `ALL` examples on its 5 families,
+ which the post-filter then prunes to 2). A dedicated typec rule is possible later; the
+ post-filter already makes it cheap.
+- **A `test/hil`-only PR reports `cmake` as skipped** rather than passing. Accepted;
+ revertible with a one-family floor if branch protection turns out to disagree.
+ `code-metrics` still runs in that case: with no `cmake-build/*/metrics.json` to
+ aggregate it writes `_Code-size comparison skipped: PR selection built no families
+ on this push._` and posts that as the sticky comment, so the size section reflects
+ THIS push instead of keeping the previous one's table.
+- **`microchip/pic32mz` builds nothing.** The scan is CMake-only and `hw/bsp/pic32mz` ships
+ only a `family.mk`, so a change there selects no family. That matches reality — `pic32mz` is
+ in neither provider's family list — but it means the port is unbuilt by CI whether or not
+ this design lands. Giving it a `family.cmake` is the fix, and is out of scope here.
+- **Seven bsp families are in no CI toolchain today** (`espressif`, `efm32`, `same7x`,
+ `cxd56`, `f1c100s`, `pic32mz`, `py32f0`); the intersection drops them, matching current
+ behaviour. This change does not alter that.
diff --git a/docs/superpowers/specs/2026-08-21-hil-report-module-design.md b/docs/superpowers/specs/2026-08-21-hil-report-module-design.md
new file mode 100644
index 000000000..41e7000b7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-21-hil-report-module-design.md
@@ -0,0 +1,144 @@
+# hil_report.py: one owner for the HIL report document
+
+**Date:** 2026-08-21
+**Branch:** `hil-report` (continues the report-unification work already on it)
+
+## Motivation
+
+`hil_report.json` and `hil_report.md` are now one document rendered two ways, but the code that
+produces, renders, merges and reads that document is spread across three modules:
+
+| Module | Report-related content |
+|---|---|
+| `hil_test.py` | `REPORT_CELL`, `BOUNDARY_CELL`, `REPORT_MD`, `REPORT_JSON`, `render_matrix`, `render_report`, `write_report`, `mark_report_abandoned`, `accumulate_report` |
+| `helper/hil_health.py` | `write_timeout_report` — composes its own markdown |
+| `helper/hil_summary.py` | `cell_state`, `variants_of`, `summarize`, CLI |
+
+Two concrete defects follow from that spread.
+
+**One classifier, two copies.** `hil_test.py:1966` (`cell_kind`, keyed off `REPORT_CELL`) and
+`hil_summary.py:34` (`cell_state`, with its own re-typed `FAIL_ICON, SKIP_ICON = '❌', '⚪'`)
+implement the same rule. The latter's docstring says it is *"the EXACT classifier hil_test.py's own
+tally uses"* — the duplication was noticed and documented as an obligation to keep in sync, rather
+than removed. Change `REPORT_CELL` and the human's table and the agent's verdict silently disagree:
+the markdown says ❌ where the JSON says `pass`. That is the same class of defect this branch
+exists to eliminate, one layer up.
+
+**A writer that cannot render.** `hil_test.py` imports `hil_health`, so `hil_health` cannot import
+`hil_test` back. That is the only reason `write_timeout_report` composes its own markdown instead of
+calling `render_report`, and the only reason the pool-guard fallback is held to a weaker promise
+(same boards and caveat in both artifacts, not byte-identical) while the other four writers are
+exact. The constraint is structural, not essential: a leaf module both can import dissolves it.
+
+## Goal / non-goals
+
+**Goal:** `test/hil/helper/hil_report.py` becomes the single owner of the report document.
+
+**This is NOT purely code motion, and the distinction matters for review.** Measured against
+`master`, `hil_test.py` contains only `render_matrix` and `accumulate_report`. Everything else in
+the new module — `render_report`, `write_report`, `mark_report_abandoned`, `mark_report_no_boards`,
+`_load`, `_write_stuck_over_prior_md`, `cell_state`, and the `scope`/`caveat` plumbing — is NEW
+code, roughly 150 lines of it, and two rounds of review found most of their defects there. Read
+those functions as new, not as relocated. `hil_test.py`'s CLI, arguments and table format do stay
+unchanged.
+
+**Deliberate user-visible changes:**
+1. `hil_summary.py` is deleted; its CLI moves to `hil_report.py`. The documented command becomes
+ `python3 test/hil/helper/hil_report.py <config> -b BOARD [-b BOARD…]`.
+2. `write_timeout_report` re-renders from the merged sidecar instead of stapling its banner above
+ the previous attempt's markdown text. Output improves — one table containing the stuck boards,
+ rather than a fresh banner above a duplicate table — but it is a change (see Testing).
+
+**Non-goals (explicit follow-ups, not this change):**
+- Splitting `accumulate_report`'s `mret` folding from its merge (see "Deliberate wart").
+- The flat `HIL_POOL_TIMEOUT` that does not scale with board count (`hil_test.py:225`).
+
+## Resulting layout (`test/hil/`)
+
+| File | ~Lines | Role |
+|---|---|---|
+| `hil_test.py` | 2390 (−250) | tests + orchestration + CLI |
+| `helper/hil_report.py` (new) | ~400 | the report document: vocabulary, render, write, merge, fold, CLI |
+| `helper/hil_health.py` | ~345 (−53) | killing wedged processes only |
+| `helper/hil_summary.py` | deleted | superseded by `hil_report.py` |
+
+Import graph: `hil_health` is a leaf; `hil_report` → `hil_health` (for `_p`, the
+BrokenPipeError-safe print used on containment paths); `hil_test` → both. No cycles.
+
+## hil_report.py
+
+Stdlib only (`json`, `argparse`, `pathlib`) beyond that one `_p` import. Sections, in order:
+
+**Vocabulary.** `REPORT_MD`, `REPORT_JSON`, `REPORT_CELL`, `BOUNDARY_CELL`, `LOCKED_CELL`.
+`REPORT_CELL` becomes the single source of the status icons; `hil_summary.py`'s `FAIL_ICON`/
+`SKIP_ICON` literals are deleted.
+
+**Classifier.** One `cell_state(v) -> 'pass' | 'fail' | 'skip'`, replacing both `cell_kind` and the
+old `cell_state`. Keeps the surviving docstring's warning that the `pass` arm is load-bearing: a
+passing test may return an unprefixed metric string (`'480.0 MBps'`), while failures are guaranteed
+icon-marked, so classifying unknown shapes as `fail` would publish a green table as a red verdict.
+
+**Render.** `render_matrix(rows_all)`, `render_report(doc)`. Unchanged; `render_matrix`'s inline
+`cell_kind` is replaced by a call to the module-level `cell_state`.
+
+**Write.** `write_report`, `accumulate_report`, `mark_report_abandoned`, `write_timeout_report`.
+Moved verbatim except `write_timeout_report`, which loses its `md_name` parameter (the module owns
+`REPORT_MD`) and renders instead of concatenating.
+
+**Fold.** `variants_of`, `summarize`, and the `main()` CLI from `hil_summary.py`.
+
+## Deliberate wart
+
+`accumulate_report` moves wholesale, keeping its knowledge of `mret`'s worker-result tuple shape.
+The cleaner boundary would split "fold `mret` → rows" (`hil_test`'s domain) from "merge rows → doc"
+(`hil_report`'s), but that rewrites subtle, well-tested logic — stale `board-locked` clearing,
+`BOUNDARY_CELL` dropping, `duration=None` preservation — for a tidier seam. It is a data-shape
+coupling, not an import cycle. Moving it verbatim keeps the motion reviewable as motion.
+
+## The sharp edge
+
+`hil_ci.sh:222-228` stages helper modules by an **explicit scp list**. A new `helper/hil_report.py`
+that is not added there reaches the rig missing, and the run dies with `ImportError` *after*
+`REMOTE_DIR` has already been wiped — so the previous run's report and re-run spec are gone too.
+
+This is already guarded: `test_hil_bounded.py`'s `RemoteStaging.test_import_closure_is_staged_to_the_rig`
+walks the AST import closure from `hil_test.py`, `usbtest.py` and `mtp_test.py` and requires an exact
+scp entry for each file. Adding the module to the list is all this change needs; no new guard is
+warranted, and an earlier draft of this document wrongly claimed none existed.
+
+## Consumers to update
+
+| File | Change |
+|---|---|
+| `test/hil/hil_ci.sh:226` | `hil_summary.py` → `hil_report.py` in the scp list |
+| `.claude/agents/hil-operator.md:71` | the documented command |
+| `.claude/workflows/hil-validate.js:58` | the command the operator is told to run |
+| `.claude/workflows/hil-validate.js:14,17,54,67`, `test-hil-validate.mjs:7` | stale `hil_summary.py` mentions in comments |
+
+No logic in the `.claude` files changes — the operator's return contract
+(`{results, banner, wedged}`) is untouched.
+
+## Testing
+
+New `test/hil/test/test_hil_report.py`. The report-specific classes move there from
+`test_hil_bounded.py` (`CaveatSurvivesAccumulate`, `SummaryFoldsReportToBoards`,
+`ScopeSurvivesInTheJson`, `RenderReportIsPureFunctionOfTheDocument`,
+`EveryExitPathLeavesBothArtifacts`, `AbandonNoticeLandsInBothArtifacts`,
+`MarkdownIsAlwaysARenderingOfTheJson`) and from `test_hil_health.py` (`WriteTimeoutReport`).
+
+Three test changes are substantive rather than mechanical:
+
+1. `WriteTimeoutReport.test_keeps_a_previous_attempts_table` asserts the prior **markdown text**
+ survives. It becomes an assertion that the prior attempt's **rows** survive — the same guarantee
+ against the new representation.
+2. `MarkdownIsAlwaysARenderingOfTheJson` gains a fifth case for the pool-guard fallback, which now
+ satisfies the byte-identical invariant like the other four.
+3. `test_the_pool_guard_fallback_agrees_even_if_it_does_not_render` — the weaker promise — is
+ deleted, because the promise it encoded no longer applies.
+
+Gate: `python3 -m unittest discover -s test/hil/test` at 275 — the current 266, minus the one
+deleted test, plus the fifth invariant case, the scp-list guard, two dual-mode import tests,
+five classifier tests and one pinning that the old entry point is gone — then
+`pre-commit run --all-files`. Because this lands on a
+branch already validated on hardware, it closes with a rig re-check: the invariant check against a
+real report pair and a scoped `--accumulate` run, not the full fleet.
diff --git a/docs/superpowers/specs/2026-08-24-rtt-skill-design.md b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md
new file mode 100644
index 000000000..7a621726f
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-24-rtt-skill-design.md
@@ -0,0 +1,164 @@
+# `rtt` skill — design & decision record
+
+Date: 2026-08-24. Branch: `rttconsole-skill`. Author sessions: lpc4088 handoff
+(measurements), sysview handoff (mechanics + probe matrix), this session
+(verification + decision). User approved promotion and the name `rtt` on
+2026-08-24.
+
+## Decision
+
+Promote SEGGER RTT from an inline technique in `.claude/skills/target-debug/`
+to a standalone skill `.claude/skills/rtt/`, scoped as **transport core +
+console layer**: getting bytes on/off RTT channels over any debug probe, plus
+the bidirectional console tooling the HIL harness ships. Consumer-specific
+layers (SystemView encode/decode/licensing, TU_LOG conventions, debugging
+methodology) stay in their skills and cross-reference.
+
+## Scoring against the promotion criteria
+
+Criteria: `docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md`
+§"Skill vs technique — promotion criteria" (exists only on branch
+`claude/add-systemview-debug`; read via `git show`). Two or more of four
+required. Score: **3/4**.
+
+1. **Ships tooling — yes.** `hil_util.JlinkRtt` (commit d98e77bac: probe
+ selection by serial, dynamic port allocation, non-blocking bidirectional
+ socket, process-group teardown) plus a thin CLI added by this plan.
+ Precedent: `hil` and `code-size` are skills wrapping repo-versioned tools;
+ "recipes over already-installed tools" is what RTT was *before* this code
+ existed (why SWO stayed a technique at 1.5/4 — see `SWO_SKILL_HANDOFF.md`).
+2. **Answers its own routed question — yes.** "Give this board a console /
+ printf I/O with no UART and no VCOM" is asked from harness and bring-up
+ contexts that never load target-debug (whose trigger is *misbehaving
+ firmware*). Measured cost of the missing route: the lpc4088 session burned
+ an hour rediscovering a gotcha already written at target-debug
+ SKILL.md:249-253.
+3. **Carries validation state — yes.** Measured tool matrix (below), 13-board
+ OpenOCD read-path campaign from the sysview cycle, WCH SDI A/B proof,
+ SAMD5x DSU gotcha, lock-porting example, per-probe constraints.
+4. **Long but conditionally relevant — yes.** The transport knowledge is a
+ page+ that most target-debug sessions don't need and harness sessions
+ can't find there.
+
+## Measured evidence the skill must carry
+
+From the lpc4088 session (LPC4088 + LPC-Link2 J-Link fw 611000000, SWD 4 MHz;
+single board — re-verify on more hardware during validation):
+
+- `JLinkExe -RTTTelnetPort <port> -AutoConnect 1`: 6/6 reliable; delivers the
+ buffered boot burst; accepted an 8550-byte write in one call. **The proven
+ standalone path.**
+- Drain rate 24.6 KiB/s (253,127 B / 10.0 s) against a saturating printf
+ firmware that produced 689,896 lines — 0.6 % delivered. RTT console is
+ **drain-limited and lossy under saturation; drops happen at the target**
+ (NO_BLOCK_SKIP, 1 KB default buffer).
+- `JLinkRTTLogger`: 0/6 — "RTT Control Block not found" even given
+ `-RTTAddress`, block plainly readable over SWD. Searches once at attach,
+ never retries. **Never build on it.**
+- `JLinkGDBServer -RTTTelnetPort` with **no GDB client attached**: served the
+ port, never located the control block (this board). target-debug's
+ GDBServer+JLinkRTTClient recipe was proven in flows where GDB attaches, and
+ CLAUDE.md's recipe worked on other parts — treat as per-part variance,
+ document both; do not "correct" either into a flat contradiction.
+- OpenOCD (jaylink) driving this J-Link-firmware probe: transport failure
+ (`LIBUSB_ERROR_TIMEOUT`, `jaylink_swd_io() failed`), probe drops off USB,
+ **physical replug needed** — twice, reproducible. Standing rule: never
+ point OpenOCD at that class of probe (J-Link OB firmware on a debug-probe
+ board like the LPC-Link2). Genuine SEGGER J-Links work under jaylink —
+ routine in the sysview campaigns (metro_m4_express).
+
+From the sysview cycle (branch `claude/add-systemview-debug`, 13-board
+campaign 2026-08-12):
+
+- OpenOCD `rtt setup <exact CB addr> … ; rtt start; rtt server start <port>
+ <ch>` **read path validated** on ST-Link, CMSIS-DAP and J-Link probes
+ (`test/hil/sysview_ci.py`). Exact CB address from
+ `arm-none-eabi-nm <elf> | grep _SEGGER_RTT` beats a full-RAM scan (slower,
+ can mis-hit stale RAM after soft reset).
+- The real transport requirement is **autonomous memory access while the core
+ runs**: ARM memory-AP (zero intrusion), RISC-V SBA where implemented.
+ **WCH QingKe SDI has neither** — Debug Module abstract commands perturb the
+ running core; A/B-proven kill ~1.9 s into USB traffic. Per-transport rule:
+ SDI = halt→read→resume / post-mortem dump only, never live streaming.
+- SAMD5x + OpenOCD: in-session `reset run` via the DSU CPU Reset Extension
+ leaves the core held — attach without reset when the flash step already
+ reset the board (general preference: attach-only capture).
+- Lock porting example: `hw/bsp/ch583/sysview_rtt_lock_wch.h` (QingKe CSR
+ 0x800 brace-scoped save/restore; generic RISC-V lock traps mcause=2).
+- Drain hierarchy: J-Link native > OpenOCD polling; matters only at
+ SystemView bandwidths (workable buffers 2048–8192); console logs never
+ overflow the drain in practice.
+- RTT mechanics for the concepts section: control block `_SEGGER_RTT` (magic
+ "SEGGER RTT") + ring buffers {sName, pBuffer, SizeOfBuffer, WrOff, RdOff,
+ Flags}; the HOST must write RdOff back to drain; modes NO_BLOCK_SKIP (log
+ default) / NO_BLOCK_TRIM / BLOCK_IF_FIFO_FULL (target spins — dangerous in
+ ISRs); post-mortem mode = `SEGGER_RTT_WriteWithOverwriteNoLock` (target
+ drags RdOff, ring holds last N bytes, no live host needed); channel 0 =
+ "Terminal" console, SystemView claims its own "SysView" up-buffer —
+ coexist on one control block.
+
+## Gotchas the skill centralises
+
+Control block exists only after the target's first printf (early reader sees
+nothing; Logger gives up). The console owns the probe: flash and reset before
+opening it; never reset while attached. An undrained NO_BLOCK_SKIP ring holds
+the FIRST KB after boot, not the wedge tail. Always select probes by serial
+(`-USB <sn>` / `adapter serial`) — rigs run several. Two probes wired to one
+SWD header wedge the target.
+
+## v1 backend matrix
+
+| Backend | Read (capture) | Write (console input) |
+| ----------------------------------------------------- | ---------------------------- | ------------------------------------------ |
+| J-Link native (`JLinkExe -RTTTelnetPort`) | validated | validated (8.5 KB writes) |
+| OpenOCD on native probes (ST-Link/CMSIS-DAP/WCH-Link) | validated (sysview campaign) | unvalidated — validate in the ci-rig phase |
+| OpenOCD on the LPC-Link2 (J-Link OB fw, measured) | forbidden (USB drop) | forbidden |
+| WCH SDI (any tool) | halt→dump only | n/a |
+
+`JlinkRtt`/CLI are J-Link-only in v1; OpenOCD console-write support is
+added only if the ci-rig phase validates it.
+
+## Tooling home
+
+Single implementation in `tools/rtt.py`: a stdlib-only importable module
+(shared socket-console base + `JlinkRtt` + `OpenocdRtt`) that doubles as
+the CLI. `hil_util` imports and re-exports the classes (the harness keeps
+addressing `hil_util.JlinkRtt`), so the dependency points harness → tools,
+never tools → harness. Because `hil_util` loads it at import time, the file
+is harness-critical: it is classified with `test/hil/` in `ci_select`'s full
+rule and covered by the pre-commit `hil-test` hook (test_hil_rtt.py).
+Precedent: `code-size` wrapping `tools/metrics_compare_base.py` — the skill
+is md-only and points at the tool. `open_board_console()` stays in
+`hil_test.py` for now; pool-check adoption is a follow-up doc, not this PR.
+
+## Doc edits (curated-skills rule: smallest possible diffs)
+
+- `target-debug/SKILL.md`: capture-channel rows and the drain-model warning
+ stay; the two capture recipe blocks and the RTTLogger/GDBServer paragraph
+ shrink to one-liners pointing at `rtt`; the manual ring-read recipe
+ (`nm`/`mem32`/`savebin`) moves into `rtt` §post-mortem.
+- `CLAUDE.md` GDB section RTT line becomes build flag + pointer.
+- `hil/SKILL.md` gains one routing line (the fix that would have prevented
+ the lost hour).
+- `sysview/SKILL.md` pointer is **deferred** until that branch merges, and
+ proposed to the user first. No edits to `sysview_ci.py` or the sysview
+ skill now.
+
+## Validation strategy (user-directed)
+
+1. **Dogfood on the local htpc bench first**: ea4088_quickstart via LPC-Link2
+ (replugged; OpenOCD attempts on it are skipped outright) and
+ raspberry_pi_pico2 via the J-Trace (nickname `jtrace`, serial private; now wired to pico2; RP2350 =
+ `rp2350_m33_0`, never a custom JLinkScript). Follow only the SKILL.md
+ text (dogfood = REFACTOR input).
+2. **Then all boards on the ci.lan rig**, per-transport smoke capture, rows
+ recorded in `.claude/skills/rtt/boards.md`. Exclusions recorded honestly
+ (esptool boards: no SEGGER-RTT path in our builds — USB-Serial-JTAG
+ console instead; tm4c: no probe path configured on the rig).
+
+## Non-goals
+
+Timing/profiling (etm-trace, sysview, parked swo-trace), SystemView
+encode/decode/licensing, TU_LOG conventions, debugging decision flows
+(target-debug), Espressif USB-Serial-JTAG console (esp-target-debug), WCH SDI
+live streaming (impossible — see matrix).