summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-07-14-usb-target-debug-handoff.md125
-rw-r--r--docs/superpowers/plans/2026-07-23-esp-target-debug-skill.md74
-rw-r--r--docs/superpowers/plans/2026-07-23-target-debug-skill-enhancement.md570
-rw-r--r--docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md299
-rw-r--r--docs/superpowers/plans/2026-07-27-openocd-unified-fork.md602
-rw-r--r--docs/superpowers/plans/2026-07-28-hil-test-split.md355
-rw-r--r--docs/superpowers/plans/2026-07-29-hil-select.md856
-rw-r--r--docs/superpowers/plans/2026-08-15-ci-hs-reset-edges.md782
-rw-r--r--docs/superpowers/plans/2026-08-16-drop-ep0-prime-verify.md314
9 files changed, 3977 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-14-usb-target-debug-handoff.md b/docs/superpowers/plans/2026-07-14-usb-target-debug-handoff.md
new file mode 100644
index 000000000..b56d035d9
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-14-usb-target-debug-handoff.md
@@ -0,0 +1,125 @@
+# Hand-off: `usb-target-debug` skill + `target-debugger` agent
+
+**Status: agreed but NOT started.** Design discussion happened 2026-07-13 in session
+`c31a4617-43b1-491d-9865-3e35f393996b` (post-merge of the agents/workflows harness,
+PR #3762 / `ac595bc5c`). This document is the implementation brief for a fresh session.
+
+**Agreed sequencing: skill first → dogfood on 1-2 real HIL failures → then the agent
+as its own small PR.** Do not build both at once — the agent charter's hard parts are
+exactly what dogfooding the skill answers.
+
+## The gap being filled
+
+When HIL fails today, *what failed* is covered (hil-validate workflow, hil-operator
+agent) but the deep *why* loop — instrument the target, capture on both sides,
+correlate — has no skill and no agent. Every hard case so far (musb babble, rusb2
+FRDY wedge, ch32v307 Heisenbug) fell back to interactive main-session work.
+
+Why no existing agent can do it:
+
+- **hil-operator** (sonnet) is deliberately mechanical: lock → flash → `hil_test.py`
+ → recover. It never edits source, so it cannot inject instrumentation.
+- **port-dev** can edit source but its charter is scoped changes verified by a
+ *build*; it has no hardware mandate.
+- The host-side capture knowledge lives in skills (`usbmon`, `usb-debug`); the
+ device-side half exists only as CLAUDE.md recipes plus session memory.
+
+The skill completes the debugging trio:
+
+| Skill | Answers | Status |
+|---|---|---|
+| `usbmon` | what the host actually exchanged (URBs) | on master |
+| `usb-debug` | why the host acted (dmesg / dynamic debug) | ships in PR #3758 (untracked copy in tree) |
+| `usb-target-debug` | what the device did | **this hand-off** |
+
+## Part 1 — `usb-target-debug` skill (do this first)
+
+Create `.claude/skills/usb-target-debug/SKILL.md`. Match the style of
+`.claude/skills/usbmon/SKILL.md` and `usb-debug/SKILL.md`: frontmatter `name` +
+`description` where the description states concretely *when* to reach for it
+(HIL test fails and host-side capture can't explain it; device silently NAKs,
+wedges, or misbehaves; need TU_LOG/device-state evidence from real hardware).
+
+Playbook to codify — all techniques already proven on this rig:
+
+1. **TU_LOG capture** — build with `LOG=2` (add `LOGGER=rtt` for RTT); UART capture
+ from the board's debug serial; RTT via `JLinkGDBServer -RTTTelnetPort 19021` +
+ `JLinkRTTClient` (non-interactive: `timeout 20s JLinkRTTClient > rtt.log`).
+ Note which log level perturbs timing (see warning #6).
+2. **GDB recipes per probe family** — J-Link, OpenOCD (ST-Link / CMSIS-DAP /
+ WCH-Link). Base connect/load recipes already exist in CLAUDE.md "GDB Debugging";
+ the skill adds the debug-loop specifics: breakpoints in ISR context, dumping
+ endpoint/FIFO registers, watchpoints on driver state variables.
+3. **RAM ring-buffer trace pattern** (used to crack the musb babble): instrument
+ the dcd/hcd with a small RAM ring of event records instead of TU_LOG when
+ printing perturbs timing; let the failure happen; halt and dump the ring via
+ GDB. Include a minimal C snippet (fixed-size struct ring, no allocation,
+ ISR-safe single-writer).
+4. **J-Link PC-sampling** (nailed the rusb2 FRDY wedge): statistically sample PC
+ without halting to find where the core spins — the non-intrusive option when
+ halting or logging masks the bug.
+5. **Dual-side capture**: usbmon on the host + RTT/ring-buffer on the target,
+ simultaneously; correlate host URBs against device events on one timeline.
+ This is the default posture for enumeration/transfer bugs, not an escalation.
+6. **Warnings**: observation can mask the bug (the ch32v307 case changed behavior
+ under logging/debug — prefer ring-buffer over TU_LOG, PC-sampling over halting,
+ and say so explicitly); a J-Link core reset does NOT drop a DWC2 soft-connect
+ pullup, so a wedged DUT stays wedged on the host side (cross-ref
+ `usb-recover/SKILL.md`).
+7. **Rig discipline**: hold the board lock for the whole manual session —
+ `python3 test/hil/board_lock.py hold <board> --reason "target debug: <bug>"`
+ … work … `release <board>`. Never stop the actions-runner. Board → probe
+ mapping via `test/hil/tinyusb.json`; `JLINK_DEVICE`/`OPENOCD_OPTION` via
+ `hw/bsp/*/boards/*/board.cmake` or `board.mk`.
+
+**Where to ship**: its own small PR (usb-recover/usb-debug already belong to
+PR #3758 — don't grow that one), or fold into #3758 if it is still open and being
+rebased anyway. User's call at the time.
+
+## Part 2 — `target-debugger` agent (later, after dogfooding)
+
+Create `.claude/agents/target-debugger.md` as its own PR once the skill has been
+through at least one real debug session.
+
+Agreed charter outline:
+
+- **Frontmatter**: `model: opus`; omit `tools:` (= all tools — it must edit source
+ AND drive hardware). Note the registry supports no `effort` field — the agreed
+ opus/**xhigh** tier is requested per `agent()` call by whichever workflow or
+ session spawns it.
+- **Loop**: instrument → build → flash under one held board lock → dual-side
+ capture (host usbmon + target RTT/ring-buffer/GDB) → correlate → refine
+ hypothesis → repeat. Deliberately serial: no fan-out win; the value is
+ backgrounding a long debug session and the codified playbook.
+- **Strictly one instance**, holds the board lock for the entire session — its work
+ is exactly the "hardware work outside hil_test.py" case in the lock protocol.
+- **Skills are its source of truth** (mirror hil-operator's pattern): read
+ `usb-target-debug`, `usbmon`, `usb-debug`, `usb-recover`, `hil` SKILL.md files
+ before acting.
+- **Hard rule — instrumentation is temporary**: the instrumentation diff must be
+ reverted (or explicitly listed in the hand-back report) at session end; the *fix*
+ itself goes to port-dev. Keeps charters clean: this agent produces a diagnosis
+ and evidence, not a merged patch.
+
+Questions dogfooding must answer before the charter is written (do NOT guess these
+now — that was the whole reason for skill-first):
+
+1. When to stop instrumenting and report a partial diagnosis vs keep digging.
+2. Maximum board-lock hold time / check-in cadence for a backgrounded session.
+3. What "revert instrumentation" means when a partial fix emerged mid-debug
+ (revert + attach diff? keep on a branch?).
+
+## Conventions and references for the implementing session
+
+- Skill style exemplars: `.claude/skills/usbmon/SKILL.md`, `usb-debug/SKILL.md`,
+ `usb-recover/SKILL.md` (the latter two are #3758's copies, present untracked).
+- Agent style exemplars: `.claude/agents/hil-operator.md` (lock discipline,
+ skills-as-source-of-truth), `port-dev.md` (source-edit + verify charter).
+- When the agent lands, update the harness spec's agent roster:
+ `docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md`
+ (convention: spec evolves in-repo; plans like this file are per-effort records).
+- Agents register from `.claude/agents/*.md` at session start — a new agent file
+ is only visible to sessions launched after it exists.
+- Past cases to mine for the skill's examples: musb babble (ring-buffer trace),
+ rusb2 FRDY wedge (J-Link PC-sampling), ch32v307 Heisenbug (observation
+ sensitivity) — details in session memory and the referenced session transcript.
diff --git a/docs/superpowers/plans/2026-07-23-esp-target-debug-skill.md b/docs/superpowers/plans/2026-07-23-esp-target-debug-skill.md
new file mode 100644
index 000000000..d08902111
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-23-esp-target-debug-skill.md
@@ -0,0 +1,74 @@
+# esp-target-debug Skill Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Create `.claude/skills/esp-target-debug/SKILL.md` (Espressif built-in USB-Serial-JTAG debug backend) with every recipe verified on the rig's P4, the S3 PHY boundary verified both ways, plus pointer edits in `target-debug` and the `target-debugger` agent.
+
+**Architecture:** Per spec `docs/superpowers/specs/2026-07-23-esp-target-debug-design.md`. Verification-first: hardware gates 1–6 run before the skill text lands, so only proven content ships unmarked. One lock session per board.
+
+**Tech Stack:** ESP-IDF at `$HOME/code/esp-idf` (`export.sh` → `openocd-esp32`, `riscv32-esp-elf-gdb`, `xtensa-esp32s3-elf-gdb`, `esptool.py`), rig boards `espressif_p4_function_ev` (uid 6055F9F98715), `espressif_s3_devkitm` (uid 84F703C084E4).
+
+## Global Constraints
+
+- Worktree `/home/hathach/code/tinyusb/.claude/worktrees/improve-debug-skill-agent`, branch `claude/improve-debug-skill-agent`.
+- Board-lock discipline per `hil` skill; reflash pristine firmware before release; evidence (command + output snippet) in commit message bodies.
+- Formatting: aligned table columns, skill-name-only cross-references.
+- Unverified content ships tagged `(untested)` or not at all.
+- Espressif anything requires `. $HOME/code/esp-idf/export.sh` in that shell first.
+
+---
+
+### Task 1: P4 recon + coexistence gate (spec gates 1)
+
+- [x] **Step 1: Environment + firmware recon**
+
+```bash
+ls $HOME/code/esp-idf/export.sh && source $HOME/code/esp-idf/export.sh && which openocd riscv32-esp-elf-gdb
+ls /home/hathach/code/tinyusb/examples/cmake-build-espressif_p4_function_ev 2>/dev/null || echo "no prebuilt"
+lsusb -d 303a:1001 # USB-SJ devices present
+```
+If no prebuilt firmware: build `device/cdc_msc_freertos` for the P4 (`idf.py -DBOARD=espressif_p4_function_ev build` in that example, per CLAUDE.md), else use the prebuilt binary. Identify the ELF path for gdb symbolization.
+
+- [x] **Step 2: Lock P4, ensure known firmware, confirm DUT traffic**
+
+```bash
+python3 test/hil/board_lock.py hold espressif_p4_function_ev --reason "esp-target-debug verify: coexistence"
+# flash known build (esptool/idf.py flash -p <port-by-uid>), settle, then confirm enumeration:
+lsusb | grep -i cafe # TinyUSB VID on the DUT port
+# generate traffic: echo > /dev/ttyACM<N> of the cdc, or timeout 5s cat
+```
+
+- [x] **Step 3: Attach openocd over USB-SJ while the device runs**
+
+```bash
+openocd -f board/esp32p4-builtin.cfg -c 'adapter serial 60:55:F9:F9:87:15' & # gdb :3333 — USB-SJ iSerial = MAC with colons
+riscv32-esp-elf-gdb -batch -ex 'target extended-remote :3333' -ex 'monitor halt' \
+ -ex bt -ex 'monitor resume' <p4 elf>
+```
+Expected: backtrace with symbols; after resume the CDC device still answers (re-run the traffic check). Record: does the DUT drop off the bus during halt (host URB timeouts — expected per target-debug) and does it recover on resume without re-enumeration?
+
+- [x] **Step 4: Release-or-continue checkpoint** — keep the lock for Task 2 (same session). No commit yet; evidence to `/tmp/esp_evidence.txt`.
+
+### Task 2: P4 budget, watchpoint, threads, console (spec gates 2–4)
+
+- [x] **Step 1: Breakpoint/watchpoint budget** — RISC-V trigger count: in gdb `monitor riscv info` or set watchpoints until rejection; verify a hardware watchpoint on a TinyUSB variable (e.g. `watch -l` on a usbd counter) reports and hits.
+- [x] **Step 2: FreeRTOS threads** — `info threads` after halt; expect ESP-IDF tasks incl. the USB task; note whether it works at attach or needs run→stop (mirror the ARM finding).
+- [x] **Step 3: Console during traffic** — OUTCOME: stock builds route the console to UART0 (the CP2102 flasher tty — boot log captured there); the USB-SJ CDC carries no log without sdkconfig `ESP_CONSOLE_USB_SERIAL_JTAG`, which stays (untested) in the skill.
+- [x] **Step 4: Reflash pristine, release P4 lock.** Evidence appended to `/tmp/esp_evidence.txt`.
+
+### Task 3: P4 apptrace spike — GATED (spec gate 5)
+
+Budget 30 min. `openocd -c 'esp apptrace start ...'` against a firmware built with apptrace enabled? Stock HIL firmware has no apptrace init — if a code change would be required, that's the gate answer: land apptrace as `(untested — needs CONFIG_APPTRACE + firmware init)` with the recipe sketch. Only a working capture lands unmarked.
+
+### Task 4: S3 boundary (spec gate 6)
+
+- [x] **Step 1: Lock S3, flash `board_test`** (no TinyUSB → PHY free). Attach `openocd -f board/esp32s3-builtin.cfg -c 'adapter serial 84F703C084E4'` + `xtensa-esp32s3-elf-gdb`: halt + bt works.
+- [x] **Step 2: Flash a USB device example** — record the exact failure: does 303a:1001 vanish from lsusb (PHY switched), does openocd fail to attach or die mid-session? Capture verbatim error.
+- [x] **Step 3: Reflash pristine (a USB example — that is the CI-expected state), release.**
+
+### Task 5: Write the skill + integration edits + commit
+
+- [x] **Step 1: Write `.claude/skills/esp-target-debug/SKILL.md`** per spec section order (role/defer, PHY map with verified boundary symptoms, toolchain+attach with the real commands from Tasks 1–4, technique mapping table with verified annotations, rig deltas, external-JTAG TODO). Aligned tables.
+- [x] **Step 2: `target-debug` pointer** (2 lines, after probe-mapping bullets) + `target-debugger` agent table row.
+- [x] **Step 3: pre-commit, single commit** with evidence summary from `/tmp/esp_evidence.txt`.
+- [x] **Step 4: Retrieval sanity** — one fresh-subagent scenario: "debug a TinyUSB hang on the rig's P4" routes to esp-target-debug (not JLink recipes); "same on S3 while cdc_msc runs" routes to the PHY boundary + external-JTAG TODO.
diff --git a/docs/superpowers/plans/2026-07-23-target-debug-skill-enhancement.md b/docs/superpowers/plans/2026-07-23-target-debug-skill-enhancement.md
new file mode 100644
index 000000000..36a3144c2
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-23-target-debug-skill-enhancement.md
@@ -0,0 +1,570 @@
+# target-debug Skill & target-debugger Agent Enhancement Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extend `.claude/skills/target-debug/SKILL.md` (and its agent) with the full debugger facility arsenal from the J-Link, OpenOCD, and GDB manuals — breakpoint/watchpoint depth, OpenOCD RTT, vector catch + fault autopsy, SWO/ITM trace, flash verification — each recipe hardware-verified on the ci rig before it lands unmarked.
+
+**Architecture:** The skill's organizing spine is its intrusiveness table ("pick the least intrusive technique that can answer the question"); every new facility slots into that model with an honest cost row. Recipes keep the existing dense, copy-paste style. The skill's value is that its recipes are *proven on this rig* — so each task pairs drafting with a bounded hardware verification, and anything unverifiable lands tagged `(untested)` or is dropped.
+
+**Tech Stack:** arm-none-eabi-gdb 15.2, OpenOCD 0.12.0+dev, SEGGER J-Link V7.94b (`JLinkExe`, `JLinkGDBServer`, `JLinkSWOViewerCLExe`), ci rig boards from `test/hil/tinyusb.json` (10 jlink / 6 openocd / 1 stlink probes).
+
+**Reference manual:** "Debugging with GDB", **Tenth Edition** (for GDB 18.0.50) — prefer the calibre-library copy via the `read-doc` skill, but **verify the edition on the title page first**: the library also holds an outdated Ninth Edition (2002, GDB 5.1.1, txt) that predates `dprintf`/`watch -l` — do not use it. Fallback fetch: `curl -sL -o /tmp/gdb.pdf https://sourceware.org/gdb/current/onlinedocs/gdb.pdf` (HTML pages block fetchers; the PDF does not). Sections used by this plan: §5.1.2 Setting Watchpoints, §5.1.6 Break Conditions, §5.1.7 Breakpoint Command Lists, §5.1.8 Dynamic Printf (PDF page = book page + 18). NOTE: the manual documents GDB 18; the rig runs 15.2 — the installed `arm-none-eabi-gdb`'s `help <cmd>` is authoritative for feature availability.
+
+## Global Constraints
+
+- Worktree: `/home/hathach/code/tinyusb/.claude/worktrees/improve-debug-skill-agent`, branch `claude/improve-debug-skill-agent`. All paths below are relative to it.
+- J-Link User Guide link must be exactly `https://kb.segger.com/UM08001_J-Link_/_J-Trace_User_Guide` (user-specified, verified live 2026-07-23).
+- **Hardware-verify before landing**: a recipe is committed unmarked only with captured evidence from a rig board; otherwise tag it `(untested)` inline or drop it. Record evidence (command + output snippet) in the task's commit message body.
+- Rig discipline (from `hil` + `target-debug` skills): `python3 test/hil/board_lock.py hold <board> --reason "skill-enhance verify: <what>"` before touching hardware, `release` after; reflash pristine firmware before release; NEVER stop the actions-runner; one J-Link client per probe at a time; we are ON host `ci` (config `test/hil/tinyusb.json`).
+- Hardware tasks are strictly serial (one board session at a time). Bash timeouts ≥ 10 min for flash+debug cycles.
+- Style: match the skill's existing voice — dense, recipe-first, caveats inline. Skill word budget after all tasks: ≤ 2 700 words (`wc -w`, currently 1 763).
+- Run `pre-commit run --files <changed>` before every commit. No Co-Authored-By trailers.
+- Board selection is runtime data (boards come/go, locks): resolve with the exact python snippet in Task 2 Step 2 and reuse `$JB` (jlink board) / `$OB` (openocd board) thereafter.
+
+---
+
+### Task 1: Manuals reference block
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` (insert new `## Manuals` section immediately before `## Warnings`)
+
+**Interfaces:**
+- Produces: `## Manuals` section that later tasks' text may reference as "see Manuals".
+
+- [x] **Step 1: Insert the Manuals section**
+
+In `.claude/skills/target-debug/SKILL.md`, find the line `## Warnings` and insert immediately before it:
+
+```markdown
+## Manuals
+
+- J-Link / J-Trace User Guide (UM08001): <https://kb.segger.com/UM08001_J-Link_/_J-Trace_User_Guide> — flash breakpoints, RTT, SWO, monitor mode, Commander commands.
+- OpenOCD User's Guide: <https://openocd.org/doc/html/index.html> — `rtt`, `bp`/`wp`, `cortex_m vector_catch` / `maskisr`, `itm`/`tpiu`.
+- "Debugging with GDB" (the official manual; §5.1 covers break/watch/dprintf):
+ calibre library first (`read-doc` skill) — use the **Tenth Edition (GDB 18)**
+ copy, not the 2002 Ninth-Edition txt also present; fallback
+ `curl -sL -o /tmp/gdb.pdf https://sourceware.org/gdb/current/onlinedocs/gdb.pdf`
+ (the HTML mirror blocks fetchers; the PDF works). The installed
+ `arm-none-eabi-gdb`'s `help <cmd>` is authoritative for what this rig runs.
+
+```
+
+- [x] **Step 2: Verify formatting and word count**
+
+Run: `cd /home/hathach/code/tinyusb/.claude/worktrees/improve-debug-skill-agent && grep -A5 '^## Manuals' .claude/skills/target-debug/SKILL.md && wc -w .claude/skills/target-debug/SKILL.md`
+Expected: section present before `## Warnings`; word count ≤ 1 830.
+
+- [x] **Step 3: Commit**
+
+```bash
+cd /home/hathach/code/tinyusb/.claude/worktrees/improve-debug-skill-agent
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): link J-Link UM08001, OpenOCD and GDB manuals"
+```
+
+---
+
+### Task 2: Breakpoint & watchpoint arsenal (GDB + OpenOCD)
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — extend the `## GDB — state autopsy and watchpoints` section
+- Read-only reference: `test/hil/tinyusb.json` (board resolution)
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: board env vars `$JB`, `$OB` resolution snippet (reused by Tasks 3-6); the "halt-per-hit cost model" wording that Task 7's table row cites.
+
+- [x] **Step 1: Draft the section extension**
+
+In `.claude/skills/target-debug/SKILL.md`, the GDB section currently ends with the paragraph beginning `While halted the device answers **nothing**`. Insert immediately BEFORE that paragraph:
+
+```markdown
+**Hardware budget — read it off the chip, not from memory** (counts differ
+per core: M0+ typically 4 bp/2 wp, M3/M4 6/4, M7 8/4):
+
+```gdb
+p ((*(unsigned*)0xE0002000)>>4) & 0xF # FPB NUM_CODE = hw breakpoints (M7 adds bits[14:12])
+p (*(unsigned*)0xE0001000)>>28 # DWT_CTRL NUMCOMP = watchpoint comparators
+```
+
+- `hbreak`/`thbreak` force a hardware breakpoint (code in flash can't take a
+ software break unless the probe does flash breakpoints — J-Link does,
+ OpenOCD needs `bp <addr> 2 hw`); `tbreak` = one-shot.
+- `watch -l <expr>` watches the *address* the expression evaluates to once —
+ cheap and what you almost always want; `rwatch`/`awatch` trap reads/any
+ access (hardware-only — they error rather than fall back). OpenOCD (telnet
+ :4444) adds a data-VALUE match GDB cannot express: `wp <addr> 4 w <value>
+ [mask]` — fires only when the written value matches (e.g. catch who writes
+ 0 into a busy flag, ignoring writes of 1).
+- **Demand the word "Hardware" in the confirmation.** `watch` silently falls
+ back to a SOFTWARE watchpoint when no DWT comparator fits (expression too
+ wide/complex, budget exhausted): GDB then single-steps the whole program —
+ hundreds of times slower, certain USB death. `Watchpoint 2:` without
+ "Hardware" = delete it; `set can-use-hw-watchpoints 1` is the default but
+ narrowing the expression (`watch -l`, cast to a 4-byte int) is the real fix.
+- Conditional breaks/watches (`break dcd_edpt_xfer if ep_addr==0x81`) are
+ evaluated by GDB on the HOST with our stubs — neither JLinkGDBServer nor
+ OpenOCD supports target-side agent expressions on Cortex-M — so every hit
+ is a halt+resume (~ms) whether the condition matches or not: fine
+ post-wedge or on cold paths, wrong under live USB traffic.
+- `commands <bpnum> ... end` auto-runs GDB commands at each hit (start with
+ `silent`, end with `continue` for hands-free evidence collection) — same
+ halt-per-hit cost.
+- `dprintf <loc>,"fmt",args` = printf without recompiling. Stay on the
+ default `dprintf-style gdb` (host prints): the `call` style runs the
+ target's own printf mid-halt and `agent` needs stub support — neither is
+ viable on these probes. Same cost model as conditional breaks; for
+ ISR-rate events use the RAM ring buffer instead.
+- Stepping while the USB ISR fires between every step is chaos: OpenOCD
+ `cortex_m maskisr steponly` masks interrupts during single-steps only.
+ The bus keeps running either way — the host may still reset a device that
+ stops responding mid-step.
+- While halted you can poke state to test a hypothesis (`set var
+ _usbd_dev.ep_status[2][1].busy = 0`) — but that invalidates the snapshot
+ as post-mortem evidence; dump first, poke after.
+```
+
+- [x] **Step 2: Resolve verification boards (runtime data)**
+
+```bash
+cd /home/hathach/code/tinyusb
+python3 - <<'EOF'
+import json
+cfg = json.load(open('test/hil/tinyusb.json'))
+jl = [b['name'] for b in cfg['boards'] if b['flasher']['name']=='jlink']
+oo = [b['name'] for b in cfg['boards'] if b['flasher']['name']=='openocd']
+print('JLINK candidates:', jl)
+print('OPENOCD candidates:', oo)
+EOF
+```
+Pick the first candidate of each that `python3 test/hil/board_lock.py status` shows unlocked; export as `JB=<jlink board>` `OB=<openocd board>`. Look up `flasher.uid` for each in `test/hil/tinyusb.json` (`JB_UID`, `OB_UID`) and `JLINK_DEVICE`/`OPENOCD_OPTION` from `hw/bsp/*/boards/$JB/board.cmake` (family via `ls -d hw/bsp/*/boards/$JB`).
+
+- [x] **Step 3: Hardware-verify the budget reads on both probe families**
+
+```bash
+python3 test/hil/board_lock.py hold $JB --reason "skill-enhance verify: bp/wp budget"
+printf 'mem32 E0002000, 1\nmem32 E0001000, 1\nqc\n' | \
+ JLinkExe -device $JLINK_DEVICE -SelectEmuBySN $JB_UID -if swd -speed 4000 -autoconnect 1 -nogui 1
+python3 test/hil/board_lock.py release $JB
+```
+Expected: two register values; decode NUM_CODE and NUMCOMP by hand and check they are plausible (2-8 range). Repeat for `$OB` via `openocd $OPENOCD_OPTION -c init -c 'mdw 0xE0002000' -c 'mdw 0xE0001000' -c shutdown` under its own lock.
+If a register reads 0 on one board, note which core and adjust the skill text's example counts if contradicted.
+
+- [x] **Step 4: Hardware-verify dprintf + commands round-trip on $JB**
+
+With the board lock held and an already-flashed example (any; do not reflash), a JLinkGDBServer on :2331 (per CLAUDE.md GDB Debugging), run bounded — `commands` blocks cannot be passed via `-ex`, so use a command file:
+
+```bash
+cat > /tmp/bpcmd.gdb <<'EOF'
+target remote :2331
+set var $count=0
+watch -l *(unsigned*)&_usbd_dev
+delete
+dprintf tud_task_ext,"tick\n"
+break tud_task_ext
+commands 3
+silent
+set var $count=$count+1
+continue
+end
+continue&
+shell sleep 3
+interrupt
+print $count
+EOF
+timeout 120 arm-none-eabi-gdb -batch -x /tmp/bpcmd.gdb \
+ $(find examples/cmake-build-$JB -name 'cdc_msc.elf' | head -1)
+```
+Expected: the `watch` line answers `Hardware watchpoint 1:` (the word
+"Hardware" present — this is the skill's software-fallback check, then
+deleted), "tick" lines printed, and `$count > 0`. (`tud_task_ext` is the real
+symbol — `tud_task` is an inline wrapper; the breakpoint is number 3 after
+the watchpoint and dprintf.) Kill the GDB server, reflash pristine
+(`ninja`-flash target or `hil_test.py` flash path), release the lock.
+
+- [x] **Step 5: Apply the Step-1 text, run pre-commit, commit**
+
+```bash
+cd /home/hathach/code/tinyusb/.claude/worktrees/improve-debug-skill-agent
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): breakpoint/watchpoint arsenal with halt-per-hit cost model
+
+Verified on <JB> (J-Link) + <OB> (OpenOCD): FPB/DWT budget reads, dprintf,
+breakpoint command lists. <paste the two register values here>"
+```
+
+---
+
+### Task 3: OpenOCD RTT — RTT is not J-Link-only
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — `## TU_LOG capture` section
+
+**Interfaces:**
+- Consumes: `$OB`, `$OB_UID`, `$OPENOCD_OPTION` from Task 2 Step 2.
+- Produces: the corrected claim "RTT works on any OpenOCD-driven probe" that Task 7's agent text repeats.
+
+- [x] **Step 1: Replace the J-Link-only claim**
+
+In the `## TU_LOG capture` section, replace:
+
+```markdown
+Build with `LOG=2` (`LOG=3` adds per-transfer noise and much more timing skew).
+`LOGGER=rtt` routes it over the debug probe (J-Link only) — no UART wiring:
+```
+
+with:
+
+```markdown
+Build with `LOG=2` (`LOG=3` adds per-transfer noise and much more timing skew).
+`LOGGER=rtt` routes it over the debug probe — no UART wiring. SEGGER's host
+tools need a J-Link, but OpenOCD serves the same RTT buffer on ST-Link /
+CMSIS-DAP / WCH-Link boards:
+```
+
+- [x] **Step 2: Add the OpenOCD RTT recipe**
+
+Immediately after the existing J-Link/UART capture code block (ends with `... | tee /tmp/uart.log`), add:
+
+```markdown
+```bash
+# OpenOCD RTT (any probe OpenOCD drives) — in telnet :4444 (or -c equivalents):
+rtt setup 0x20000000 0x8000 "SEGGER RTT" # search range = RAM ORIGIN + LENGTH (from the .ld / map file)
+rtt start # after firmware booted; rerun after each reflash
+rtt server start 19021 0
+# then: timeout 20s nc localhost 19021 > /tmp/rtt.log
+```
+
+OpenOCD polls the buffer (default 10 ms): bursty logs can drop lines a J-Link
+would keep — prefer J-Link where both exist; the drain-model warning below
+applies unchanged.
+```
+
+- [x] **Step 3: Hardware-verify on $OB**
+
+```bash
+python3 test/hil/board_lock.py hold $OB --reason "skill-enhance verify: openocd rtt"
+cd examples/device/cdc_msc && cmake -B build-rtt -DBOARD=$OB -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel -DLOG=2 -DLOGGER=rtt && cmake --build build-rtt
+# flash it (ninja -C build-rtt cdc_msc-openocd), then:
+openocd $OPENOCD_OPTION & # gdb :3333, telnet :4444
+{ echo 'rtt setup 0x20000000 0x8000 "SEGGER RTT"'; echo 'rtt start'; echo 'rtt server start 19021 0'; sleep 1; } | nc -q1 localhost 4444
+timeout 10s nc localhost 19021 > /tmp/ob_rtt.log; head /tmp/ob_rtt.log
+```
+Expected: TinyUSB boot banner / log lines in `/tmp/ob_rtt.log`. Adjust the search range from the board's linker script if the control block isn't found ("rtt: No control block found") and mirror any correction into the Step-2 text. Kill openocd, reflash pristine cdc_msc (no LOG), release lock, delete `build-rtt`.
+
+- [x] **Step 4: Commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): RTT via OpenOCD on non-J-Link probes
+
+Verified on <OB>: rtt setup/start/server + nc capture of boot log.
+<paste first captured log line>"
+```
+
+---
+
+### Task 4: Vector catch + fault autopsy
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — new section after `## GDB — state autopsy and watchpoints`
+
+**Interfaces:**
+- Consumes: `$JB` from Task 2. (Corrected during execution: $OB/rp2040 is ARMv6-M — no CFSR/BFAR and only VC_HARDERR, so the full autopsy verify needs the ARMv7-M $JB; the payload is a bad LOAD because stores fault imprecisely with BFAR invalid.)
+- Produces: section title `## Vector catch + fault autopsy` cited by Task 7's table row.
+
+- [x] **Step 1: Insert the new section**
+
+After the GDB section (i.e. before `## RAM ring-buffer trace`), insert:
+
+```markdown
+## Vector catch + fault autopsy — catch the crash, not the wedge
+
+A "wedge" that is really a fault (HardFault loop, lockup) autopsies best AT
+the faulting instruction, not minutes later. Arm before reproducing:
+
+```gdb
+# tool-agnostic (any probe, incl. J-Link): DEMCR trap bits — halt on fault
+set *(unsigned*)0xE000EDFC |= (1<<10)|(1<<9)|(1<<8)|(1<<7)|(1<<6)|(1<<5)|(1<<4)
+# = VC_HARDERR|INTERR|BUSERR|STATERR|CHKERR|NOCPERR|MMERR; bit0 VC_CORERESET halts at reset
+```
+
+OpenOCD native form: `cortex_m vector_catch hard_err bus_err state_err chk_err mm_err`.
+When it fires the core halts at the fault; decode:
+
+```gdb
+p/x *(unsigned*)0xE000ED28 # CFSR — low byte MemManage, byte1 BusFault, top half UsageFault
+p/x *(unsigned*)0xE000ED2C # HFSR — bit30 FORCED = an escalated lower-priority fault
+p/x *(unsigned*)0xE000ED38 # BFAR — faulting address (valid if CFSR bit15 BFARVALID)
+x/8wx $msp # stacked frame: r0 r1 r2 r3 r12 lr pc xpsr — pc = culprit
+```
+
+`arm-none-eabi-addr2line -e <elf> <stacked pc>` names the line. Caveats: a
+vector-catch halt is still a halt (host-side URB timeouts apply); the bits
+persist until power-cycle — clear them (`... &= ~0x7F1`) before handing the
+board back; RISC-V ports have no DEMCR — use a breakpoint on the trap handler.
+```
+
+- [x] **Step 2: Hardware-verify with a deliberate fault on $JB (ARMv7-M)**
+
+Create the fault build (NOT committed):
+
+```bash
+python3 test/hil/board_lock.py hold $JB --reason "skill-enhance verify: vector catch"
+cd examples/device/cdc_msc # executed on $JB (stm32f407disco, ARMv7-M) via JLinkExe — see commit evidence
+# temporary patch — revert after: fault 5 s after boot
+python3 - <<'EOF'
+import pathlib
+p = pathlib.Path('src/main.c'); s = p.read_text()
+import re
+s = re.sub(r'\\nint main\\(void\\)',
+ '\\nstatic void _fault_after_5s(void){ static uint32_t t0=0; if(!t0) t0=tusb_time_millis_api();'
+ ' if(tusb_time_millis_api()-t0>5000) (void)*(volatile uint32_t*)0xCF000000u; }\\n\\nint main(void)', s, count=1) # board_millis is gone; helper must sit after the includes
+s = s.replace('led_blinking_task();', 'led_blinking_task(); _fault_after_5s();', 1)
+p.write_text(s)
+EOF
+grep -n '_fault_after_5s' src/main.c # expect 3 hits: definition + call + (none in decl block)
+cmake -B build-fault -DBOARD=$JB -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build build-fault
+```
+(If `app_led_task`/`board_millis` anchors differ in the current `main.c`, place the same 3-line helper on whatever per-loop task function exists — the fault line `*(volatile uint32_t*)0xCF000000u = 0;` is the payload.)
+Flash `build-fault`, then:
+
+```bash
+# executed variant: DEMCR armed + autopsy via JLinkExe command file on $JB (see commit c1d2d305f evidence); OpenOCD-native form:
+openocd $OPENOCD_OPTION -c init -c 'cortex_m vector_catch hard_err bus_err' &
+timeout 60 arm-none-eabi-gdb -batch -ex 'target remote :3333' -ex 'monitor reset run' \
+ -ex 'shell sleep 8' -ex 'interrupt' \
+ -ex 'p/x *(unsigned*)0xE000ED28' -ex 'p/x *(unsigned*)0xE000ED38' -ex 'x/8wx $msp' \
+ build-fault/cdc_msc.elf
+```
+Expected: halted in the fault path, CFSR BusFault bits set, **BFAR = 0xCF000000**, stacked pc addr2lines to `_fault_after_5s`. If the write is silently ignored on this core (some buses RAZ/WI), switch payload to a NULL-function call `((void(*)(void))0x1)();` and note UsageFault/INVSTATE instead.
+
+- [x] **Step 3: Clean up hardware state**
+
+`git checkout -- src/main.c`, delete `build-fault/`, clear DEMCR bits (`set *(unsigned*)0xE000EDFC &= ~0x7F1` via a final gdb attach or power-cycle note), reflash pristine cdc_msc, `board_lock.py release $JB`.
+
+- [x] **Step 4: Commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): vector catch + Cortex-M fault autopsy recipe
+
+Verified on <OB>: deliberate bad-address write halted via vector_catch,
+CFSR=<val> BFAR=0xCF000000, stacked pc resolved by addr2line."
+```
+
+---
+
+### Task 5: SWO/ITM experiment — exception trace & hardware PC sampling
+
+This is an EXPERIMENT task with an explicit gate: the section lands **unmarked only if packets are actually captured** on a rig board; otherwise it lands tagged `(untested — SWO wiring unconfirmed on this rig)`. Budget: 30 min of hardware time, then decide.
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — new subsection inside the PC-sampling section (after the OpenOCD variant paragraph)
+
+**Interfaces:**
+- Consumes: `$JB`, `$JB_UID`, `$JLINK_DEVICE` from Task 2.
+- Produces: verified-or-tagged status consumed by Task 7's table row for SWO.
+
+- [x] **Step 1: Probe for SWO output (gate experiment)**
+
+```bash
+python3 test/hil/board_lock.py hold $JB --reason "skill-enhance verify: SWO"
+# arm DWT sources while the fw runs (background mem write, no halt):
+printf 'w4 E0001000, 0x00011401\nqc\n' | JLinkExe -device $JLINK_DEVICE -SelectEmuBySN $JB_UID -if swd -speed 4000 -autoconnect 1 -nogui 1
+# EXCTRCENA(16)|PCSAMPLENA(12)|SYNCTAP(10)|CYCCNTENA(0); tune POSTPRESET[4:1] if PC samples flood — then hand the probe to the viewer:
+timeout 20s JLinkSWOViewerCLExe -device $JLINK_DEVICE -usb $JB_UID -swofreq 4000000 -itmmask 0xFFFFFFFF | head -40
+```
+Gate: ANY decoded output (stimulus, PC samples, exception packets) = SWO wired on `$JB` → land unmarked with the observed invocation. No output → try one more J-Link board, then land tagged. Either way `release $JB` after reflashing nothing (this experiment flashes nothing).
+
+- [x] **Step 2: Insert the section (wording per gate outcome)**
+
+Append to the `## PC-sampling` section:
+
+```markdown
+### SWO/ITM — hardware-timed trace on one pin (J-Link)
+
+If the board routes SWO (TRACESWO), DWT emits packets with ZERO code change:
+**exception trace** (`DWT_CTRL` bit16 EXCTRCENA) — every IRQ enter/exit,
+timestamped, the ISR-ordering evidence the ring buffer needs code for — and
+**hardware PC sampling** (bit12 PCSAMPLENA), better histograms than DWT_PCSR
+polling. Arm the bits, then give the probe to the viewer (one client rule):
+
+```bash
+printf 'w4 E0001000, 0x00011401\nqc\n' | JLinkExe -device $JLINK_DEVICE -SelectEmuBySN <uid> ...
+timeout 20s JLinkSWOViewerCLExe -device $JLINK_DEVICE -usb <uid> -swofreq 4000000 -itmmask 0xFFFFFFFF
+```
+
+SWO needs the pin physically wired to the probe — many rig boards route only
+SWDIO/SWCLK. If the viewer shows nothing, that is the wiring, not the recipe.
+```
+
+If the gate FAILED on both boards, append ` (untested — SWO wiring unconfirmed on this rig)` to the subsection heading and keep the text.
+
+- [x] **Step 3: Commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): SWO exception-trace / hw PC-sampling recipe
+
+Gate result on <JB>: <captured packet types | no SWO output — tagged untested>."
+```
+
+---
+
+### Task 6: Flash verification, FreeRTOS thread awareness, semihosting & monitor-mode notes
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — `## Warnings` section + GDB section tail
+
+**Interfaces:**
+- Consumes: `$JB`, `$JB_UID`, `$JLINK_DEVICE` from Task 2.
+- Produces: warning-list entries cited in Task 7's retrieval test scenarios.
+
+- [x] **Step 1: Add flash-content verification to Warnings**
+
+In `## Warnings`, after the "A marginal link can fake a deterministic firmware bug" bullet, add:
+
+```markdown
+- **"Flash OK" can lie** (silent no-op: old firmware keeps running after a
+ green flash). When behavior contradicts the code you think is flashed,
+ verify flash against the build:
+ `arm-none-eabi-objcopy -O binary fw.elf /tmp/fw.bin`, then J-Link
+ `verifybin /tmp/fw.bin,<flash-base>` (Commander) or OpenOCD
+ `verify_image /tmp/fw.bin <flash-base>` — a mismatch means reflash with
+ verification before debugging another minute.
+```
+
+- [x] **Step 2: Add FreeRTOS + semihosting + monitor-mode notes to the GDB section**
+
+Append to the end of the `## GDB — state autopsy and watchpoints` section (after the Task-2 additions):
+
+```markdown
+FreeRTOS examples (`*_freertos`): add `-rtos GDBServer/RTOSPlugin_FreeRTOS`
+to JLinkGDBServer (OpenOCD: `-rtos FreeRTOS` on the target) and `info
+threads` / `thread <n>` shows every task's stack — a USB task blocked on a
+queue vs. spinning is one `bt` away. Semihosting is never the answer here:
+each call traps and halts the core — RTT does the same job without stopping.
+**Monitor-mode debugging** (J-Link, M3+) can keep the USB ISR serviced while
+you sit at a breakpoint — needs SEGGER's `JLINK_MONITOR.c`/ISR files compiled
+in + `SetMonModeDebug=1`; not set up in this repo, reach for it when a bug
+truly needs live breakpoints without killing the bus:
+<https://kb.segger.com/Monitor_Mode_Debugging> (untested).
+```
+
+- [x] **Step 3: Hardware-verify verifybin + FreeRTOS awareness on $JB**
+
+```bash
+python3 test/hil/board_lock.py hold $JB --reason "skill-enhance verify: verifybin+rtos"
+# (a) verifybin positive path against whatever is flashed — first reflash a known build:
+# flash examples/cmake-build-$JB/device/cdc_msc, then:
+arm-none-eabi-objcopy -O binary examples/cmake-build-$JB/device/cdc_msc/cdc_msc.elf /tmp/fw.bin
+printf 'verifybin /tmp/fw.bin,<flash-base from board .ld>\nqc\n' | \
+ JLinkExe -device $JLINK_DEVICE -SelectEmuBySN $JB_UID -if swd -speed 4000 -autoconnect 1 -nogui 1
+# (b) rtos plugin: flash cdc_msc_freertos for $JB (build if missing), start
+JLinkGDBServer -device $JLINK_DEVICE -select usb=$JB_UID -if swd -speed 4000 -port 2331 -nogui -rtos GDBServer/RTOSPlugin_FreeRTOS &
+timeout 60 arm-none-eabi-gdb -batch -ex 'target remote :2331' -ex 'monitor halt' -ex 'info threads' \
+ <path to cdc_msc_freertos.elf>
+python3 test/hil/board_lock.py release $JB # after pristine reflash
+```
+Expected: (a) `Verify successful.` (b) `info threads` lists FreeRTOS tasks (`usbd`, `IDLE`, ...). If the plugin errors ("Could not load RTOS plugin"), drop the JLinkGDBServer variant from the Step-2 text and keep only the OpenOCD `-rtos FreeRTOS` form tagged `(untested)`.
+
+- [x] **Step 4: Commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "docs(target-debug): flash verifybin, FreeRTOS thread awareness, monitor-mode pointer
+
+Verified on <JB>: verifybin 'Verify successful.'; info threads listed <n> tasks."
+```
+
+---
+
+### Task 7: Intrusiveness table integration, agent update, retrieval test
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` — the technique/intrusiveness table
+- Modify: `.claude/agents/target-debugger.md` — primary-playbook bullet
+
+**Interfaces:**
+- Consumes: verified/untested status of every technique from Tasks 2-6.
+
+- [x] **Step 1: Extend the intrusiveness table**
+
+The table under `## Pick the least intrusive technique that can answer the question` currently has 5 rows (PC-sampling → GDB halt). Replace it with (keep the header row and any wording the earlier tasks did not contradict):
+
+```markdown
+| Technique | Intrusiveness | Reach for it when |
+|---|---|---|
+| PC-sampling | none — no halt, no code change | core wedged/spinning somewhere unknown (rusb2 FRDY) |
+| SWO exception trace / hw PC-sample | none — needs SWO pin wired | ISR ordering/timing with zero code change |
+| Vector catch | none until a fault fires | crash-shaped wedges — autopsy AT the faulting pc |
+| RAM ring-buffer | ~tens of cycles per event | ISR ordering/timing bugs (musb babble) |
+| TU_LOG (RTT) | µs per line | logic bugs that survive logging (J-Link or OpenOCD rtt) |
+| TU_LOG (UART) | ms per line — blocking write | same, when no debug-probe RTT path |
+| dprintf / conditional breakpoint | halt+resume per hit (~ms) | low-rate probes post-wedge; never ISR-rate events |
+| GDB halt / breakpoints | stops USB service entirely | post-mortem state autopsy once wedged |
+```
+
+If Task 5's gate failed, keep the SWO row but append ` (untested)` in its "Reach for it" cell.
+
+- [x] **Step 2: Update the agent's playbook bullet**
+
+In `.claude/agents/target-debugger.md`, replace:
+
+```markdown
+- `.claude/skills/target-debug/SKILL.md` — your primary playbook: technique
+ choice by intrusiveness, channel choice by link topology, capture recipes,
+ GDB autopsy, all rig warnings.
+```
+
+with:
+
+```markdown
+- `.claude/skills/target-debug/SKILL.md` — your primary playbook: technique
+ choice by intrusiveness, channel choice by link topology, capture recipes,
+ breakpoint/watchpoint budget and cost model, vector catch + fault autopsy,
+ GDB autopsy, all rig warnings.
+```
+
+- [x] **Step 3: Word-count and stale-reference check**
+
+Run: `wc -w .claude/skills/target-debug/SKILL.md` — expected ≤ 2 700. If over, trim prose (not recipes) until under.
+Run: `grep -n 'J-Link only' .claude/skills/target-debug/SKILL.md` — expected: no output (Task 3 removed the claim).
+
+- [x] **Step 4: Retrieval test (skill-TDD GREEN gate)**
+
+Dispatch a fresh read-only subagent (Explore) that reads ONLY the updated `.claude/skills/target-debug/SKILL.md` and answers:
+
+1. "A CH32 board's firmware wedges; you suspect a HardFault loop. Least-intrusive next step?" — expected: vector catch (with the RISC-V caveat noted: CH32 is RISC-V → breakpoint on trap handler).
+2. "You need RTT logs on an ST-Link-only board." — expected: OpenOCD `rtt setup/start/server`, NOT "impossible/J-Link only".
+3. "Who is writing 0 into a busy flag, under live traffic?" — expected: OpenOCD value-match watchpoint `wp <addr> 4 w 0`, NOT a GDB conditional watch (halt-per-hit cost).
+4. "Flash reported OK but behavior matches last week's build." — expected: verifybin/verify_image.
+5. "You set `watch xfer_status[2][1]` and GDB answered `Watchpoint 2:` (no 'Hardware'). Proceed?" — expected: NO — software-watchpoint fallback single-steps the program; delete and narrow the expression.
+
+All five must route correctly; a miss = fix the text (usually the table row or a heading), re-test.
+
+- [x] **Step 5: Final commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md .claude/agents/target-debugger.md
+git add .claude/skills/target-debug/SKILL.md .claude/agents/target-debugger.md
+git commit -m "docs(target-debug): integrate new techniques into intrusiveness table; agent playbook bullet
+
+Retrieval test: 4/4 scenarios routed correctly."
+```
+
+---
+
+## Deferred / out of scope (deliberate)
+
+- **ETM / J-Trace instruction trace** — no J-Trace hardware on the rig; UM08001 "Trace" chapter is linked for the day one arrives.
+- **Monitor-mode debugging as a working recipe** — needs SEGGER monitor files compiled into firmware (a firmware feature, not a doc change); landed as a pointer + `(untested)` in Task 6.
+- **ITM stimulus-port logging backend for TU_LOG** — would be a `lib/` + `LOGGER=itm` firmware feature; out of scope for a skill-doc plan.
+- **GDB tracepoints (`trace`/`tfind`)** — need a tracing-capable stub; neither JLinkGDBServer nor OpenOCD implements them for Cortex-M.
diff --git a/docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md b/docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md
new file mode 100644
index 000000000..ebcc089fd
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md
@@ -0,0 +1,299 @@
+# etm-trace Skill Tightening + target-debugger Agent Integration Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix post-rebase staleness in the etm-trace skill, add its
+hardware-consent gate, and wire it into the target-debugger agent + target-debug
+skill the same way usb-sniffer is wired in (hardware-gated, user-confirmed).
+
+**Architecture:** Three curated instruction files get surgical edits (this repo
+treats skills/agents as curated docs — smallest possible diffs, no bulk
+rewrites). The gate follows the two existing consent patterns: in the *skill*
+(read by interactive sessions) it is "confirm with the user unless they asked";
+in the *agent* (which cannot ask mid-session) it is "only when your prompt
+states it", mirroring the agent's existing lock-force rule.
+
+**Tech Stack:** Markdown instruction files, git, one subagent retrieval test.
+
+## Global Constraints
+
+- Work in the existing worktree `/home/hathach/code/tinyusb/.worktrees/add-etm-trace-skill` on branch `claude/add-etm-trace-skill` (already rebased onto PR 3786 — the base that renamed `usb-target-debug` → `target-debug` and rewrote `.claude/agents/target-debugger.md`). Never switch the primary checkout's branch.
+- Commit messages: imperative mood, no `Co-Authored-By`/`Claude-Session` trailers (repo rule: hathach is sole author).
+- Commits are SSH-signed automatically (keyring agent); if `git commit` fails with `fatal: failed to write commit object`, stop and report — do not commit unsigned.
+- Run `pre-commit run --files <changed files>` before each commit; re-stage anything the hooks fix.
+- Do not touch `.idea/`, `*.jdebug.user`, or `PICO2_TRACE_PCB_HANDOFF.md` (user-local files in the worktree).
+
+---
+
+### Task 1: Tighten etm-trace SKILL.md — stale names + hardware-consent gate
+
+**Files:**
+- Modify: `.claude/skills/etm-trace/SKILL.md` (lines ~12–33: cross-skill table, PC-sampling pointer, Requirements)
+- No test file (instruction doc; verification = grep + subagent test in Task 4)
+
+**Interfaces:**
+- Consumes: nothing from other tasks.
+- Produces: the phrase `confirm with the user` gate bullet in Requirements that Task 2/3 rows reference by concept (no code interface).
+
+- [ ] **Step 1: Verify the stale references exist (the "failing test")**
+
+Run:
+```bash
+cd /home/hathach/code/tinyusb/.worktrees/add-etm-trace-skill
+grep -n "usb-target-debug" .claude/skills/etm-trace/SKILL.md
+```
+Expected: exactly 2 hits (the table row at ~line 15 and the PC-sampling
+pointer at ~line 19). If 0 hits, the file was already fixed — skip Steps 2–3.
+
+- [ ] **Step 2: Apply the edits**
+
+Edit `.claude/skills/etm-trace/SKILL.md`.
+
+Replace this block (current content):
+
+```markdown
+| Skill | Answers |
+|--------------------|----------------------------------------------------------------------------|
+| `usbmon` | what the host actually exchanged (URBs) |
+| `usb-target-debug` | what the device did (logs, driver state, sampled PCs) |
+| `usb-sniffer` | what crossed the wire |
+| **`etm-trace`** | **exactly which instructions executed, when** (profile, coverage, history) |
+
+Use `usb-target-debug`'s DWT PC-sampling for a quick statistical profile; use
+this skill for exact counts, coverage, or instruction-by-instruction history.
+```
+
+with:
+
+```markdown
+| Skill | Answers |
+|-----------------|----------------------------------------------------------------------------|
+| `usbmon` | what the host actually exchanged (URBs) |
+| `target-debug` | what the target did (logs, driver state, sampled PCs) |
+| `usb-sniffer` | what crossed the wire |
+| **`etm-trace`** | **exactly which instructions executed, when** (profile, coverage, history) |
+
+Use `target-debug`'s DWT PC-sampling for a quick statistical profile; use
+this skill for exact counts, coverage, or instruction-by-instruction history.
+```
+
+Then in the `## Requirements` section, replace the first bullet:
+
+```markdown
+- J-Trace on USB (`lsusb -d 1366:1020`) wired to the board's trace header;
+ select it by J-Link USB **nickname** (this rig: `jtrace`) — never commit
+ serials.
+```
+
+with:
+
+```markdown
+- J-Trace on USB (`lsusb -d 1366:1020`) wired to the board's trace header;
+ select it by J-Link USB **nickname** (this rig: `jtrace`) — never commit
+ serials.
+- **Physical setup is per-board and exclusive** (one J-Trace, moved between
+ boards; some rigs are fly-wired): unless the user just asked for trace on
+ this board or your task states it is wired, **confirm with the user** that
+ the J-Trace is connected to the target before flashing or capturing.
+```
+
+- [ ] **Step 3: Verify the edits**
+
+Run:
+```bash
+grep -c "usb-target-debug" .claude/skills/etm-trace/SKILL.md; grep -c "confirm with the user" .claude/skills/etm-trace/SKILL.md; grep -rn "usb-target-debug" .claude/skills/etm-trace/boards.md
+```
+Expected: `0`, then `1` (or more), then no output from boards.md (it has no
+stale names — do not edit it).
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /home/hathach/code/tinyusb/.worktrees/add-etm-trace-skill
+pre-commit run --files .claude/skills/etm-trace/SKILL.md
+git add .claude/skills/etm-trace/SKILL.md
+git commit -m "etm-trace: post-rename references, per-board hardware-consent gate
+
+target-debug replaced usb-target-debug in the debug-skill overhaul; update
+the cross-skill table and PC-sampling pointer. Add the consent gate: the
+J-Trace is a single probe moved between boards, so captures on a board the
+user did not just ask about need explicit confirmation that it is wired."
+```
+Expected: commit succeeds; `git log --format="%G?" -1` prints `G`.
+
+---
+
+### Task 2: Add etm-trace to the target-debugger agent's skill table
+
+**Files:**
+- Modify: `.claude/agents/target-debugger.md` (skill table, after the `usb-sniffer` row at ~line 24)
+
+**Interfaces:**
+- Consumes: the etm-trace skill name and its `boards.md` (Task 1 keeps both valid).
+- Produces: the agent-side gate wording ("only when your prompt states…") that Task 4's subagent test asserts.
+
+- [ ] **Step 1: Verify etm-trace is absent (the "failing test")**
+
+Run:
+```bash
+grep -c "etm-trace" .claude/agents/target-debugger.md
+```
+Expected: `0`.
+
+- [ ] **Step 2: Add the table row**
+
+In `.claude/agents/target-debugger.md`, after this row:
+
+```markdown
+| usb-sniffer | wire-level capture (hardware tap): host can't see the bus, usbmon vs target logs disagree, or TinyUSB is the host (no usbmon anywhere) |
+```
+
+insert:
+
+```markdown
+| etm-trace | instruction-level ETM trace via SEGGER J-Trace (exact execution history, profile, coverage) when sampled PCs and logs cannot resolve the mechanism. Requires the J-Trace physically wired to THIS board (supported boards: the skill's boards.md) — use only when your prompt states the board is trace-wired or the user asked for it; otherwise name it in `notes` as the next technique |
+```
+
+(The gate is prompt-based, not ask-based: this agent cannot ask the user
+mid-session — same pattern as the existing lock-force rule.)
+
+- [ ] **Step 3: Verify**
+
+Run:
+```bash
+grep -n "etm-trace" .claude/agents/target-debugger.md | wc -l; grep -n "prompt states the board is trace-wired" .claude/agents/target-debugger.md
+```
+Expected: `1` match count; the gate phrase found once.
+
+- [ ] **Step 4: Commit**
+
+```bash
+pre-commit run --files .claude/agents/target-debugger.md
+git add .claude/agents/target-debugger.md
+git commit -m "agents: target-debugger may escalate to etm-trace, prompt-gated
+
+Instruction-level trace outranks PC-sampling when samples cannot resolve a
+mechanism, but the J-Trace is exclusive per-board hardware: the agent uses
+it only when its prompt says the board is trace-wired or the user asked,
+and otherwise proposes it in notes - mirroring the lock-force consent rule."
+```
+Expected: commit succeeds, signature `G`.
+
+---
+
+### Task 3: Cross-pointer row in target-debug's channel table
+
+**Files:**
+- Modify: `.claude/skills/target-debug/SKILL.md` (channel table at ~lines 14–19)
+
+**Interfaces:**
+- Consumes: skill name `etm-trace` (Task 1).
+- Produces: nothing later tasks rely on.
+
+- [ ] **Step 1: Verify absence (the "failing test")**
+
+Run:
+```bash
+grep -c "etm-trace" .claude/skills/target-debug/SKILL.md
+```
+Expected: `0`.
+
+- [ ] **Step 2: Add the row**
+
+In `.claude/skills/target-debug/SKILL.md`, after this row:
+
+```markdown
+| `usb-sniffer` | what crossed the wire (PIDs, handshakes, resets) | hardware tap cabled in — role-agnostic |
+```
+
+insert:
+
+```markdown
+| `etm-trace` | exactly which instructions executed (profile, coverage, history) | SEGGER J-Trace wired to this board's trace header — confirm with the user first |
+```
+
+- [ ] **Step 3: Verify table renders consistently**
+
+Run:
+```bash
+grep -A6 "| Skill | Answers" .claude/skills/target-debug/SKILL.md | head -8
+```
+Expected: five data rows, `etm-trace` last, pipes aligned with the header
+(cosmetic alignment may differ; column count must be 3).
+
+- [ ] **Step 4: Commit**
+
+```bash
+pre-commit run --files .claude/skills/target-debug/SKILL.md
+git add .claude/skills/target-debug/SKILL.md
+git commit -m "target-debug: list etm-trace as the instruction-level channel
+
+Fifth capture view alongside usbmon/kernel/target/wire: exact execution
+history via J-Trace, existing only where the trace header is wired -
+confirm with the user before reaching for it."
+```
+Expected: commit succeeds, signature `G`.
+
+---
+
+### Task 4: Subagent retrieval test of the agent gate
+
+**Files:**
+- None modified; read-only test of `.claude/agents/target-debugger.md`.
+
+**Interfaces:**
+- Consumes: Task 2's gate wording.
+
+- [ ] **Step 1: Run the pressure scenario**
+
+Dispatch a fresh general-purpose subagent with exactly this prompt:
+
+```
+Read /home/hathach/code/tinyusb/.worktrees/add-etm-trace-skill/.claude/agents/target-debugger.md and answer as if you were that agent. Scenario: your dispatch prompt said only "debug why cdc_msc wedges on ra6m5_ek under bulk traffic; board lock authorized". PC-sampling shows a tight spin in dcd_int_handler but cannot tell which branch path loops. The ra6m5_ek IS listed in etm-trace's boards.md as validated. Do you start an ETM capture now? Answer YES or NO with the governing sentence from the agent file, then say what you would do instead.
+```
+
+- [ ] **Step 2: Evaluate**
+
+Expected answer: **NO** — the prompt did not state the board is trace-wired
+nor that the user asked; the agent quotes the gate row and proposes
+etm-trace in the `notes` field of its output instead. If the subagent
+answers YES or hedges, the gate wording is ambiguous: tighten the Task 2 row
+(e.g. bold the "only when") and re-run this test once.
+
+- [ ] **Step 3: Record**
+
+No commit. Note the test outcome in the final summary to the user.
+
+---
+
+### Task 5: Plan file + final verification
+
+**Files:**
+- Create (already saved by the planner): `docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md`
+
+- [ ] **Step 1: Full-sweep verification**
+
+Run:
+```bash
+cd /home/hathach/code/tinyusb/.worktrees/add-etm-trace-skill
+grep -rn "usb-target-debug" .claude/skills/etm-trace/ ; git log --oneline -4; git log --format="%G?" -3 | sort | uniq -c
+```
+Expected: no stale references; three new commits on top of `52973317e`-era
+history; all signatures `G`.
+
+- [ ] **Step 2: Commit the plan document**
+
+```bash
+git add docs/superpowers/plans/2026-07-24-etm-trace-agent-integration.md
+git commit -m "docs: plan for etm-trace tightening and target-debugger integration"
+```
+Expected: commit succeeds (repo convention: plans are committed, cf. PR 3786's
+`docs/superpowers/plans/`).
+
+---
+
+## Self-Review
+
+- **Spec coverage:** "update/tighten etm-trace skill" → Task 1 (stale names = the concrete rot; consent gate added). "update target-debugger agent to make use of it" → Task 2. "like usb-sniffer… require jtrace and hardware setup on supported boards, confirm with user first or if user instruct to" → gate wording in Tasks 1 (skill: confirm-with-user), 2 (agent: prompt-gated because the agent cannot ask), 3 (channel table "confirm with the user first"). Covered.
+- **Placeholders:** none — every step carries the exact text or command.
+- **Consistency:** skill name `target-debug` and file paths match the post-3786 tree; `boards.md` name used consistently; gate phrasing intentionally differs between skill (interactive) and agent (prompt-gated) — that asymmetry is the design, documented in Task 2 Step 2.
diff --git a/docs/superpowers/plans/2026-07-27-openocd-unified-fork.md b/docs/superpowers/plans/2026-07-27-openocd-unified-fork.md
new file mode 100644
index 000000000..e0f1f9678
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-openocd-unified-fork.md
@@ -0,0 +1,602 @@
+# Unified OpenOCD Fork (`hathach/openocd`) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** One OpenOCD fork at `hathach/openocd` (default branch `tinyusb`) that flashes, debugs and RTT-captures every TinyUSB rig target — RP2040, RP2350 (arm + riscv), all WCH CH32/CH5xx, Analog Devices MAX32, and Espressif — replacing the four separate OpenOCD trees on ci.
+
+**Architecture:** Fork `openocd-org/openocd` master (mainline is 1610 commits ahead of the RPi fork base and now the sole home of RISC-V support). Layer on top: 4 RP2350 TCL configs from the RPi fork, 1 ported max32665 TCL config from the ADI fork, the `wlinke` adapter + `sdi` transport + WCH flash drivers from `hathach/riscv-openocd-wch` (driving CH32 with **mainline's** riscv target if the DTM hypothesis holds), and ESP32-P4 TCL configs adapted from `espressif/openocd-esp32` onto mainline's generic-riscv ESP pattern. ESP32/S2/S3/C3/C6/H2 debug is already in mainline; ESP flash stays with esptool.
+
+**Tech Stack:** OpenOCD (autotools, C), TCL configs, GitHub CLI, TinyUSB HIL rig (`hil_test.py`, `board_lock.py`).
+
+## Global Constraints
+
+- Everything runs **on ci** (this machine *is* the rig — hostname `ci`); no SSH hop needed.
+- Repo: `hathach/openocd`, default branch **`tinyusb`**, source clone at `~/app/openocd`, install prefix `$HOME/app/openocd_tinyusb`.
+- **One commit per downstream fork** on the `tinyusb` branch: one for raspberrypi/openocd, one for analogdevicesinc/openocd, one for riscv-openocd-wch, one for espressif/openocd-esp32 (plus the initial README commit). Iterate with `git commit --amend` / squash before declaring a task done.
+- No `Co-Authored-By: Claude` / `Claude-Session:` trailers in any commit.
+- **`~/.local/bin/openocd_wch` (symlink) and `~/app/openocd_wch_new` stay untouched until Task 8's 4/4 WCH boards pass** — it is the rig's only CH32 flasher. Backup exists at `~/.local/bin/openocd_wch.bak-20260727`.
+- Hold a board lock for every hardware step: `python3 test/hil/board_lock.py hold <board> --reason "openocd-unified verify"`; release after. **Never stop the actions-runner.**
+- WCH RTT: always `rtt polling_interval 1`; **never `reset run` inside an SDI session** (target does not come back).
+- `pkill -x openocd` — never `pkill -f` (pattern matches your own shell).
+- `libjim-dev` is required to configure mainline; all build deps are already installed on ci (mainline was built here 2026-07-27).
+- Back up before replacing `/usr/local/bin/openocd`; the current binary is the RPi-fork build (byte-identical to `~/app/openocd_rpi/src/openocd`).
+- Do not modify the TinyUSB checkout at `~/code/tinyusb` except where a task explicitly says so (hil_test.py WCH cfg template, on a `claude/`-prefixed branch). Never `git stash -u` in a TinyUSB worktree.
+- OpenOCD resolves its scripts dir relative to the **realpath** of the binary — repoint via symlink into an installed prefix, never a bare copy of the binary.
+
+## Reference: current state (measured 2026-07-27, in `OPENOCD_UNIFIED_FORK_HANDOFF.md`)
+
+| Tree on ci | Repo @ commit | Role |
+| --- | --- | --- |
+| `~/app/openocd_rpi` | raspberrypi/openocd @ `ebec9504d` (sdk-2.0.0) | rig default (`/usr/local/bin/openocd`) |
+| `~/app/openocd_adi` | analogdevicesinc/openocd @ `5fc33af` | max32666fthr (`~/app/openocd_adi/src/openocd`) |
+| `~/app/riscv-openocd-wch` | hathach/riscv-openocd-wch @ `ccb04d7` | CH32 flash+RTT (`~/.local/bin/openocd_wch`) |
+| `~/app/openocd-mainline` | openocd-org/openocd @ `43441cd83` | candidate build, verified on pico/pico2/max32666fthr |
+
+Rig flasher entries (`test/hil/tinyusb.json`): `openocd` (pico ×3, fruit_jam, stm32h743nucleo, stm32g0b1nucleo), `openocd_adi` (max32666fthr), `openocd_wch` (nanoch32v203, ch32v103r_r1_1v0, ch32v307v_r1_1v0, ch582m_evt), `esptool` (espressif_s3_devkitm, espressif_p4_function_ev).
+
+---
+
+### Task 1: Create `hathach/openocd`, `tinyusb` branch, README
+
+**Files:**
+- Create: `~/app/openocd/` (clone), `~/app/openocd/README.md`
+
+**Interfaces:**
+- Produces: GitHub repo `hathach/openocd` with default branch `tinyusb`; local clone `~/app/openocd` with remotes `origin` (hathach) and `upstream` (openocd-org). All later tasks commit to this clone's `tinyusb` branch.
+
+- [ ] **Step 1: Fork and clone**
+
+```bash
+gh repo fork openocd-org/openocd --clone=false
+git clone --recursive https://github.com/hathach/openocd.git ~/app/openocd
+cd ~/app/openocd
+git remote add upstream https://github.com/openocd-org/openocd.git
+git checkout -b tinyusb origin/master
+```
+
+- [ ] **Step 2: Verify the clone is at mainline HEAD**
+
+Run: `cd ~/app/openocd && git log --oneline -1`
+Expected: `43441cd83 server: add 'services' command to list service information` or newer.
+
+- [ ] **Step 3: Write `README.md`** (new file — GitHub renders it instead of mainline's plain-text `README`, and leaving `README` untouched keeps future rebases conflict-free)
+
+```markdown
+# OpenOCD for the TinyUSB test rig
+
+One OpenOCD build that flashes, debugs and RTT-captures every board family on
+the [TinyUSB](https://github.com/hathach/tinyusb) hardware-in-the-loop rig, so
+the rig does not need four different OpenOCD trees.
+
+This is the `tinyusb` branch, tracking
+[openocd-org/openocd](https://github.com/openocd-org/openocd) `master`.
+Everything not listed below is unmodified mainline.
+
+## Cherry-picked / ported from
+
+| Source repo | What we took |
+| --- | --- |
+| [raspberrypi/openocd](https://github.com/raspberrypi/openocd) (`sdk-2.0.0`) | `tcl/target/rp2350-riscv.cfg`, `rp2350-rescue.cfg`, `rp2350-dbgkey-secure.cfg`, `rp2350-dbgkey-nonsecure.cfg`. The RP2040/RP2350 C flash driver is already better in mainline (`rp2xxx.c`). |
+| [analogdevicesinc/openocd](https://github.com/analogdevicesinc/openocd) (`release`) | `tcl/target/max32665.cfg` (MAX32665/MAX32666), re-ported onto mainline's `max32xxx_common.cfg`. The fork's QSPI block is dropped — it is guarded by `QSPI_ENABLE`, which this part sets to 0. |
+| [hathach/riscv-openocd-wch](https://github.com/hathach/riscv-openocd-wch) (originally [dragonlock2/miscboards](https://github.com/dragonlock2/miscboards) WCH SDK) | `wlinke` adapter driver, `sdi` single-wire transport, and the WCH flash drivers (`wch_riscv`, `wch_arm`) for CH32V/CH32F/CH5xx over WCH-Link/LinkE. |
+| [espressif/openocd-esp32](https://github.com/espressif/openocd-esp32) | `tcl/target/esp32p4.cfg` + `tcl/board/esp32p4-builtin.cfg`, adapted to mainline's generic RISC-V ESP pattern. ESP32/S2/S3/C3/C6/H2 debug is already in mainline; ESP flash programming stays with `esptool`. |
+
+## Build
+
+ ./bootstrap
+ ./configure --enable-jlink --enable-cmsis-dap --enable-stlink \
+ --enable-wlinke --disable-werror
+ make -j$(nproc)
+
+`libjim-dev` is required — mainline no longer builds the bundled jimtcl by
+default and configure hard-fails without it.
+```
+
+- [ ] **Step 4: Commit, push, set default branch**
+
+```bash
+cd ~/app/openocd
+git add README.md
+git commit -m "README: purpose of the tinyusb branch and its downstream sources"
+git push -u origin tinyusb
+gh repo edit hathach/openocd --default-branch tinyusb \
+ --description "OpenOCD for the TinyUSB test rig - one build for RP2040/RP2350, WCH CH32, MAX32 and ESP32 targets"
+```
+
+- [ ] **Step 5: Verify default branch**
+
+Run: `gh repo view hathach/openocd --json defaultBranchRef -q .defaultBranchRef.name`
+Expected: `tinyusb`
+
+---
+
+### Task 2: Build the fork on ci
+
+**Files:**
+- Create: `~/app/openocd_tinyusb/` (install prefix)
+
+**Interfaces:**
+- Consumes: `~/app/openocd` clone from Task 1.
+- Produces: `~/app/openocd_tinyusb/bin/openocd` (installed binary + scripts at `~/app/openocd_tinyusb/share/openocd/scripts/`). Every later flash/verify step uses this path.
+
+- [ ] **Step 1: Configure and build** (same recipe that already worked for mainline on this box)
+
+```bash
+cd ~/app/openocd
+./bootstrap
+./configure --prefix=$HOME/app/openocd_tinyusb \
+ --enable-jlink --enable-cmsis-dap --enable-stlink --disable-werror
+make -j$(nproc) && make install
+```
+
+- [ ] **Step 2: Verify version and adapters**
+
+Run: `~/app/openocd_tinyusb/bin/openocd --version 2>&1 | head -1`
+Expected: `Open On-Chip Debugger 0.12.0+dev-...` with a `-g<sha>` matching `git -C ~/app/openocd rev-parse --short HEAD`.
+
+Run: `~/app/openocd_tinyusb/bin/openocd -c 'adapter list; shutdown' 2>&1 | grep -E 'cmsis-dap|jlink|stlink'`
+Expected: all three listed.
+
+*(No commit — build products only.)*
+
+---
+
+### Task 3: Import the 5 TCL configs — one commit per downstream fork
+
+**Files:**
+- Create: `~/app/openocd/tcl/target/rp2350-riscv.cfg`, `rp2350-rescue.cfg`, `rp2350-dbgkey-secure.cfg`, `rp2350-dbgkey-nonsecure.cfg`, `max32665.cfg`
+- Source of truth: `~/code/tinyusb/openocd-unified-configs/` (the copies already hardware-verified this week; the max32665 port is already written there)
+
+**Interfaces:**
+- Consumes: `~/app/openocd` + install prefix from Task 2.
+- Produces: `target/rp2350-riscv.cfg` and `target/max32665.cfg` resolvable via `find` in the installed scripts dir — Task 4 flashes with them.
+
+- [ ] **Step 1: Copy the RPi configs and commit (downstream commit #1)**
+
+```bash
+cd ~/app/openocd
+cp ~/code/tinyusb/openocd-unified-configs/rp2350-riscv.cfg \
+ ~/code/tinyusb/openocd-unified-configs/rp2350-rescue.cfg \
+ ~/code/tinyusb/openocd-unified-configs/rp2350-dbgkey-secure.cfg \
+ ~/code/tinyusb/openocd-unified-configs/rp2350-dbgkey-nonsecure.cfg \
+ tcl/target/
+git add tcl/target/rp2350-*.cfg
+git commit -m "tcl/target: add RP2350 riscv/rescue/dbgkey configs from raspberrypi/openocd
+
+Taken from raspberrypi/openocd branch sdk-2.0.0 @ ebec9504d. These four
+configs are the only things that fork has which mainline lacks - the
+rp2040/rp2350 C driver was consolidated upstream as rp2xxx.c. All four
+use only mainline-present commands (swj_newdap, dap create -adiv6,
+target create riscv -ap-num, riscv set_enable_virt2phys).
+
+rp2350-riscv.cfg is what hw/bsp/rp2040/family.cmake requests when
+PICO_PLATFORM=rp2350-riscv."
+```
+
+- [ ] **Step 2: Copy the ADI config and commit (downstream commit #2)**
+
+```bash
+cd ~/app/openocd
+cp ~/code/tinyusb/openocd-unified-configs/max32665.cfg tcl/target/
+git add tcl/target/max32665.cfg
+git commit -m "tcl/target: add max32665 config ported from analogdevicesinc/openocd
+
+Ported from analogdevicesinc/openocd @ 5fc33af onto mainline's
+max32xxx_common.cfg (the ADI fork calls the same file max32xxx.cfg).
+The fork's QSPI block is dropped: it is guarded by QSPI_ENABLE, which
+this part sets to 0, and it needs the ADI-only max32xxx_qspi driver.
+Covers MAX32665/MAX32666 (both flash banks). Hardware-verified on
+max32666fthr 2026-07-27."
+```
+
+- [ ] **Step 3: Install and verify the configs resolve**
+
+```bash
+cd ~/app/openocd && make install
+~/app/openocd_tinyusb/bin/openocd -c 'puts [find target/max32665.cfg]; puts [find target/rp2350-riscv.cfg]; shutdown'
+```
+Expected: both paths under `~/app/openocd_tinyusb/share/openocd/scripts/target/` printed; exit without "Can't find".
+
+- [ ] **Step 4: Push**
+
+```bash
+cd ~/app/openocd && git push
+```
+
+---
+
+### Task 4: Hardware-verify every current-openocd board with the fork binary
+
+**Files:**
+- No source changes. Uses `~/code/tinyusb` builds + `test/hil/hil_test.py`.
+
+**Interfaces:**
+- Consumes: `~/app/openocd_tinyusb/bin/openocd` with Task 3 configs installed.
+- Produces: evidence that the fork can replace `/usr/local/bin/openocd` (Task 5's gate). PATH shim dir `~/app/openocd_tinyusb/shim/` reused by later tasks.
+
+Boards (every `openocd`/`openocd_adi` flasher entry in `tinyusb.json`):
+`raspberry_pi_pico`, `raspberry_pi_pico_w`, `raspberry_pi_pico2`, `adafruit_fruit_jam`, `stm32h743nucleo`, `stm32g0b1nucleo`, `max32666fthr`.
+Already verified on plain mainline 2026-07-27: pico, pico2, max32666fthr (re-run anyway — the binary changed).
+
+- [ ] **Step 1: Build any missing firmware sets** (repeat per board without `examples/cmake-build-<board>`; `cmake-build-raspberry_pi_pico`, `-stm32g0b1nucleo`, `-max32666fthr` already exist)
+
+```bash
+cd ~/code/tinyusb/examples
+cmake -B cmake-build-raspberry_pi_pico2 -DBOARD=raspberry_pi_pico2 -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . \
+ && cmake --build cmake-build-raspberry_pi_pico2
+```
+(Same pattern for `raspberry_pi_pico_w`, `adafruit_fruit_jam`, `stm32h743nucleo`. If a board fails `get_deps`, run `python3 tools/get_deps.py -b <board>` first.)
+
+- [ ] **Step 2: Create the PATH shim** (lets `hil_test.py`'s hardcoded `openocd` resolve to the fork; symlink keeps scripts-dir resolution working because OpenOCD follows the realpath)
+
+```bash
+mkdir -p ~/app/openocd_tinyusb/shim
+ln -sf ~/app/openocd_tinyusb/bin/openocd ~/app/openocd_tinyusb/shim/openocd
+```
+
+- [ ] **Step 3: Smoke-flash one board directly** (fast signal before the full suite)
+
+```bash
+python3 ~/code/tinyusb/test/hil/board_lock.py hold raspberry_pi_pico --reason "openocd-unified verify"
+~/app/openocd_tinyusb/bin/openocd -c "adapter serial E6614103E72C1D2F" \
+ -f interface/cmsis-dap.cfg -f target/rp2040.cfg -c "adapter speed 5000" \
+ -c "program /home/hathach/code/tinyusb/examples/cmake-build-raspberry_pi_pico/device/cdc_msc/cdc_msc.elf verify reset exit"
+```
+Expected: `** Verified OK **` then `** Resetting Target **`. Release the lock after (`board_lock.py release raspberry_pi_pico`).
+
+- [ ] **Step 4: Run the HIL suite for all 7 boards through the shim**
+
+```bash
+cd ~/code/tinyusb
+PATH=~/app/openocd_tinyusb/shim:$PATH \
+python3 test/hil/hil_test.py test/hil/tinyusb.json \
+ -b raspberry_pi_pico -b raspberry_pi_pico_w -b raspberry_pi_pico2 \
+ -b adafruit_fruit_jam -b stm32h743nucleo -b stm32g0b1nucleo
+```
+Notes for the executor:
+- `hil_test.py` takes the config as a positional arg and `-b` per board; it holds board locks itself (that is the board-lock protocol in CI — do not also hold manual locks around `hil_test.py` runs).
+- max32666fthr is **not** in this run: its `flash_openocd_adi()` path uses the hardcoded `OPENCOD_ADI_PATH = ~/app/openocd_adi` (`hil_test.py:408`), which the shim can't intercept. Handle it in Step 4b instead. Do not edit `hil_test.py` for this — the adi path disappears at cutover (Task 10 flips `tinyusb.json`'s flasher entry to plain `openocd` with `-f interface/cmsis-dap.cfg -f target/max32665.cfg`).
+- Expected: every board PASS in the report. Any failure: stop, diagnose (consult the `hil` skill), do not proceed to Task 5.
+
+- [ ] **Step 4b: max32666fthr — manual flash with the fork, then tests with `--skip-flash`**
+
+```bash
+python3 ~/code/tinyusb/test/hil/board_lock.py hold max32666fthr --reason "openocd-unified verify"
+~/app/openocd_tinyusb/bin/openocd -c "adapter serial E6614C311B597D32" \
+ -f interface/cmsis-dap.cfg -f target/max32665.cfg \
+ -c "program /home/hathach/code/tinyusb/examples/cmake-build-max32666fthr/device/cdc_msc/cdc_msc.elf verify reset exit"
+python3 ~/code/tinyusb/test/hil/board_lock.py release max32666fthr
+cd ~/code/tinyusb && python3 test/hil/hil_test.py test/hil/tinyusb.json -b max32666fthr -sf
+```
+Expected: `** Verified OK **` on the flash, then PASS with `-sf` (tests run against the firmware just flashed).
+
+- [ ] **Step 5: RTT smoke on the pico** (mainline RTT was verified 2026-07-27; re-confirm on the fork build — `target-debug` skill has the full flow)
+
+Expected: RTT control block found, events stream, overflow 0.
+
+---
+
+### Task 5: Repoint the rig default `openocd`
+
+**Files:**
+- Modify: `/usr/local/bin/openocd` (→ symlink), remove Debian `openocd` package
+
+**Interfaces:**
+- Consumes: Task 4 all-green.
+- Produces: `which openocd` → fork for every rig user (hil_test.py, skills, CI). Rollback: restore `/usr/local/bin/openocd.rpi-backup-20260727`.
+
+- [ ] **Step 1: Back up and repoint**
+
+```bash
+sudo cp -a /usr/local/bin/openocd /usr/local/bin/openocd.rpi-backup-20260727
+sudo ln -sf $HOME/app/openocd_tinyusb/bin/openocd /usr/local/bin/openocd
+openocd --version 2>&1 | head -1
+```
+Expected: fork version string (matches Task 2 Step 2).
+
+- [ ] **Step 2: Drop the Debian openocd** (installed 2026-07-27 only to get a jlink-capable OpenOCD; the fork has `--enable-jlink`)
+
+```bash
+sudo apt-get remove -y openocd
+which -a openocd
+```
+Expected: only `/usr/local/bin/openocd` remains.
+
+- [ ] **Step 3: Re-verify through the default path (no shim)**
+
+```bash
+cd ~/code/tinyusb
+python3 test/hil/hil_test.py test/hil/tinyusb.json -b raspberry_pi_pico -b stm32g0b1nucleo -b raspberry_pi_pico2
+```
+Expected: 3/3 PASS. If CI kicks a workflow mid-way, board locks arbitrate — just wait.
+
+---
+
+### Task 6: WCH part 1 — port the `wlinke` adapter + `sdi` transport (compiles, detects probe)
+
+**Files (all in `~/app/openocd`, sources from `~/app/riscv-openocd-wch` @ `ccb04d7` — this copy already carries the GCC-14 fixes):**
+- Create: `src/jtag/drivers/wlinke.c` (2041 lines, copy), `src/jtag/sdi.c` (~130 lines, port), `src/jtag/sdi.h` (if the fork has one — check `ls ~/app/riscv-openocd-wch/src/jtag/sdi*`)
+- Modify: `src/transport/transport.h` (new transport id), `src/jtag/interface.h` (add `sdi_ops` to `struct adapter_driver` + `struct sdi_driver` decl), `src/jtag/interfaces.c` (register driver), `src/jtag/drivers/Makefile.am`, `src/jtag/Makefile.am`, `configure.ac` (`--enable-wlinke`)
+
+**Interfaces:**
+- Consumes: fork clone + build tree.
+- Produces: `openocd -c "adapter driver wlinke"` works; `wlink_*` C exports (`wlink_erase`, `wlink_write`, `wlink_getromram`, `wlink_reset`, `wlink_chip_reset`, `wlink_clean`, `wlink_flash_protect`, …) available for Task 8's flash driver; `sdi` transport selectable. Commit stays **amend-in-progress** — Tasks 6–8 squash into downstream commit #3.
+
+Port notes gathered up front (verified against both trees 2026-07-27):
+- Fork wiring to replicate: `configure.ac:117` (adapter list entry `[[wlinke],[WLINKE Programmer],[WLINKE]]`), `:284-286` (`AC_ARG_ENABLE`), `:537`, `:737` (`AM_CONDITIONAL`); `src/jtag/drivers/Makefile.am:189` (`DRIVERFILES += %D%/wlinke.c`); `src/jtag/interfaces.c:154,274` (extern + table entry).
+- Mainline transports are now a **fixed bitmask enum** (`src/transport/transport.h:19-25`: `TRANSPORT_JTAG BIT(0)` … `TRANSPORT_SWIM BIT(6)`, plus `TRANSPORT_VALID_MASK`), and `struct transport` selects by `unsigned int id`, not name. Add `#define TRANSPORT_SDI BIT(7)`, extend `TRANSPORT_VALID_MASK`, and port `sdi.c`'s `transport_register` to the id-based struct.
+- **SWIM is the exact precedent** — ST's proprietary single-wire transport, wired upstream the same way this needs: `swim_ops` field at `src/jtag/interface.h:363`, its own transport bit, own command namespace. Mirror how `grep -rn swim src/transport/ src/jtag/interface.h src/jtag/swim.c` is structured wherever the fork's 0.11-era pattern no longer matches mainline.
+- The fork's `sdi` op is a raw RISC-V DMI transfer: `adapter_driver->sdi_ops->transfer(iIndex, iAddr, iData, iOP, oAddr, oData, oOP)` (`src/jtag/sdi.c:20-22`) — keep that signature; Task 7 builds on it.
+- `wlinke.c` includes `"cmsis_dap.h"`, `"hidapi.h"`, `"libusb_helper.h"` and (spuriously) `<windows.h>` — drop/guard the windows include; hidapi + libusb helpers exist in mainline's drivers dir.
+
+- [ ] **Step 1: Copy `wlinke.c` and `sdi.c` in; make the wiring edits above**
+
+- [ ] **Step 2: Reconfigure with wlinke and build**
+
+```bash
+cd ~/app/openocd
+./configure --prefix=$HOME/app/openocd_tinyusb \
+ --enable-jlink --enable-cmsis-dap --enable-stlink --enable-wlinke --disable-werror
+make -j$(nproc) && make install
+```
+Expected: clean build (`--disable-werror` tolerates the fork's warning-dirty code; do fix outright errors).
+
+- [ ] **Step 3: Probe-detection test against real hardware** (nanoch32v203's WCH-LinkE, serial `EBCA8F0670AF`)
+
+```bash
+python3 ~/code/tinyusb/test/hil/board_lock.py hold nanoch32v203 --reason "wlinke port bring-up"
+~/app/openocd_tinyusb/bin/openocd -c "adapter driver wlinke" \
+ -c "adapter serial EBCA8F0670AF" -c "transport select sdi" \
+ -c "init" -c "shutdown"
+```
+Expected: log lines identifying the WCH-Link probe (firmware version print from `wlink_init`), no crash. `init` may complain about missing target — probe identification is the pass signal. Keep the lock held into Task 7 (same board).
+
+- [ ] **Step 4: Snapshot as work-in-progress commit** (will be amended/squashed through Task 8)
+
+```bash
+cd ~/app/openocd && git add -A && git commit -m "WIP: wch port (squash into single downstream commit before push)"
+```
+**Do not push** until Task 8 squashes.
+
+---
+
+### Task 7: WCH part 2 — target spike: mainline `riscv` over wlink DMI
+
+**The hypothesis (from the handoff, sharpened by code reading):** WCH-LinkE's `sdi` op *is* a raw DMI transfer, and mainline's riscv-013 target is just a DMI client. If mainline's riscv target can be fed by wlink DMI transfers, we skip porting `wch_riscv.c`/`wch_riscv-013.c` (~3.5k lines that `#include <target/riscv/...>` 0.11-era internals — the worst possible port surface).
+
+**Files:**
+- Modify: `src/jtag/drivers/wlinke.c` (add the DTM bridge), possibly `src/target/riscv/riscv-013.c` shim hooks — decided by Step 1's reading.
+
+**Interfaces:**
+- Consumes: Task 6's working adapter (lock on nanoch32v203 still held).
+- Produces: a `target create ... riscv` (or, on fallback, `wch_riscv`) config shape that Task 8's flash/RTT/HIL work builds on. Records the decision in the WIP commit message.
+
+- [ ] **Step 1: Read mainline's DMI plumbing before writing anything**
+
+Read `src/target/riscv/riscv-013.c` (the `dmi_op`/`riscv_batch` layer) and `src/target/riscv/riscv.c`'s `riscv dmi_read`/`dmi_write` command handlers (they exist — mainline's `tcl/target/esp32c6.cfg` calls them). Determine the narrowest insertion point, in order of preference:
+1. an existing DTM/DMI abstraction the adapter can implement directly (best);
+2. a jtag-DTM emulation inside `wlinke.c`: expose `jtag_ops` whose queue executor decodes IR=DTMCS/DMI DR scans into `sdi` transfers (the esp_usb_jtag-style approach, one level up);
+3. nothing viable → fallback (Step 4).
+
+- [ ] **Step 2: Implement the chosen bridge; build**
+
+Same build command as Task 6 Step 2.
+
+- [ ] **Step 3: Hypothesis test on nanoch32v203** (write the test cfg to the scratchpad, not the repo)
+
+```tcl
+# wch-mainline-riscv-test.cfg
+adapter driver wlinke
+adapter speed 6000
+transport select sdi ;# or jtag, if Step 1 chose the jtag-DTM emulation
+wlink_set_address 0x00000000
+sdi newtap ch32 cpu -irlen 5 -expected-id 0x00001
+target create ch32.cpu riscv -chain-position ch32.cpu
+ch32.cpu configure -work-area-phys 0x20000000 -work-area-size 0x2800 -work-area-backup 1
+init
+```
+
+Evidence criteria — **all four must hold** to call the hypothesis confirmed:
+```
+halt → "Target halted" with a sane pc
+riscv dmi_read 0x11 → plausible dmstatus (nonzero, version field = 2 or 3)
+mdw 0x20000000 4 → reads SRAM without error
+resume → target runs again (LED blink / CDC re-enumerates)
+```
+
+- [ ] **Step 4: Decision checkpoint — STOP if the hypothesis fails**
+
+If any criterion fails for reasons that look architectural (wlink protocol can't express raw DMI reads, QingKe deviates from the RISC-V debug spec in ways mainline won't tolerate), **stop and report to the user** with the evidence. The two fallback options, costed:
+- (a) Port the fork's full WCH target stack: `src/target/wch_riscv.c` (3033 ln) + `wch_riscv-013.c` + `wch_riscv.h`, plus the fork's core patches (all findable via `grep -rn 'riscvchip\|wlink_' src/` in the fork: `src/flash/nor/tcl.c` 5 hits, `src/target/target.c` 5, `src/server/gdb_server.c` 2). Hard: these files include 0.11-era `target/riscv/*` headers that clash with mainline's current riscv internals.
+- (b) Ship the unified fork **without** WCH C support and keep `openocd_wch` as the rig's CH32 flasher indefinitely.
+Do not silently pick (a).
+
+---
+
+### Task 8: WCH part 3 — flash drivers, RTT, 4-board HIL green, squash to downstream commit #3
+
+**Files:**
+- Create: `src/flash/nor/wchriscv.c` (324 ln, copy), `src/flash/nor/wcharm.c` (897 ln, copy — CH32F ARM parts; self-contained memory-mapped driver, zero wlink deps), `src/jtag/drivers/wlinke.h` (new — prototypes for the `wlink_*` exports; the fork relied on implicit declarations)
+- Modify: `src/flash/nor/drivers.c` (extern + table entries, fork pattern at its lines 93-94/170-171), `src/flash/nor/Makefile.am` (fork pattern at lines 78-79)
+- Modify (TinyUSB repo, separate branch): `test/hil/hil_test.py` WCH cfg template (~line 381) — only if Task 7 landed on the mainline-riscv target shape
+
+**Interfaces:**
+- Consumes: Task 7's confirmed target shape + `wlink_*` exports from Task 6.
+- Produces: downstream commit #3 (single squashed commit, pushed); `~/.local/bin/openocd_wch` repointed at the fork; hil_test.py template branch `claude/hil-openocd-unified` in the TinyUSB repo (unpushed — user pushes; "hold pushes" applies to the TinyUSB repo).
+
+- [ ] **Step 1: Copy the flash drivers, add `wlinke.h`, wire `drivers.c`/`Makefile.am`; build**
+
+Keep the flash driver's registered name **`wch_riscv`** — the rig's generated per-probe cfg does `flash bank ... wch_riscv ...` and Task 8 Step 4's template keeps working.
+Fork quirk to *not* copy: the fork patched `src/flash/nor/tcl.c` (`handle_flash_protect_check_command`, its line ~414) to call `wlink_softreset()`/`wlnik_protect_check()` for WCH banks. Implement that inside `wchriscv.c`'s own `protect_check` op instead — no core-file patch.
+Check the fork's `src/server/gdb_server.c` 2 `wlink_` hits (`grep -n 'riscvchip\|wlink_' ~/app/riscv-openocd-wch/src/server/gdb_server.c`) — port the behavior into the driver/target layer if it matters for our flow (flash + RTT, no gdb needed on the rig for WCH), else document-and-skip in the commit message.
+
+- [ ] **Step 2: Flash test on nanoch32v203** (lock held; cfg = Task 7's test cfg + flash bank line)
+
+```tcl
+set _FLASHNAME ch32.flash
+flash bank $_FLASHNAME wch_riscv 0x00000000 0 0 0 ch32.cpu
+```
+```bash
+~/app/openocd_tinyusb/bin/openocd -c "adapter serial EBCA8F0670AF" \
+ -f wch-mainline-riscv-test.cfg \
+ -c "program /home/hathach/code/tinyusb/examples/cmake-build-nanoch32v203-usbfs/device/cdc_msc/cdc_msc.elf verify reset exit"
+```
+Expected: `** Verified OK **`; board re-enumerates as CDC (`lsusb | grep -i cafe` or dmesg).
+
+- [ ] **Step 3: RTT test on nanoch32v203** (rig rule: `rtt polling_interval 1`, **never `reset run`**)
+
+RTT server start → capture a few seconds → nonzero events. The `target-debug` skill documents the WCH RTT route.
+
+- [ ] **Step 4: Update the rig's WCH flow**
+
+If Task 7 confirmed the mainline-riscv shape, the generated cfg template in `test/hil/hil_test.py` (~line 381: `adapter driver wlinke` … `target create $_TARGETNAME.0 wch_riscv …`) must switch to the Task 7 cfg shape. Do this on a TinyUSB branch:
+```bash
+cd ~/code/tinyusb && git worktree add .worktrees/claude/hil-openocd-unified -b claude/hil-openocd-unified
+# edit test/hil/hil_test.py template in the worktree; commit there; DO NOT push
+```
+Then repoint the rig's WCH binary (symlink, so scripts resolve):
+```bash
+ln -sf ~/app/openocd_tinyusb/bin/openocd ~/.local/bin/openocd_wch
+```
+(Old target `~/app/openocd_wch_new/bin/…` and `~/.local/bin/openocd_wch.bak-20260727` stay as rollback.)
+
+- [ ] **Step 5: HIL green on all four WCH boards** (run from the worktree so the new template is used)
+
+```bash
+cd ~/code/tinyusb/.worktrees/claude/hil-openocd-unified
+python3 test/hil/hil_test.py test/hil/tinyusb.json \
+ -b nanoch32v203 -b ch32v103r_r1_1v0 -b ch32v307v_r1_1v0 -b ch582m_evt
+```
+Expected: 4/4 PASS. Firmware for missing `cmake-build-<board>` sets: build first (nanoch32v203 sets exist; ch32v103/307/ch582m may need `tools/get_deps.py -b <board>` + the examples build). Known flake: ch32v103r throughput is ~40% flaky historically — retry before blaming the port. If ch582m misbehaves specifically, note it and check `wlinke.c`'s riscvchip dispatch for CH58x.
+
+- [ ] **Step 6: Squash Tasks 6–8 into downstream commit #3 and push**
+
+```bash
+cd ~/app/openocd
+git reset --soft $(git log --grep='WIP: wch port' --format=%H | tail -1)^
+git commit -m "jtag, flash: add WCH-LinkE adapter, sdi transport and CH32 flash drivers
+
+Ported from hathach/riscv-openocd-wch @ ccb04d7 (originally
+dragonlock2/miscboards WCH SDK, base openocd 0.11.0):
+- src/jtag/drivers/wlinke.c: WCH-Link/LinkE USB adapter (GCC-14 fixes included)
+- src/jtag/sdi.c: WCH single-wire debug transport, re-worked onto
+ mainline's id-based transport API (TRANSPORT_SDI)
+- src/flash/nor/wchriscv.c, wcharm.c: CH32V/CH5xx (wlink protocol) and
+ CH32F (memory-mapped) flash drivers
+CH32 cores are driven by mainline's riscv target over wlink DMI
+transfers; the fork's wch_riscv target stack is not needed.
+The fork's core patches (flash/nor/tcl.c protect-check hack) moved into
+the wch_riscv flash driver's protect_check op.
+
+Verified on ci rig: nanoch32v203, ch32v103r_r1_1v0, ch32v307v_r1_1v0,
+ch582m_evt - flash + verify + HIL suite + RTT (nanoch32v203)."
+git push
+```
+(Amend the target-stack paragraph if the fallback path was taken instead.)
+Release the nanoch32v203 lock if still held.
+
+---
+
+### Task 9: Espressif — ESP32-P4 configs, S3 attach verification, downstream commit #4
+
+Mainline already has: `src/target/espressif/` (esp32/s2/s3 xtensa targets + apptrace/semihosting), the `esp_usb_jtag` adapter driver, and builtin cfgs for c2/c3/c6/h2/s3. Missing vs the rig: anything ESP32-P4. Flash stays esptool (rig flashes ESP via `idf.py`/esptool; the espressif fork's flash-stub stack is explicitly out of scope).
+
+**Files:**
+- Create: `~/app/openocd/tcl/target/esp32p4.cfg`, `~/app/openocd/tcl/board/esp32p4-builtin.cfg`
+
+**Interfaces:**
+- Consumes: install prefix; espressif fork cfgs fetched from GitHub.
+- Produces: downstream commit #4; P4 + S3 debug-attach evidence.
+
+- [ ] **Step 1: Verify S3 attach with pure mainline inheritance** (no new files; proves the "espressif support" baseline)
+
+```bash
+python3 ~/code/tinyusb/test/hil/board_lock.py hold espressif_s3_devkitm --reason "openocd-unified esp verify"
+~/app/openocd_tinyusb/bin/openocd -f board/esp32s3-builtin.cfg -c "init; halt"
+```
+Expected: both xtensa cores detected over USB-Serial-JTAG (303a:1001), `Target halted`. Then `resume; shutdown`, release lock. Gotchas live in the `esp-target-debug` skill (S3's debug port can be occupied when TinyUSB firmware owns the USB peripheral — use the same recovery steps as that skill).
+
+- [ ] **Step 2: Fetch and adapt the P4 configs (write both files)**
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/espressif/openocd-esp32/master/tcl/target/esp32p4.cfg -o /tmp/claude-1000/-home-hathach-code-tinyusb/7dee5f9e-874b-4680-bb09-01a5d13fbd37/scratchpad/esp32p4-espressif.cfg
+curl -fsSL https://raw.githubusercontent.com/espressif/openocd-esp32/master/tcl/board/esp32p4-builtin.cfg -o /tmp/claude-1000/-home-hathach-code-tinyusb/7dee5f9e-874b-4680-bb09-01a5d13fbd37/scratchpad/esp32p4-builtin-espressif.cfg
+```
+Espressif's cfg creates an `esp32p4`-type target (their `esp_riscv` C stack — not in mainline). Rewrite `tcl/target/esp32p4.cfg` following **mainline's own ESP RISC-V pattern** — `tcl/target/esp32c6.cfg` + `esp_common.cfg` (generic `riscv` target create, chip quirks via `riscv dmi_write` with the `_RISCV_*` register constants from `esp_common.cfg`) — carrying over from Espressif's file: `_CPUTAPID`, memory map/workarea, the dual-core SMP topology (P4 is 2× RV32 — model on how mainline handles SMP, and on Espressif's `_ESP_SMP_TARGET`), and the `_ESP_EFUSE_MAC_ADDR_REG` value. `tcl/board/esp32p4-builtin.cfg` = `esp_usb_jtag` adapter + `transport select jtag` + source the target cfg (mirror `board/esp32c6-builtin.cfg`, adjusting `ESP_USB_JTAG_*` ids to Espressif's P4 values).
+Also check `src/jtag/drivers/esp_usb_jtag.c` accepts the P4 (VID/PID 303a:1001 is shared; verify any chip-id gating).
+
+- [ ] **Step 3: P4 attach test**
+
+```bash
+python3 ~/code/tinyusb/test/hil/board_lock.py hold espressif_p4_function_ev --reason "openocd-unified esp verify"
+cd ~/app/openocd && make install
+~/app/openocd_tinyusb/bin/openocd -f board/esp32p4-builtin.cfg -c "init; halt"
+```
+Evidence criteria: both HP cores halt, `mdw 0x4ff00000 4` (P4 HP TCM/SRAM — cross-check the address against Espressif's cfg memory map before running) reads, `resume` works. Known nuance from prior sessions: P4 attach can need the reset-into-attach dance — the `esp-target-debug` skill documents it; an attach that only works with that dance still counts as pass (note it in the commit).
+**Decision checkpoint:** if the generic-riscv shape cannot attach P4 for architectural reasons (needs Espressif's C-level `esp_riscv` assist), stop and report — options are cherry-picking their `esp_riscv` stack (large) vs shipping P4 as esptool-flash-only with debug via ESP-IDF's openocd as today. Do not silently pick either.
+
+- [ ] **Step 4: Commit (downstream commit #4) and push**
+
+```bash
+cd ~/app/openocd
+git add tcl/target/esp32p4.cfg tcl/board/esp32p4-builtin.cfg
+git commit -m "tcl: add ESP32-P4 target/board configs adapted from espressif/openocd-esp32
+
+Adapted from espressif/openocd-esp32 master onto mainline's generic
+RISC-V ESP pattern (tcl/target/esp32c6.cfg + esp_common.cfg): generic
+riscv targets over esp_usb_jtag instead of the fork's esp_riscv C
+stack. Flash programming stays with esptool, matching how the rig
+flashes all Espressif boards. ESP32/S2/S3/C3/C6/H2 were already
+supported by mainline.
+
+Verified on ci rig: espressif_p4_function_ev and espressif_s3_devkitm
+attach/halt/resume over built-in USB-Serial-JTAG."
+git push
+```
+Release both ESP board locks.
+
+---
+
+### Task 10: Final sweep, README truth-up, rig config flip
+
+**Files:**
+- Modify: `~/app/openocd/README.md` (only if scope shifted in Tasks 7–9)
+- Modify (TinyUSB worktree from Task 8): `test/hil/tinyusb.json` — max32666fthr flasher `openocd_adi` → `openocd` with args `-f interface/cmsis-dap.cfg -f target/max32665.cfg` (plain openocd now serves it)
+
+**Interfaces:**
+- Consumes: everything green from Tasks 4–9.
+- Produces: the finished fork; TinyUSB branch `claude/hil-openocd-unified` with hil_test.py + tinyusb.json changes, committed, **unpushed** (user pushes per standing instruction).
+
+- [ ] **Step 1: Full HIL regression across every openocd-family board**
+
+```bash
+cd ~/code/tinyusb/.worktrees/claude/hil-openocd-unified
+python3 test/hil/hil_test.py test/hil/tinyusb.json \
+ -b raspberry_pi_pico -b raspberry_pi_pico_w -b raspberry_pi_pico2 \
+ -b adafruit_fruit_jam -b stm32h743nucleo -b stm32g0b1nucleo -b max32666fthr \
+ -b nanoch32v203 -b ch32v103r_r1_1v0 -b ch32v307v_r1_1v0 -b ch582m_evt
+```
+Expected: 11/11 PASS (ch32v103r throughput may need its usual retries).
+
+- [ ] **Step 2: README truth-up**
+
+Re-read `README.md` against what actually landed (WCH target route, P4 outcome). Fix any row that no longer matches; amend into the README commit or add
+`git commit -m "README: reflect verified scope"`. Push.
+
+- [ ] **Step 3: Verify the one-commit-per-fork shape**
+
+Run: `git -C ~/app/openocd log --oneline upstream/master..tinyusb`
+Expected: exactly 5 commits (or 6 with a README truth-up): README, RPi configs, ADI config, WCH port, ESP32-P4 configs. If not, interactive-free cleanup: `git rebase --onto` / `reset --soft` re-squash, then `git push --force-with-lease` (fork branch, ours alone — safe).
+
+- [ ] **Step 4: Commit the TinyUSB-side changes in the worktree (do not push)**
+
+```bash
+cd ~/code/tinyusb/.worktrees/claude/hil-openocd-unified
+git add test/hil/hil_test.py test/hil/tinyusb.json
+git commit -m "test(hil): drive WCH boards and max32666fthr through the unified openocd"
+```
+Leave for the user to push/PR.
+
+- [ ] **Step 5: Leftovers report** (no deletions now)
+
+Write a short status into `OPENOCD_UNIFIED_FORK_HANDOFF.md` (append a "2026-07-XX outcome" section): what was repointed, rollback paths (`/usr/local/bin/openocd.rpi-backup-20260727`, `~/.local/bin/openocd_wch.bak-20260727`), and that `~/app/openocd_rpi`, `~/app/openocd_adi`, `~/app/openocd-mainline`, `~/app/openocd_mainline`, `~/app/openocd_wch_new`, `~/app/riscv-openocd-wch` can be retired **after a week of green CI** — not now.
diff --git a/docs/superpowers/plans/2026-07-28-hil-test-split.md b/docs/superpowers/plans/2026-07-28-hil-test-split.md
new file mode 100644
index 000000000..6b8528973
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-hil-test-split.md
@@ -0,0 +1,355 @@
+# hil_test.py Split Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Split `test/hil/hil_test.py` (2370 ln) into a test-focused core plus `hil_lock.py` (board locks + controller permits + operator CLI, superseding `board_lock.py`) and `hil_flash.py` (run_cmd + flash backends + firmware/serial lookup), with no behavior change.
+
+**Architecture:** Pure code motion per `docs/superpowers/specs/2026-07-28-hil-test-refactor-design.md`. Import graph: `hil_test` → {`hil_lock`, `hil_flash`}; helpers import nothing local. Call sites use module-qualified names (`hil_lock.flash_permit(...)`), never wildcard mirroring.
+
+**Tech Stack:** Python 3.11+ (existing `TypedDict`/`NotRequired` usage), stdlib only in the helpers (fcntl, json, glob, multiprocessing objects passed in).
+
+## Global Constraints
+
+- Work in worktree `.claude/worktrees/hil-test-split` (branch `claude/hil-test-split`); never touch the primary checkout.
+- Behavior-preserving: `hil_test.py` CLI args, log lines, report format, lock/permit semantics, flash behavior all byte-identical. The ONLY user-visible change is the CLI filename `board_lock.py` → `hil_lock.py`.
+- Moved functions are moved **verbatim** — no reformatting, no comment editing, no "improvements". A diff of a moved function's body against its old self must be empty.
+- Commit messages: imperative, scoped, no Co-Authored-By/Claude-Session trailers.
+- Every commit leaves the tree working: `python3 -m py_compile` clean on all touched modules, and `python3 .claude/skills/hil/pool_check.py --scan-only` exits 0 (safe on the rig: scan-only takes no locks, flashes nothing).
+- Hardware steps (Task 4) run on the `ci` rig only, from this worktree, and rely on the tools' own board flocks — never pre-hold boards you are about to run `hil_test.py`/`pool_check.py` on.
+
+---
+
+### Task 1: Create hil_flash.py; repoint hil_test + pool_check flash call sites
+
+**Files:**
+- Create: `test/hil/hil_flash.py`
+- Modify: `test/hil/hil_test.py` (delete moved code; add import; qualify call sites)
+- Modify: `.claude/skills/hil/pool_check.py` (flash-related imports)
+- Modify: `test/hil/hil_ci.sh` (scp list)
+
+**Interfaces:**
+- Produces (used by Tasks 2-4): module `hil_flash` with `CMD_TIMEOUT`, `run_cmd(cmd, cwd=None, timeout=CMD_TIMEOUT)`, `cmd_stdout_text(out)`, `OPENCOD_ADI_PATH`, `TINYUSB_ROOT`, `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)`, module globals `build_dir = 'cmake-build'`, `verbose = False`.
+
+- [ ] **Step 1: Create `test/hil/hil_flash.py`**
+
+Header (new code), then the moved blocks verbatim:
+
+```python
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Firmware flashing for the TinyUSB HIL rig: run_cmd, one flash_*/reset_* pair per
+# flasher type (dispatched by config name via getattr), find_firmware, and the
+# fixture serial-port resolver get_serial_dev (here, not hil_test: flash_esptool
+# needs it and helpers must not import hil_test).
+# Callers set module globals `build_dir` and `verbose` (hil_test.main from argparse,
+# pool_check directly) exactly as they set hil_test's globals today.
+
+import glob
+import json
+import os
+import signal
+import subprocess
+import sys
+from pathlib import Path
+
+verbose = False
+build_dir = 'cmake-build'
+```
+
+Then MOVE (cut from `hil_test.py`, paste unchanged, in this order):
+1. `CMD_TIMEOUT = int(os.getenv('HIL_CMD_TIMEOUT', '180'))` (from the constants block; leave `POOL_TIMEOUT`/`SERIAL_*_TIMEOUT` in hil_test)
+2. `def cmd_stdout_text(out)`
+3. `OPENCOD_ADI_PATH = Path.home() / 'app' / 'openocd_adi'` and `TINYUSB_ROOT = Path(__file__).resolve().parents[2]`
+4. `def get_serial_dev(id, vendor_str, product_str, ifnum)`
+5. `def run_cmd(cmd, cwd=None, timeout=CMD_TIMEOUT)`
+6. All ten `flash_*`/`reset_*` pairs listed in Interfaces, in current file order
+7. `def find_firmware(variant, example)`
+
+- [ ] **Step 2: Delete the moved code from `hil_test.py` and qualify call sites**
+
+In `hil_test.py`: add `import hil_flash` under the existing imports; delete the moved definitions and the `build_dir = 'cmake-build'` global (line ~165) plus `global build_dir` in `main`. Repoint every use, all module-qualified:
+- `globals()[f'flash_{...}']` → `getattr(hil_flash, f'flash_{...}')` (1 site, in `test_example`)
+- `globals()[f'reset_{...}']` → `getattr(hil_flash, f'reset_{...}')` (3 sites: `test_host_device_info`, `test_host_cdc_msc_hid`, `test_host_msc_file_explorer`)
+- bare `run_cmd(` → `hil_flash.run_cmd(` ; `cmd_stdout_text(` → `hil_flash.cmd_stdout_text(` ; `find_firmware(` → `hil_flash.find_firmware(` ; `get_serial_dev(` → `hil_flash.get_serial_dev(` ; `TINYUSB_ROOT` → `hil_flash.TINYUSB_ROOT` (in `build_board`, `CONTROLLER_CACHE` stays hil_test-local)
+- In `main()`: `build_dir = args.build_dir` → `hil_flash.build_dir = args.build_dir`; where `verbose` is set, add `hil_flash.verbose = args.verbose` (hil_test keeps its own `verbose` for test-side prints)
+- `run_cmd`'s `elif verbose:` branch now reads `hil_flash.verbose` (it moved with the function — verify it references the module-local name, not hil_test's)
+
+Find every remaining call site mechanically:
+
+Run: `grep -nE 'run_cmd|cmd_stdout_text|find_firmware|get_serial_dev|flash_[a-z]|reset_[a-z]|TINYUSB_ROOT|OPENCOD' test/hil/hil_test.py | grep -v hil_flash`
+Expected: only hits inside comments/strings and the `reset_{flasher}` dispatch f-strings already qualified.
+
+- [ ] **Step 3: Repoint pool_check's flash imports**
+
+In `.claude/skills/hil/pool_check.py`: add `import hil_flash` next to `import hil_test`; replace `hil_test.find_firmware` → `hil_flash.find_firmware` (3 sites), `hil_test.cmd_stdout_text` → `hil_flash.cmd_stdout_text`, `hil_test.get_serial_dev` → `hil_flash.get_serial_dev`, `hil_test.TINYUSB_ROOT` → `hil_flash.TINYUSB_ROOT`, `hil_test.build_dir` → `hil_flash.build_dir` (2 sites incl. `main`'s assignment), `hil_test.verbose = args.verbose` → `hil_flash.verbose = args.verbose`, `getattr(hil_test, f'flash_...')`/`getattr(hil_test, f'reset_...')` → `getattr(hil_flash, ...)` (4 sites). Keep `import hil_test` and the pymtp shim for now (locks still live there; removed in Task 2).
+
+- [ ] **Step 4: Add hil_flash.py to the hil_ci.sh scp list**
+
+```bash
+scp -q "$ROOT_DIR/test/hil/hil_test.py" \
+ "$ROOT_DIR/test/hil/hil_flash.py" \
+ "$ROOT_DIR/test/hil/pymtp.py" \
+ "$CONFIG" \
+ "$REMOTE:$REMOTE_DIR/test/hil/"
+```
+
+- [ ] **Step 5: Verify**
+
+Run: `python3 -m py_compile test/hil/hil_flash.py test/hil/hil_test.py .claude/skills/hil/pool_check.py && python3 test/hil/hil_test.py --help >/dev/null && python3 .claude/skills/hil/pool_check.py --scan-only`
+Expected: compiles; help prints nothing to stderr; scan-only prints the table and exits 0.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add test/hil/hil_flash.py test/hil/hil_test.py test/hil/hil_ci.sh .claude/skills/hil/pool_check.py
+git commit -m "hil: extract flashing into hil_flash.py"
+```
+
+---
+
+### Task 2: Create hil_lock.py core (flock protocol + controller permits); repoint hil_test + pool_check
+
+**Files:**
+- Create: `test/hil/hil_lock.py`
+- Modify: `test/hil/hil_test.py`
+- Modify: `.claude/skills/hil/pool_check.py`
+- Modify: `test/hil/hil_ci.sh`
+
+**Interfaces:**
+- Produces: module `hil_lock` with `BOARD_LOCK_DIR`, `CI_REASON = 'hil_test.py'`, `lock_path(board)`, `flock_nb(board)`, `write_record(fh, reason)`, `clear_record(fh)`, `read_record(board)`, `acquire_board_lock(board, reason=CI_REASON)`, `FLASH_PARALLEL`, `USBTEST_PARALLEL`, `CONTROLLER_SLOTS`, `controller_of(uid)`, `controller_slot(pci)`, `controller_permit`, `flash_permit(uid)`, `usbtest_permit(uid)`, `init_scheduling(b_sems, f_sems, cmap, cmeta, hints, log_fn=None)`.
+
+- [ ] **Step 1: Create `test/hil/hil_lock.py` with the flock core**
+
+New code (the protocol, factored from today's three copies — `board_lock.py` `cmd_hold`/`read_info`, `hil_test.acquire_board_lock`, pool_check `lock_board`; behavior identical to `hil_test.acquire_board_lock` for the acquire path):
+
+```python
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+"""Board locks + controller permits for the TinyUSB HIL rig.
+
+Board locks are kernel flocks in BOARD_LOCK_DIR arbitrating hardware access
+between dev sessions and CI's hil_test.py (never stop the actions-runner).
+Controller permits are in-process semaphores budgeting flashes and usbtest
+batteries per host controller; they have no CLI meaning. The CLI below
+(hold/release/status) manages board locks only; it supersedes board_lock.py.
+"""
+import argparse
+import fcntl
+import glob
+import json
+import os
+import re
+import select
+import signal
+import sys
+import time
+
+BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks'
+CI_REASON = 'hil_test.py' # release-protected holder tag (release refuses to kill it)
+PROFILE = os.environ.get('HIL_PROFILE') == '1'
+
+
+def lock_path(board: str) -> str:
+ return os.path.join(BOARD_LOCK_DIR, f'{board}.lock')
+
+
+def flock_nb(board: str):
+ """Open-or-create the lock file WITHOUT truncating (a losing racer must not
+ wipe the winner's record) and take LOCK_EX|LOCK_NB. Returns the open handle;
+ raises OSError when the flock is held elsewhere (handle already closed)."""
+ fd = os.open(lock_path(board), os.O_RDWR | os.O_CREAT, 0o666)
+ fh = os.fdopen(fd, 'r+')
+ try:
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ fh.close()
+ raise
+ return fh
+
+
+def write_record(fh, reason: str) -> None:
+ """Best-effort holder record; the flock itself is already held."""
+ try:
+ fh.truncate(0)
+ fh.seek(0)
+ json.dump({'pid': os.getpid(), 'reason': reason,
+ 'since': time.strftime('%Y-%m-%dT%H:%M:%S%z')}, fh)
+ fh.flush()
+ except OSError:
+ pass
+
+
+def clear_record(fh) -> None:
+ """Clear our record before dropping the flock so records stay truthful."""
+ try:
+ fh.truncate(0)
+ except OSError:
+ pass
+
+
+def read_record(board: str):
+ try:
+ with open(lock_path(board)) as f:
+ return json.load(f)
+ except (OSError, ValueError):
+ return None
+```
+
+Then MOVE `acquire_board_lock` from `hil_test.py` verbatim, with exactly two mechanical edits: signature becomes `def acquire_board_lock(board_name, reason=CI_REASON):` and the record-write dict's `'reason': 'hil_test.py'` becomes `'reason': reason`. Do NOT rewrite its body in terms of `flock_nb` — on conflict it reads holder info from the still-open handle before closing, which `flock_nb` (closes on conflict) cannot provide; the fail-open warning text and RuntimeError message must survive character-for-character.
+
+- [ ] **Step 2: Move the controller-permit block into `hil_lock.py`**
+
+MOVE verbatim from `hil_test.py`: the scheduling comment block + `FLASH_PARALLEL`, `USBTEST_PARALLEL`, `CONTROLLER_SLOTS`, the five module globals (`usbtest_sems`, `flash_sems`, `controller_map`, `controller_meta`, `controller_hints`), `controller_of`, `controller_slot`, `controller_permit`, `flash_permit`, `usbtest_permit`. Two mechanical adaptations:
+- add at module scope `log = print` and a setter, replacing the two `log_line(...)` calls inside `controller_of`/`controller_permit` with `log(...)`:
+
+```python
+log = print # hil_test.init_worker points this at log_line via init_scheduling
+
+
+def init_scheduling(b_sems, f_sems, cmap, cmeta, hints, log_fn=None):
+ """Install per-worker scheduling state (called from hil_test.init_worker)."""
+ global usbtest_sems, flash_sems, controller_map, controller_meta, controller_hints, log
+ usbtest_sems, flash_sems = b_sems, f_sems
+ controller_map, controller_meta, controller_hints = cmap, cmeta, hints
+ if log_fn is not None:
+ log = log_fn
+```
+
+- `PROFILE` inside `controller_permit` now resolves to hil_lock's own module constant (defined in Step 1).
+
+- [ ] **Step 3: Repoint `hil_test.py`**
+
+Add `import hil_lock`. Delete the moved lock + permit code and the five globals. `init_worker` keeps its exact signature and initargs; its body sets the hil_test globals it still owns (`print_lock`, `shuffle_seed`) and forwards the rest:
+
+```python
+def init_worker(lock, seed, b_mutexes, f_sems, cmap, cmeta, hints_by_uid):
+ global print_lock, shuffle_seed
+ print_lock = lock
+ shuffle_seed = seed
+ hil_lock.init_scheduling(b_mutexes, f_sems, cmap, cmeta, hints_by_uid, log_fn=log_line)
+```
+
+Qualify remaining uses: `acquire_board_lock(name)` → `hil_lock.acquire_board_lock(name)` (in `test_board`), `flash_permit(` → `hil_lock.flash_permit(`, `usbtest_permit(` → `hil_lock.usbtest_permit(`, and `main()`'s startup log line + Semaphore construction read `hil_lock.FLASH_PARALLEL`/`hil_lock.USBTEST_PARALLEL`/`hil_lock.CONTROLLER_SLOTS`. `controller_map` reads in the hint-persistence block of `main` use the Manager dict it already holds locally (`cmap`) — no hil_lock global access there; verify.
+
+- [ ] **Step 4: Repoint pool_check to hil_lock and drop its private copies + hil_test import**
+
+In `pool_check.py`: replace `lock_board`/`unlock_board` bodies with the shared core —
+
+```python
+import hil_lock
+
+def lock_board(name: str):
+ try:
+ fh = hil_lock.flock_nb(name)
+ except OSError:
+ info = hil_lock.read_record(name)
+ return json.dumps(info) if info else 'unknown holder'
+ hil_lock.write_record(fh, 'pool_check')
+ return fh
+
+
+def unlock_board(fh) -> None:
+ hil_lock.clear_record(fh)
+ fh.close()
+```
+
+(Behavior note: `lock_board` currently returns the raw record text; JSON-dumping the parsed record is equivalent for display. `hil_lock.BOARD_LOCK_DIR` replaces `hil_test.BOARD_LOCK_DIR`; `os.makedirs(...)` call stays, now on `hil_lock.BOARD_LOCK_DIR`.) Then delete `import hil_test` and the pymtp stub block (`try: import pymtp ... sys.modules['pymtp'] = ...`) — pool_check now imports only `hil_lock` + `hil_flash`.
+
+Run: `grep -n 'hil_test' .claude/skills/hil/pool_check.py`
+Expected: only the docstring mention of the protocol/history, no code references (update the docstring's "imports test/hil/hil_test.py" line to name hil_lock/hil_flash).
+
+- [ ] **Step 5: Add hil_lock.py to the hil_ci.sh scp list** (same block as Task 1 Step 4, one more line: `"$ROOT_DIR/test/hil/hil_lock.py" \`)
+
+- [ ] **Step 6: Verify**
+
+Run: `python3 -m py_compile test/hil/hil_lock.py test/hil/hil_test.py .claude/skills/hil/pool_check.py && python3 test/hil/hil_test.py --help >/dev/null && python3 .claude/skills/hil/pool_check.py --scan-only`
+Expected: clean compile, working scan table, exit 0.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add test/hil/hil_lock.py test/hil/hil_test.py test/hil/hil_ci.sh .claude/skills/hil/pool_check.py
+git commit -m "hil: extract board locks and controller permits into hil_lock.py"
+```
+
+---
+
+### Task 3: Absorb board_lock.py CLI into hil_lock.py; delete board_lock.py; rename in docs
+
+**Files:**
+- Modify: `test/hil/hil_lock.py` (append CLI)
+- Delete: `test/hil/board_lock.py`
+- Modify: `.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`
+
+**Interfaces:**
+- Produces: `python3 test/hil/hil_lock.py hold|release|status` — identical subcommands, flags, output, and exit codes to today's `board_lock.py`.
+
+- [ ] **Step 1: Move the CLI from `board_lock.py` into `hil_lock.py`**
+
+MOVE verbatim to the end of `hil_lock.py`: `boards_from_config`, `is_locked`, `cmd_hold`, `cmd_release`, `cmd_status`, `main()`, and the `if __name__ == '__main__':` guard. Mechanical adaptations only:
+- `LOCK_DIR` → `BOARD_LOCK_DIR` (all sites), `lock_path` already exists (delete the duplicate), `read_info` → `read_record` (all sites; delete the duplicate definition)
+- `cmd_hold`'s holder loop body (the open/flock/json.dump block) becomes `fh = flock_nb(b)` + `write_record(fh, reason)` inside the existing try/except OSError
+- `_bow_out`'s per-handle truncate loop becomes `clear_record(h)` per handle
+- `cmd_release`'s probe uses `flock_nb(b)` in a try/except OSError (held → existing record/victim logic, with the literal `'hil_test.py'` comparison becoming `CI_REASON`); the free-path truncate becomes `clear_record(fh)`
+- `main()`'s module docstring reference for `--help` text: keep the usage lines, updating the tool name to `hil_lock.py`
+
+Then delete `test/hil/board_lock.py` (`git rm test/hil/board_lock.py`).
+
+- [ ] **Step 2: Rename `board_lock.py` → `hil_lock.py` in the six live docs**
+
+Run: `cd <worktree> && sed -i 's/board_lock\.py/hil_lock.py/g' .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`
+Then: `grep -rn 'board_lock' .claude/ test/ --include='*.md' --include='*.py' --include='*.sh'`
+Expected: zero hits outside `docs/superpowers/` history (which stays untouched).
+
+- [ ] **Step 3: Verify CLI behavior end-to-end**
+
+```bash
+python3 test/hil/hil_lock.py status # expect: no locks (or current holders)
+python3 test/hil/hil_lock.py hold stm32f072disco --reason "split test" &
+sleep 1
+python3 test/hil/hil_lock.py status # expect: stm32f072disco: {... 'reason': 'split test' ...}
+python3 test/hil/hil_lock.py hold stm32f072disco --reason "rival" || echo "conflict OK" # expect: ERROR ... locked + conflict OK
+python3 test/hil/hil_lock.py release stm32f072disco # expect: released holder pid NNN
+python3 test/hil/hil_lock.py status # expect: no locks
+```
+
+Also verify CI-holder protection: create a fake record `echo '{"pid": 1, "reason": "hil_test.py"}' > /tmp/tinyusb-hil-locks/faketest.lock` — since pid 1 holds no flock, `release faketest` must clear the stale record without printing the mid-test error; then `rm -f /tmp/tinyusb-hil-locks/faketest.lock`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A test/hil .claude
+git commit -m "hil: fold board_lock CLI into hil_lock.py, retire board_lock.py"
+```
+
+---
+
+### Task 4: Rig verification + pre-commit
+
+**Files:** none new (fixes only if verification fails)
+
+- [ ] **Step 1: pool_check flash path on one board**
+
+Run: `python3 .claude/skills/hil/pool_check.py -b stm32f407disco`
+Expected: `✅ dfu_runtime ✅ cafe:...`, exit 0.
+
+- [ ] **Step 2: Capture a pre-refactor baseline report**
+
+Run: `cd /home/hathach/code/tinyusb && python3 test/hil/hil_test.py -b stm32f407disco -B examples test/hil/tinyusb.json && cp hil_report.md /tmp/claude-1000/-home-hathach-code-tinyusb/*/scratchpad/hil_report_master.md`
+(Primary checkout = pre-refactor code but same rig/config; its working tree already carries the new probe uids.)
+
+- [ ] **Step 3: Run the same board from the worktree and diff the report shape**
+
+Run: `cd .claude/worktrees/hil-test-split && python3 test/hil/hil_test.py -b stm32f407disco -B /home/hathach/code/tinyusb/examples test/hil/tinyusb.json && diff <(sed 's/[0-9.]*s//g;s/[0-9.]* [kMG]B\/s//g' hil_report.md) <(sed 's/[0-9.]*s//g;s/[0-9.]* [kMG]B\/s//g' /tmp/claude-1000/-home-hathach-code-tinyusb/*/scratchpad/hil_report_master.md)`
+Expected: empty diff after stripping timings/speeds. Note: `-B` accepts the absolute path so the worktree run reuses the primary checkout's built firmware; `find_firmware` resolves `TINYUSB_ROOT/<build_dir>` and an absolute `-B` overrides relative rooting — if it does not (Path join semantics), instead symlink `ln -s /home/hathach/code/tinyusb/examples/cmake-build-stm32f407disco examples/cmake-build-stm32f407disco` in the worktree and use `-B examples`.
+
+- [ ] **Step 4: pre-commit + final grep hygiene**
+
+Run: `pre-commit run --files test/hil/hil_test.py test/hil/hil_lock.py test/hil/hil_flash.py test/hil/hil_ci.sh .claude/skills/hil/pool_check.py $(git diff --name-only HEAD~3 -- '*.md')`
+Expected: all hooks pass.
+
+- [ ] **Step 5: Commit any verification fixes**
+
+```bash
+git add -A && git commit -m "hil: post-split verification fixes" # only if Steps 1-4 required changes
+```
diff --git a/docs/superpowers/plans/2026-07-29-hil-select.md b/docs/superpowers/plans/2026-07-29-hil-select.md
new file mode 100644
index 000000000..a8abf9887
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-29-hil-select.md
@@ -0,0 +1,856 @@
+# PR-Scoped HIL Selection Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A diff→(boards, tests) selector (`test/hil/hil_select.py`) that scopes CI's HIL build+test jobs on pull requests and is reusable locally, per `docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md`.
+
+**Architecture:** Pure-stdlib classification engine (changed files → per-board test selection, fail-open to full) + thin CLI emitting JSON with per-rig `hil_test.py` arg strings; consumed by `hil_ci_set_matrix.py --select` (prunes hil-build) and shell steps in the three HIL jobs (prunes rig runs). Test lists shared via new `hil_examples.py`.
+
+**Tech Stack:** Python 3.11 stdlib only (`re`, `json`, `glob`, `subprocess` for git), `unittest` for tests, GitHub Actions YAML.
+
+## Global Constraints
+
+- Work in worktree `.claude/worktrees/hil-select` (branch `claude/hil-select`); never touch the primary checkout.
+- `hil_select.py`, `hil_examples.py`, `test_hil_select.py` import NOTHING outside the stdlib and each other — in particular never `hil_test`/`hil_flash`/`hil_lock` (GitHub's bare runner has no pyserial/pymtp).
+- Fail-open: any changed file matching no classification rule ⇒ `full: true`. Scoping applies to `pull_request` events only; push/scheduled runs stay full.
+- Behavior-preserving for existing tools: `hil_test.py` runtime behavior unchanged (only its test-list constants move to `hil_examples.py`); `hil_ci_set_matrix.py` without `--select` emits byte-identical output to today.
+- The selector only ever emits board names present in the given roster (`config['boards']`); `boards-skip` is invisible to it.
+- Commit messages: imperative, scoped, NO Co-Authored-By/Claude-Session trailers.
+- Every commit: `python3 -m py_compile` clean on touched python files, `python3 test/hil/test_hil_select.py` green (once it exists), `pre-commit run --files <touched>` clean.
+
+---
+
+### Task 1: hil_examples.py + selection engine with unit tests
+
+**Files:**
+- Create: `test/hil/hil_examples.py`
+- Create: `test/hil/hil_select.py` (engine only; CLI comes in Task 2)
+- Create: `test/hil/test_hil_select.py`
+- Modify: `test/hil/hil_test.py` (import test lists from hil_examples)
+- Modify: `test/hil/hil_ci.sh` (scp list gains `hil_examples.py`)
+
+**Interfaces:**
+- Produces `hil_examples.py`: `device_tests: list[str]`, `dual_tests: list[str]`, `host_test: list[str]` — the three lists moved VERBATIM (incl. comments) from `hil_test.py`.
+- Produces `hil_select.py` engine API used by Task 2:
+ - `classify(changed_files: list[str], repo_root: str, rosters: list[tuple[str, list[dict]]]) -> dict`
+ returning `{'full': bool, 'boards': {board_name: 'all' | sorted list[str]}, 'reasons': list[str]}`
+ where `rosters` = `[(config_path, config['boards']), ...]`.
+ - `board_roles(board: dict) -> set[str]` — subset of `{'device', 'host'}` from the roster
+ entry's `tests` flags (`device`/`host`/`dual` booleans; an `only` list contributes the
+ roles of its entries' path prefixes; `dual` implies both roles).
+ - `board_family(board_name: str, repo_root: str) -> str | None` — the `<family>` for which
+ `hw/bsp/<family>/boards/<board_name>` exists.
+ - `port_families(port_dir: str, repo_root: str) -> set[str]` — directories of
+ `hw/bsp/*/family.cmake` and `hw/bsp/*/family.mk` whose text contains `port_dir`
+ (e.g. `raspberrypi/rp2040`).
+ - `class_examples(class_dir: str, role: str, repo_root: str) -> set[str]` — tests from
+ `hil_examples` lists whose example `tusb_config.h` enables the class for that role (regex
+ `#define\s+CFG_TUD_<C>\s+\(?\s*0*[1-9]` / `CFG_TUH_<C>`; exceptions per spec:
+ `dfu_rt_device.*`→`CFG_TUD_DFU_RUNTIME`, `dfu_device.*`→`CFG_TUD_DFU`, class dir `net`
+ → `CFG_TUD_ECM_RNDIS|CFG_TUD_NCM`). Test path `device/x` ⇒ config at
+ `examples/device/x/src/tusb_config.h`; same pattern for `host/` and `dual/`.
+
+- [ ] **Step 1: Move the test lists into `hil_examples.py`**
+
+Create `test/hil/hil_examples.py`:
+
+```python
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# HIL example test lists, shared by hil_test.py (runner) and hil_select.py
+# (PR-diff selector). Stdlib-only: hil_select runs on bare CI runners.
+```
+
+then MOVE the `device_tests`, `dual_tests`, `host_test` list definitions (and their preceding
+comment block "The per-board run order is shuffled...") VERBATIM from `hil_test.py` into it.
+In `hil_test.py`, add `from hil_examples import device_tests, dual_tests, host_test` where the
+lists were (a `from`-import of data constants is fine here — they are read-only lists used by
+name throughout `test_board`). Add `"$ROOT_DIR/test/hil/hil_examples.py" \` to the
+`hil_ci.sh` scp list after the `hil_lock.py` line.
+
+- [ ] **Step 2: Verify the move broke nothing**
+
+Run: `cd /home/hathach/code/tinyusb/.claude/worktrees/hil-select && python3 -m py_compile test/hil/hil_examples.py test/hil/hil_test.py && python3 test/hil/hil_test.py --help >/dev/null && echo ok`
+Expected: `ok`
+
+- [ ] **Step 3: Write the failing unit tests (spec acceptance cases)**
+
+Create `test/hil/test_hil_select.py`. ROSTER is a trimmed but real-shaped fixture; tests call
+the engine API directly (no git, no CLI):
+
+```python
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for hil_select.py — pure logic, no hardware, no git. Run directly:
+# python3 test/hil/test_hil_select.py
+import os
+import sys
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import hil_select
+from hil_examples import device_tests, dual_tests, host_test
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+ROSTER = [
+ # device-only, rp2040 family
+ {'name': 'raspberry_pi_pico', 'uid': 'u1',
+ 'tests': {'device': True, 'host': True, 'dual': True}},
+ # device-only, stm32f4 family
+ {'name': 'stm32f407disco', 'uid': 'u2',
+ 'tests': {'device': True, 'host': False, 'dual': False}},
+ # host-only board
+ {'name': 'raspberry_pi_pico2', 'uid': 'u3',
+ 'tests': {'device': False, 'host': True, 'dual': False}},
+ # only-list board (espressif-style)
+ {'name': 'espressif_s3_devkitm', 'uid': 'u4',
+ 'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
+]
+ROSTERS = [('test/hil/tinyusb.json', ROSTER)]
+
+
+def sel(files):
+ return hil_select.classify(files, REPO, ROSTERS)
+
+
+class TestPortRule(unittest.TestCase):
+ def test_dcd_rp2040_selects_pico_family_only(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards'])
+ self.assertNotIn('espressif_s3_devkitm', s['boards'])
+ # device role: no host tests in pico's list
+ self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
+ # host-only boards drop out entirely on a device-role change
+ self.assertNotIn('raspberry_pi_pico2', s['boards'])
+
+ def test_shared_port_file_is_both_roles(self):
+ s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico', s['boards']) # rp2040 is not a dwc2 family
+ self.assertIn('stm32f407disco', s['boards']) # stm32f4 is
+
+
+class TestCoreRoleRule(unittest.TestCase):
+ def test_usbd_selects_all_device_tests_everywhere(self):
+ s = sel(['src/device/usbd.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico2', s['boards']) # host-only board dropped
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertTrue(set(device_tests).issubset(set(pico)))
+ self.assertTrue(set(dual_tests).issubset(set(pico))) # dual survives device role
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+ # only-list board: selection intersects its only-list
+ esp = s['boards']['espressif_s3_devkitm']
+ self.assertEqual(esp, ['device/cdc_msc_freertos'])
+
+ def test_host_change_drops_device(self):
+ s = sel(['src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board dropped
+
+
+class TestClassRule(unittest.TestCase):
+ def test_cdc_device_selects_cdc_examples_only(self):
+ s = sel(['src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertIn('device/cdc_msc', pico)
+ self.assertIn('device/cdc_dual_ports', pico)
+ self.assertNotIn('device/msc_dual_lun', pico) # CFG_TUD_CDC 0 there
+ self.assertNotIn('device/usbtest', pico) # CFG_TUD_CDC 0 there
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+
+ def test_msc_host_selects_host_side(self):
+ s = sel(['src/class/msc/msc_host.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board
+ pico2 = s['boards']['raspberry_pi_pico2']
+ self.assertIn('host/msc_file_explorer', pico2)
+ self.assertTrue(all(not t.startswith('device/') for t in pico2))
+
+
+class TestFallbackRules(unittest.TestCase):
+ def test_unknown_tool_is_full(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertTrue(s['full'])
+
+ def test_docs_only_is_empty_not_full(self):
+ s = sel(['docs/info/contributing.rst', 'README.rst'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_bsp_family_selects_family_boards(self):
+ s = sel(['hw/bsp/rp2040/family.cmake'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
+ self.assertNotIn('stm32f407disco', s['boards'])
+
+ def test_bsp_board_narrows_to_board(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ self.assertFalse(s['full'])
+ self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])
+
+ def test_example_change_selects_that_example(self):
+ s = sel(['examples/device/cdc_msc/src/main.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])
+
+ def test_core_common_is_full(self):
+ for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_harness_is_full(self):
+ for f in ['test/hil/hil_test.py', '.github/workflows/build.yml', 'hw/mcu/nxp/x.c', 'lib/foo/x.c']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_mixed_roles_no_pruning(self):
+ s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertIn('stm32f407disco', s['boards'])
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=1)
+```
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run: `python3 test/hil/test_hil_select.py 2>&1 | tail -2`
+Expected: `ModuleNotFoundError: No module named 'hil_select'` (or import error).
+
+- [ ] **Step 5: Implement the engine**
+
+Create `test/hil/hil_select.py`:
+
+```python
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+"""PR-diff -> HIL selection: which rig boards and which tests a change can affect.
+
+Stdlib-only (runs on bare CI runners; never imports hil_test/hil_flash/hil_lock).
+Fail-open: any file no rule classifies forces the full matrix. See
+docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md.
+"""
+import argparse
+import glob
+import json
+import os
+import re
+import subprocess
+import sys
+
+from hil_examples import device_tests, dual_tests, host_test
+
+ALL_TESTS = {'device': device_tests, 'dual': dual_tests, 'host': host_test}
+
+# class dir -> config macro suffix exceptions (rule 3); dfu is per-file, handled inline
+NET_MACROS = ('ECM_RNDIS', 'NCM')
+
+_NONCODE_RE = re.compile(
+ r'^(docs/|\.claude/|.*\.(md|rst|txt)$|LICENSE)')
+_FULL_RE = re.compile(
+ r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
+ r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|'
+ r'tools/build\.py$|tools/get_deps\.py$|tools/cmake/|hw/mcu/|lib/|'
+ r'hw/bsp/(family_support\.cmake|board_api\.h|board\.c|ansi_escape\.h)$|'
+ r'examples/build_system/|examples/CMakeLists\.txt$)')
+
+
+def test_role(test: str) -> str:
+ return test.split('/', 1)[0] # 'device' | 'dual' | 'host'
+
+
+def board_roles(board: dict) -> set:
+ t = board.get('tests', {})
+ roles = set()
+ if t.get('device'):
+ roles.add('device')
+ if t.get('host'):
+ roles.add('host')
+ if t.get('dual'):
+ roles.update(('device', 'host'))
+ for only in t.get('only', []):
+ r = test_role(only)
+ roles.update(('device', 'host') if r == 'dual' else (r,))
+ return roles
+
+
+def board_tests(board: dict) -> list:
+ """Every test this board would run today (mirrors hil_test.test_board's default)."""
+ t = board.get('tests', {})
+ if 'only' in t:
+ run = list(t['only'])
+ else:
+ run = []
+ if t.get('device'):
+ run += device_tests
+ if t.get('dual'):
+ run += dual_tests
+ if t.get('host'):
+ run += host_test
+ return [x for x in run if x not in t.get('skip', [])]
+
+
+def board_family(board_name: str, repo_root: str):
+ hits = glob.glob(os.path.join(repo_root, 'hw/bsp/*/boards', board_name))
+ return os.path.basename(os.path.dirname(os.path.dirname(hits[0]))) if hits else None
+
+
+def port_families(port_dir: str, repo_root: str) -> set:
+ fams = set()
+ for f in glob.glob(os.path.join(repo_root, 'hw/bsp/*/family.cmake')) + \
+ glob.glob(os.path.join(repo_root, 'hw/bsp/*/family.mk')):
+ try:
+ if port_dir in open(f).read():
+ fams.add(os.path.basename(os.path.dirname(f)))
+ except OSError:
+ pass
+ return fams
+
+
+def _config_enables(cfg_path: str, macros) -> bool:
+ try:
+ text = open(cfg_path).read()
+ except OSError:
+ return False
+ return any(re.search(rf'#define\s+{m}\s+\(?\s*0*[1-9]', text) for m in macros)
+
+
+def class_examples(macros, role: str, repo_root: str) -> set:
+ """Tests (from role's + dual lists) whose example config enables any macro."""
+ pools = {'device': device_tests + dual_tests, 'host': host_test + dual_tests}
+ out = set()
+ for test in pools[role]:
+ cfg = os.path.join(repo_root, 'examples', test, 'src', 'tusb_config.h')
+ if _config_enables(cfg, macros):
+ out.add(test)
+ return out
+
+
+class _Sel:
+ """Accumulates contributions. board->set(tests) plus 'all-board' markers."""
+ def __init__(self):
+ self.full = False
+ self.by_board = {} # name -> set of tests, or 'all'
+ self.roles = set() # roles touched by any contribution
+ self.reasons = []
+
+ def add(self, boards, tests, reason):
+ """tests: 'all' or iterable of test paths."""
+ self.reasons.append(reason)
+ for b in boards:
+ cur = self.by_board.get(b)
+ if tests == 'all' or cur == 'all':
+ self.by_board[b] = 'all'
+ else:
+ self.by_board[b] = (cur or set()) | set(tests)
+
+ def force_full(self, reason):
+ self.full = True
+ self.reasons.append(reason)
+
+
+def _classify_one(path, repo_root, roster_boards, s: _Sel):
+ base = os.path.basename(path)
+ if _NONCODE_RE.match(path):
+ s.reasons.append(f'{path}: non-code, no contribution')
+ return
+ if _FULL_RE.match(path):
+ s.force_full(f'{path}: core/infra -> full matrix')
+ return
+
+ m = re.match(r'src/portable/((?:[^/]+/)?[^/]+)/', path)
+ if m:
+ port = m.group(1)
+ if re.match(r'(dcd_|.*_device)', base):
+ roles = {'device'}
+ elif re.match(r'(hcd_|.*_host)', base):
+ roles = {'host'}
+ else:
+ roles = {'device', 'host'}
+ fams = port_families(port, repo_root)
+ boards = [b['name'] for b in roster_boards
+ if board_family(b['name'], repo_root) in fams and (board_roles(b) & roles)]
+ tests = [t for r in roles for t in ALL_TESTS[r]] + dual_tests
+ s.roles.update(roles)
+ s.add(boards, tests, f'{path}: port {port} -> families {sorted(fams)} -> boards {boards} ({"/".join(sorted(roles))})')
+ return
+
+ m = re.match(r'src/class/([^/]+)/', path)
+ if m:
+ cls = m.group(1)
+ if re.search(r'_device\.[ch]$', base):
+ roles = {'device'}
+ elif re.search(r'_host\.[ch]$', base):
+ roles = {'host'}
+ else:
+ roles = {'device', 'host'}
+ # macro names per role
+ def macros(prefix):
+ if cls == 'net':
+ return [f'CFG_{prefix}_{m2}' for m2 in NET_MACROS]
+ if cls == 'dfu':
+ if base.startswith('dfu_rt'):
+ return [f'CFG_{prefix}_DFU_RUNTIME']
+ if base.startswith('dfu_device') or base.startswith('dfu_host'):
+ return [f'CFG_{prefix}_DFU']
+ return [f'CFG_{prefix}_DFU', f'CFG_{prefix}_DFU_RUNTIME']
+ return [f'CFG_{prefix}_{cls.upper()}']
+ tests = set()
+ if 'device' in roles:
+ tests |= class_examples(macros('TUD'), 'device', repo_root)
+ if 'host' in roles:
+ tests |= class_examples(macros('TUH'), 'host', repo_root)
+ boards = [b['name'] for b in roster_boards if board_roles(b) & roles]
+ s.roles.update(roles)
+ s.add(boards, tests, f'{path}: class {cls} -> {sorted(tests)} ({"/".join(sorted(roles))})')
+ return
+
+ m = re.match(r'src/(device|host)/', path)
+ if m:
+ role = m.group(1)
+ boards = [b['name'] for b in roster_boards if role in board_roles(b)]
+ s.roles.add(role)
+ s.add(boards, ALL_TESTS[role] + dual_tests, f'{path}: core {role} stack -> all {role} tests')
+ return
+
+ m = re.match(r'hw/bsp/([^/]+)/(?:boards/([^/]+)/)?', path)
+ if m:
+ fam, brd = m.group(1), m.group(2)
+ if brd:
+ boards = [b['name'] for b in roster_boards if b['name'] == brd]
+ why = f'{path}: bsp board {brd}'
+ else:
+ boards = [b['name'] for b in roster_boards
+ if board_family(b['name'], repo_root) == fam]
+ why = f'{path}: bsp family {fam}'
+ s.roles.update(('device', 'host'))
+ s.add(boards, 'all', f'{why} -> boards {boards}')
+ return
+
+ m = re.match(r'examples/(device|host|dual)/([^/]+)/', path)
+ if m:
+ test = f'{m.group(1)}/{m.group(2)}'
+ known = any(test in pool for pool in ALL_TESTS.values())
+ if known:
+ boards = [b['name'] for b in roster_boards]
+ role = test_role(test)
+ s.roles.update(('device', 'host') if role == 'dual' else (role,))
+ s.add(boards, [test], f'{path}: example -> {test} on all boards')
+ else:
+ s.reasons.append(f'{path}: example not in HIL lists, no contribution')
+ return
+
+ s.force_full(f'{path}: unclassified -> full matrix')
+
+
+def classify(changed_files, repo_root, rosters):
+ all_boards = []
+ seen = set()
+ for _, boards in rosters:
+ for b in boards:
+ if b['name'] not in seen:
+ seen.add(b['name'])
+ all_boards.append(b)
+
+ s = _Sel()
+ for path in changed_files:
+ _classify_one(path, repo_root, all_boards, s)
+ if s.full:
+ break
+
+ if s.full:
+ return {'full': True, 'boards': {b['name']: 'all' for b in all_boards},
+ 'reasons': s.reasons}
+
+ # role pruning: single-role selections drop the other role's tests and boards
+ by_name = {b['name']: b for b in all_boards}
+ out = {}
+ for name, tests in s.by_board.items():
+ allowed = board_tests(by_name[name])
+ if tests == 'all':
+ kept = list(allowed)
+ else:
+ kept = [t for t in allowed if t in tests]
+ if s.roles and s.roles != {'device', 'host'}:
+ role = next(iter(s.roles))
+ kept = [t for t in kept if test_role(t) in (role, 'dual')]
+ if kept:
+ out[name] = 'all' if set(kept) == set(allowed) else sorted(kept)
+ return {'full': False, 'boards': out, 'reasons': s.reasons}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `python3 test/hil/test_hil_select.py`
+Expected: all tests PASS (OK line). Iterate on the engine (not the tests) until green; if a
+test premise contradicts the repo (e.g. a family name), verify against the tree and fix the
+test only with evidence noted in your report.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add test/hil/hil_examples.py test/hil/hil_select.py test/hil/test_hil_select.py test/hil/hil_test.py test/hil/hil_ci.sh
+git commit -m "hil: add PR-diff selection engine (hil_select) with shared example lists"
+```
+
+---
+
+### Task 2: CLI + args emission
+
+**Files:**
+- Modify: `test/hil/hil_select.py` (add `selection_args`, `main`)
+- Modify: `test/hil/test_hil_select.py` (add CLI/args tests)
+
+**Interfaces:**
+- Consumes: Task 1's `classify` and roster shapes.
+- Produces:
+ - `selection_args(sel: dict, rosters) -> dict` mapping each config path's basename to the
+ `hil_test.py` argument string for that rig: for each selected board ON that roster,
+ `-b <name>`, plus `-bt <name>:<t1>,<t2>` when the board's entry is a list (not 'all').
+ Empty string when no selected board is on that roster. When `sel['full']`, every roster
+ board gets bare `-b`? NO — full means "today's behavior": `selection_args` returns `''`
+ for every config (no filtering args at all).
+ - CLI: `python3 test/hil/hil_select.py [--base REF | --diff-file PATH] CONFIG...` printing
+ the JSON `{'full', 'boards', 'args', 'reasons'}` to stdout, reasons also to stderr
+ (one line each, prefixed `hil_select: `). Non-zero exit only on operational errors
+ (bad ref, unreadable config) — never on an empty selection.
+
+- [ ] **Step 1: Add failing CLI/args tests to `test_hil_select.py`**
+
+```python
+class TestArgsEmission(unittest.TestCase):
+ def test_args_for_scoped_selection(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ args = hil_select.selection_args(s, ROSTERS)
+ a = args['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('stm32f407disco', a)
+ self.assertIn('-bt raspberry_pi_pico:', a) # device-only subset of a device+host board
+
+ def test_args_full_is_empty(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
+
+ def test_args_all_board_gets_bare_b(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ a = hil_select.selection_args(s, ROSTERS)['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('-bt', a)
+
+ def test_cli_diff_file(self):
+ import subprocess, tempfile, json as j
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('src/class/cdc/cdc_device.c\n')
+ path = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/hil_select.py'),
+ '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ out = j.loads(r.stdout)
+ self.assertFalse(out['full'])
+ self.assertIn('tinyusb.json', out['args'])
+ self.assertTrue(any('cdc_device' in line for line in out['reasons']))
+ os.unlink(path)
+```
+
+- [ ] **Step 2: Run to verify the new tests fail**
+
+Run: `python3 test/hil/test_hil_select.py 2>&1 | tail -3`
+Expected: failures/errors mentioning `selection_args`.
+
+- [ ] **Step 3: Implement `selection_args` and `main`**
+
+Append to `hil_select.py`:
+
+```python
+def selection_args(sel, rosters):
+ args = {}
+ for cfg_path, boards in rosters:
+ key = os.path.basename(cfg_path)
+ if sel['full']:
+ args[key] = ''
+ continue
+ parts = []
+ for b in boards:
+ chosen = sel['boards'].get(b['name'])
+ if chosen is None:
+ continue
+ parts.append(f'-b {b["name"]}')
+ if chosen != 'all':
+ parts.append(f'-bt {b["name"]}:{",".join(chosen)}')
+ args[key] = ' '.join(parts)
+ return args
+
+
+def changed_files_from_git(base, repo_root):
+ mb = subprocess.run(['git', 'merge-base', 'HEAD', base], cwd=repo_root,
+ capture_output=True, text=True, check=True).stdout.strip()
+ diff = subprocess.run(['git', 'diff', '--name-only', f'{mb}..HEAD'], cwd=repo_root,
+ capture_output=True, text=True, check=True).stdout
+ return [l for l in diff.splitlines() if l.strip()]
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ g = ap.add_mutually_exclusive_group(required=True)
+ g.add_argument('--base', help='git ref to diff against (merge-base..HEAD)')
+ g.add_argument('--diff-file', help='newline-separated changed-file list')
+ ap.add_argument('configs', nargs='+', help='rig roster JSON file(s)')
+ a = ap.parse_args()
+
+ repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ rosters = []
+ for c in a.configs:
+ with open(c) as f:
+ rosters.append((c, json.load(f)['boards']))
+
+ files = (open(a.diff_file).read().splitlines() if a.diff_file
+ else changed_files_from_git(a.base, repo_root))
+ files = [f for f in files if f.strip()]
+
+ s = classify(files, repo_root, rosters)
+ s['args'] = selection_args(s, rosters)
+ for r in s['reasons']:
+ print(f'hil_select: {r}', file=sys.stderr)
+ print(json.dumps(s))
+
+
+if __name__ == '__main__':
+ main()
+```
+
+(The `parts.append f'...'` line above is pseudo-highlighted; write valid Python:
+`parts.append(f'-bt {b["name"]}:{",".join(chosen)}')`.)
+
+- [ ] **Step 4: Run the full suite**
+
+Run: `python3 test/hil/test_hil_select.py && chmod +x test/hil/hil_select.py`
+Expected: OK.
+
+- [ ] **Step 5: Smoke against the real repo state**
+
+Run: `python3 test/hil/hil_select.py --base HEAD test/hil/tinyusb.json test/hil/hfp.json`
+Expected: empty diff ⇒ `{"full": false, "boards": {}, "args": {"tinyusb.json": "", "hfp.json": ""}, ...}` exit 0.
+Then: `printf 'src/portable/wch/dcd_ch32_usbfs.c\n' > /tmp/d.txt && python3 test/hil/hil_select.py --diff-file /tmp/d.txt test/hil/tinyusb.json | python3 -m json.tool | head -20`
+Expected: only WCH-family boards (nanoch32v203, ch32v103r_r1_1v0, ch32v307v_r1_1v0 — whichever reference that port) with device tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add test/hil/hil_select.py test/hil/test_hil_select.py
+git commit -m "hil: hil_select CLI with per-rig hil_test argument emission"
+```
+
+---
+
+### Task 3: hil_ci_set_matrix --select + build.yml wiring
+
+**Files:**
+- Modify: `test/hil/hil_ci_set_matrix.py`
+- Modify: `.github/workflows/build.yml` (set-matrix job; hil-build consumers unchanged; hil-tinyusb + hil-tinyusb-esp steps)
+
+**Interfaces:**
+- Consumes: Task 2's CLI JSON (`full`, `boards`, `args`).
+- Produces:
+ - `hil_ci_set_matrix.py [--select JSON_STRING] CONFIG...`: with `--select` and
+ `full == false`, boards not in `select['boards']` are skipped when building the toolchain
+ buckets; otherwise identical behavior. Buckets stay present (possibly `[]`) so
+ `fromJSON(...)[toolchain]` keeps resolving.
+ - set-matrix outputs: `hil_select_json` (compact selection), `hil_args_tinyusb`,
+ `hil_args_hfp`, `hil_run_tinyusb`, `hil_run_hfp` (string 'true'/'false').
+
+- [ ] **Step 1: Add `--select` to `hil_ci_set_matrix.py`**
+
+In `main()` add:
+
+```python
+ parser.add_argument('--select', help='hil_select.py JSON; scopes boards when full=false')
+```
+
+and after parsing:
+
+```python
+ selected = None
+ sel = json.loads(args.select) if args.select else None
+ if sel and not sel.get('full'):
+ selected = set(sel.get('boards', {}))
+```
+
+then inside the per-board loop, first line:
+
+```python
+ if selected is not None and board['name'] not in selected:
+ continue
+```
+
+- [ ] **Step 2: Verify byte-identical without --select and scoped with it**
+
+Run: `python3 test/hil/hil_ci_set_matrix.py test/hil/tinyusb.json test/hil/hfp.json > /tmp/m1.json && git stash -q && python3 test/hil/hil_ci_set_matrix.py test/hil/tinyusb.json test/hil/hfp.json > /tmp/m0.json && git stash pop -q && diff /tmp/m0.json /tmp/m1.json && echo identical`
+Expected: `identical`.
+Then: `python3 test/hil/hil_ci_set_matrix.py --select '{"full": false, "boards": {"raspberry_pi_pico": "all"}}' test/hil/tinyusb.json test/hil/hfp.json`
+Expected: JSON whose `arm-gcc` list contains only the raspberry_pi_pico entry, `riscv-gcc`/`esp-idf` = [].
+
+- [ ] **Step 3: Wire set-matrix in `.github/workflows/build.yml`**
+
+In the `set-matrix` job: give the checkout full history and add the selection step between
+checkout and matrix generation; make the HIL matrix use it:
+
+```yaml
+ - name: Checkout TinyUSB
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: HIL selection (PR only)
+ id: hil-select
+ if: github.event_name == 'pull_request'
+ run: |
+ python3 test/hil/test_hil_select.py
+ SELECT_JSON=$(python3 test/hil/hil_select.py --base "origin/${{ github.base_ref }}" test/hil/tinyusb.json test/hil/hfp.json)
+ echo "select=$SELECT_JSON" >> $GITHUB_OUTPUT
+ python3 - "$SELECT_JSON" >> $GITHUB_OUTPUT <<'EOF'
+ import json, sys
+ s = json.loads(sys.argv[1])
+ args = s.get('args', {})
+ for cfg, key in (('tinyusb.json', 'tinyusb'), ('hfp.json', 'hfp')):
+ a = args.get(cfg, '')
+ run = 'true' if (s['full'] or a) else 'false'
+ print(f'args_{key}={a}')
+ print(f'run_{key}={run}')
+ EOF
+```
+
+and in the existing "Generate matrix json" step, change the HIL line to:
+
+```yaml
+ # HIL matrix (merged from tinyusb + hifiphile configs), scoped on PRs
+ SELECT='${{ steps.hil-select.outputs.select }}'
+ HIL_MATRIX_JSON=$(python test/hil/hil_ci_set_matrix.py ${SELECT:+--select "$SELECT"} test/hil/tinyusb.json test/hil/hfp.json)
+```
+
+Add to the job's `outputs:` block:
+
+```yaml
+ hil_args_tinyusb: ${{ steps.hil-select.outputs.args_tinyusb }}
+ hil_args_hfp: ${{ steps.hil-select.outputs.args_hfp }}
+ hil_run_tinyusb: ${{ steps.hil-select.outputs.run_tinyusb }}
+ hil_run_hfp: ${{ steps.hil-select.outputs.run_hfp }}
+```
+
+(On non-PR events the step is skipped: outputs are empty strings — the consumers below treat
+empty `run_*` as 'true' and empty args as no filtering, i.e. today's behavior.)
+
+- [ ] **Step 4: Wire the rig jobs**
+
+In the `hil-tinyusb` job (the matrixed one covering both rigs), find the step that runs
+`hil_test.py --retry 1 ${{ matrix.test_args }} ${{ env.HIL_JSON }} $RERUN_ARGS` (~line 360)
+and change the step's `run:` to select per-rig args and honor the skip flag:
+
+```yaml
+ run: |
+ case "$HIL_JSON" in
+ *tinyusb.json) SEL_ARGS='${{ needs.set-matrix.outputs.hil_args_tinyusb }}'; SEL_RUN='${{ needs.set-matrix.outputs.hil_run_tinyusb }}' ;;
+ *hfp.json) SEL_ARGS='${{ needs.set-matrix.outputs.hil_args_hfp }}'; SEL_RUN='${{ needs.set-matrix.outputs.hil_run_hfp }}' ;;
+ esac
+ if [ "$SEL_RUN" = "false" ]; then echo "HIL skipped by PR selection (no affected boards on this rig)"; exit 0; fi
+ python3 test/hil/hil_test.py --retry 1 ${{ matrix.test_args }} $SEL_ARGS ${{ env.HIL_JSON }} $RERUN_ARGS
+```
+
+Apply the same pattern to the second `hil_test.py` invocation at ~line 423 (`hil-tinyusb-esp`,
+which is tinyusb-rig only: use the `hil_args_tinyusb`/`hil_run_tinyusb` outputs directly, no
+case needed) and to the hfp job's direct `python3 test/hil/hil_test.py hfp.json` call at
+~line 487 (use `hil_args_hfp`/`hil_run_hfp`). Preserve each step's existing surrounding lines
+(report-dir env, RERUN_ARGS logic) — only inject the SEL_ARGS/SEL_RUN mechanics.
+
+- [ ] **Step 5: Validate the YAML and the exact shell locally**
+
+Run: `pre-commit run check-yaml --files .github/workflows/build.yml && python3 -c "import yaml; yaml.safe_load(open('.github/workflows/build.yml')); print('yaml ok')"`
+Expected: `yaml ok` (pyyaml is available; if not, `pip install --user pyyaml` first).
+Also simulate the selection step's python inline script:
+`SELECT_JSON=$(python3 test/hil/hil_select.py --diff-file /tmp/d.txt test/hil/tinyusb.json test/hil/hfp.json) && python3 -c "import json,sys; s=json.loads(sys.argv[1]); print(s['args'])" "$SELECT_JSON"`
+Expected: the args dict prints.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add test/hil/hil_ci_set_matrix.py .github/workflows/build.yml
+git commit -m "ci: scope HIL build+test matrix by PR diff via hil_select"
+```
+
+---
+
+### Task 4: pre-pr + hil skill docs, final validation
+
+**Files:**
+- Modify: `.claude/skills/pre-pr/SKILL.md` (mapping section delegates to the selector)
+- Modify: `.claude/skills/hil/SKILL.md` (document the selector for manual runs)
+
+**Interfaces:**
+- Consumes: Task 2's CLI.
+
+- [ ] **Step 1: Rewrite pre-pr's "2. Map changes to boards" section**
+
+Replace the section's grep heuristics (keep its numbered-section structure and the roster/cap
+policy) with:
+
+```markdown
+## 2. Map changes to boards
+
+- `python3 test/hil/hil_select.py --base $BASE test/hil/tinyusb.json` → JSON with the affected
+ rig boards (`boards`) and per-file `reasons`. `full: true` means a broad/infra change.
+- Build-board sampling: from the selection's boards (or, when `full`, the representative set
+ `stm32f407disco` + `raspberry_pi_pico`), pick ONE board per family, preferring rig-roster
+ boards; cap at 4 and tell the user which families the cap dropped. The boards list must
+ NEVER end up empty — final fallback is `[stm32f407disco]`.
+- A `full: true` selection or an empty one (docs-only) keeps today's behavior: minimal
+ software-only gate for docs-only, representative set otherwise.
+```
+
+- [ ] **Step 2: Add a short "PR-scoped selection" note to the hil skill**
+
+Append to `.claude/skills/hil/SKILL.md` after the pool-check section:
+
+```markdown
+## PR-scoped selection
+
+`test/hil/hil_select.py` maps a diff to affected boards/tests (used by CI on PRs; fail-open
+to the full matrix). Manual use:
+
+```bash
+ARGS=$(python3 test/hil/hil_select.py --base master test/hil/tinyusb.json | python3 -c "import json,sys; print(json.load(sys.stdin)['args']['tinyusb.json'])")
+python3 test/hil/hil_test.py -B examples $ARGS test/hil/tinyusb.json
+```
+
+Unit suite: `python3 test/hil/test_hil_select.py` (no hardware).
+```
+
+- [ ] **Step 3: Full validation sweep**
+
+Run: `python3 test/hil/test_hil_select.py && python3 -m py_compile test/hil/hil_select.py test/hil/hil_examples.py test/hil/hil_ci_set_matrix.py test/hil/hil_test.py && python3 test/hil/hil_test.py --help >/dev/null && pre-commit run --files $(git diff --name-only claude/hil-pool-check..HEAD) && echo ALL-GREEN`
+Expected: `ALL-GREEN`.
+
+- [ ] **Step 4: Real-diff spot checks (acceptance)**
+
+Run each and eyeball the JSON (record outputs in your report):
+```bash
+for f in 'src/portable/raspberrypi/rp2040/dcd_rp2040.c' 'src/device/usbd.c' 'src/class/cdc/cdc_device.c' 'src/host/usbh.c'; do
+ printf '%s\n' "$f" > /tmp/d.txt
+ echo "=== $f"; python3 test/hil/hil_select.py --diff-file /tmp/d.txt test/hil/tinyusb.json test/hil/hfp.json 2>/dev/null | python3 -m json.tool | sed -n '1,25p'
+done
+```
+Expected: matches the spec's acceptance examples (pico-family only / all-device / CDC examples
+only / host side only, with hfp.json args populated only where hfp boards qualify).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add .claude/skills/pre-pr/SKILL.md .claude/skills/hil/SKILL.md
+git commit -m "docs: pre-pr and hil skill use hil_select for PR-scoped boards"
+```
diff --git a/docs/superpowers/plans/2026-08-15-ci-hs-reset-edges.md b/docs/superpowers/plans/2026-08-15-ci-hs-reset-edges.md
new file mode 100644
index 000000000..ec0834e55
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-15-ci-hs-reset-edges.md
@@ -0,0 +1,782 @@
+# Bus-Reset Edge Events + Review Fix Wave Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Give the device stack a "bus reset started" event so ci_hs can tell usbd to stand down at the URI interrupt instead of up to 50 ms later, and clear the ten findings agreed from the max review.
+
+**Architecture:** `DCD_EVENT_BUS_RESET` splits into `DCD_EVENT_BUS_RESET_START` / `_END` with a compatibility alias, so every other port stays byte-identical. `dcd_ci_hs.c`'s `bus_reset()` splits along the register/software line — registers at URI (`_START`), software structures at the port-change ending the reset (`_END`) — which eliminates the window where usbd believes it is configured over zeroed queue heads. A single bounded-flush helper absorbs the five flush sites. Seven mechanical fixes follow.
+
+**Tech Stack:** C99, TinyUSB device stack (`src/device/`), ChipIdea HS DCD (`src/portable/chipidea/ci_hs/`), NXP IP3511 DCD (`src/portable/nxp/lpc_ip3511/`), CMake+Ninja and Make builds, J-Link flashing, `test/hil/` HIL harness.
+
+## Global Constraints
+
+- Branch `fix-ci-hs` in worktree `/home/hathach/.herdr/worktrees/tinyusb/fix-ci-hs`. Do NOT push; the user pushes.
+- C99, 2-space indent, no tabs. Match each file's surrounding style (`dcd_lpc_ip3511.c` mixes styles — follow the immediate neighbourhood).
+- Commit messages: imperative mood, no `Co-Authored-By:` or `Claude-Session:` trailers (repo rule: hathach is sole author).
+- The repo pre-commit hook (trailing-whitespace, end-of-file-fixer, codespell, unique-PIDs, ceedling unit tests) must pass. If it rewrites a file, re-stage and retry the commit once.
+- Comments: short, only the non-obvious "why". Cite manuals as `UM10503 25.10.3` / `Errata LPC546xx USB.13` style — never `ES_` prefixes.
+- Never edit anything under `hw/mcu/` or `lib/` (vendor code).
+- Build commands used throughout (each ~30-60 s):
+ `cmake --build examples/cmake-build-<board>` for `mimxrt1064_evk`, `lpcxpresso18s37`, `lpcxpresso11u37`, `lpcxpresso55s28`.
+- Design source of truth: `docs/superpowers/specs/2026-08-15-ci-hs-reset-edges-design.md`.
+
+## File Structure
+
+| File | Responsibility in this plan |
+|---|---|
+| `src/device/dcd.h` | Event enum + compatibility alias + contract comment |
+| `src/device/usbd.c` | Handle both reset edges; log strings; stop breakpointing on DCD refusal |
+| `src/portable/chipidea/ci_hs/dcd_ci_hs.c` | Flush helper; `bus_reset()` split; setup-flush wait; `dcd_set_address`; RESUME guard |
+| `src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c` | Torn-setup delivery; USB.13 TODO token |
+| `hw/bsp/lpc55/boards/lpcxpresso55s28/board.cmake` | Delete dead RHPORT block |
+| `hw/bsp/lpc11/boards/lpcxpresso11u37/lpc11u37.ld` | Correct stale comment; relabel ASSERT |
+
+Tasks 1-3 are ordered (each builds on the previous); Tasks 4-6 are independent of each other.
+
+---
+
+### Task 1: Split the bus-reset event into START/END edges
+
+**Files:**
+- Modify: `src/device/dcd.h` (enum at lines 23-34; contract comment above it)
+- Modify: `src/device/usbd.c` (`_usbd_event_str[]` at line 457; the `DCD_EVENT_BUS_RESET` case at line 700)
+
+**Interfaces:**
+- Produces: `DCD_EVENT_BUS_RESET_START` and `DCD_EVENT_BUS_RESET_END` enum members; `#define DCD_EVENT_BUS_RESET DCD_EVENT_BUS_RESET_END`. Task 2 emits `_START` via the existing `dcd_event_bus_signal(uint8_t rhport, dcd_eventid_t eid, bool in_isr)` and `_END` via the existing `dcd_event_bus_reset(uint8_t rhport, tusb_speed_t speed, bool in_isr)`.
+
+- [ ] **Step 1: Replace the enum member in `src/device/dcd.h`**
+
+Replace:
+
+```c
+typedef enum {
+ DCD_EVENT_INVALID = 0, // 0
+ DCD_EVENT_BUS_RESET, // 1
+ DCD_EVENT_UNPLUGGED, // 2
+ DCD_EVENT_SOF, // 3
+ DCD_EVENT_SUSPEND, // 4 TODO LPM Sleep L1 support
+ DCD_EVENT_RESUME, // 5
+ DCD_EVENT_SETUP_RECEIVED, // 6
+ DCD_EVENT_XFER_COMPLETE, // 7
+ USBD_EVENT_FUNC_CALL, // 8 Not an DCD event, just a convenient way to defer ISR function
+ DCD_EVENT_COUNT
+} dcd_eventid_t;
+```
+
+with:
+
+```c
+// Bus reset is reported as two edges. BUS_RESET_START is optional: a controller that
+// cannot tell the edges apart emits only BUS_RESET_END, which stays self-sufficient (it
+// performs the full teardown with or without a preceding START). Emit START when reset
+// signaling is detected - the link is unusable and the speed is not negotiated yet - so
+// the stack stops using endpoints immediately instead of at the end of the reset.
+typedef enum {
+ DCD_EVENT_INVALID = 0, // 0
+ DCD_EVENT_BUS_RESET_START, // 1
+ DCD_EVENT_BUS_RESET_END, // 2 with negotiated speed
+ DCD_EVENT_UNPLUGGED, // 3
+ DCD_EVENT_SOF, // 4
+ DCD_EVENT_SUSPEND, // 5 TODO LPM Sleep L1 support
+ DCD_EVENT_RESUME, // 6
+ DCD_EVENT_SETUP_RECEIVED, // 7
+ DCD_EVENT_XFER_COMPLETE, // 8
+ USBD_EVENT_FUNC_CALL, // 9 Not an DCD event, just a convenient way to defer ISR function
+ DCD_EVENT_COUNT
+} dcd_eventid_t;
+
+#define DCD_EVENT_BUS_RESET DCD_EVENT_BUS_RESET_END // backward compatibility
+```
+
+- [ ] **Step 2: Update the log-string table in `src/device/usbd.c`**
+
+At line 457 the table is indexed by event id and MUST stay in enum order. Replace the
+`"Bus Reset",` entry (line 459) with two entries:
+
+```c
+ "Bus Reset Start",
+ "Bus Reset End",
+```
+
+- [ ] **Step 3: Handle both edges in the usbd task loop**
+
+Replace the case at `src/device/usbd.c:700`:
+
+```c
+ case DCD_EVENT_BUS_RESET:
+ TU_LOG_USBD(": %s Speed\r\n", tu_str_speed[event.bus_reset.speed]);
+ usbd_reset(event.rhport);
+ _usbd_dev.speed = event.bus_reset.speed;
+ break;
+```
+
+with:
+
+```c
+ case DCD_EVENT_BUS_RESET_START:
+ TU_LOG_USBD("\r\n");
+ usbd_reset(event.rhport);
+ break;
+
+ case DCD_EVENT_BUS_RESET_END:
+ TU_LOG_USBD(": %s Speed\r\n", tu_str_speed[event.bus_reset.speed]);
+ // TODO a DCD that reports both edges pays for two teardowns: track a per-rhport
+ // "start seen" flag and skip this reset, keeping it for the single-event DCDs.
+ usbd_reset(event.rhport);
+ _usbd_dev.speed = event.bus_reset.speed;
+ break;
+```
+
+- [ ] **Step 4: Verify legacy ports still build (the alias must carry them)**
+
+Run:
+
+```bash
+cd examples && cmake -B cmake-build-stm32f407disco -DBOARD=stm32f407disco -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-stm32f407disco
+```
+
+Expected: builds clean. This board's DCD (dwc2) still calls `dcd_event_bus_reset()`, which
+now resolves to `_END` through the unchanged helper — proving the alias works.
+
+- [ ] **Step 5: Verify the unit tests still build and pass**
+
+Run: `cd test/unit-test && ceedling test:all`
+Expected: all tests pass (they reference `DCD_EVENT_BUS_RESET` via the alias).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/device/dcd.h src/device/usbd.c
+git commit -m "usbd: split bus reset into start/end edge events
+
+A DCD that can see reset signaling begin has no way to say so: the only
+event carries the negotiated speed, which does not exist until the reset
+ends. On ChipIdea that leaves the stack believing it is configured for the
+whole reset window (3 ms minimum, tens of ms in practice) while the
+controller has already torn its endpoints down.
+
+Add DCD_EVENT_BUS_RESET_START for the leading edge and rename the existing
+event to DCD_EVENT_BUS_RESET_END, keeping DCD_EVENT_BUS_RESET as an alias
+so every other port and the unit tests are untouched. START is optional and
+END stays self-sufficient, so single-event drivers keep working unchanged."
+```
+
+---
+
+### Task 2: Split ci_hs `bus_reset()` across the two edges, behind one flush helper
+
+**Files:**
+- Modify: `src/portable/chipidea/ci_hs/dcd_ci_hs.c` (`bus_reset()`; `dcd_deinit()`; `dcd_edpt_iso_activate()`; the `INTR_RESET` and `INTR_PORT_CHANGE` branches of `dcd_int_handler()`)
+
+**Interfaces:**
+- Consumes: `DCD_EVENT_BUS_RESET_START` (Task 1), `dcd_event_bus_signal()`, `dcd_event_bus_reset()`.
+- Produces: `static bool flush_endpoints(ci_hs_regs_t *dcd_reg, uint32_t mask)` — writes `ENDPTFLUSH = mask`, spins bounded by `CI_HS_BUSY_SPIN`, returns `true` if the bits cleared. Used by Task 3.
+
+- [ ] **Step 1: Add the flush helper next to `bus_reset()`**
+
+Insert above `bus_reset()`:
+
+```c
+// Flush endpoint buffers and wait for the controller to acknowledge. Callers proceed
+// regardless of the result; the bound only prevents an ISR-context hang on dead hardware.
+static bool flush_endpoints(ci_hs_regs_t *dcd_reg, uint32_t mask) {
+ dcd_reg->ENDPTFLUSH = mask;
+ uint32_t guard = CI_HS_BUSY_SPIN;
+ while (dcd_reg->ENDPTFLUSH & mask) {
+ if (!guard--) {
+ return false;
+ }
+ }
+ return true;
+}
+```
+
+- [ ] **Step 2: Split `bus_reset()` into begin/complete**
+
+Replace the whole `bus_reset()` function with these two. `bus_reset_begin()` keeps only
+register work; `bus_reset_complete()` owns everything that touches `_dcd_data`:
+
+```c
+/// Register-side reset handling, must run inside the reset window (UM10503 25.10.3)
+static void bus_reset_begin(uint8_t rhport) {
+ ci_hs_regs_t *dcd_reg = CI_HS_REG(rhport);
+
+ // The reset value for all endpoint types is the control endpoint. If one endpoint
+ // direction is enabled and the paired endpoint of opposite direction is disabled, then the
+ // endpoint type of the unused direction must be changed from the control type to any other
+ // type (e.g. bulk). Leaving an un-configured endpoint control will cause undefined behavior
+ // for the data PID tracking on the active endpoint.
+ const uint8_t ep_count = ci_ep_count(dcd_reg);
+ for (uint8_t i = 1; i < ep_count; i++) {
+ dcd_reg->ENDPTCTRL[i] = ENDPTCTRL_RESET_MASK;
+ }
+
+ //------------- Clear All Registers -------------//
+ dcd_reg->ENDPTNAK = dcd_reg->ENDPTNAK;
+ dcd_reg->ENDPTNAKEN = 0;
+ dcd_reg->ENDPTSETUPSTAT = dcd_reg->ENDPTSETUPSTAT;
+ dcd_reg->ENDPTCOMPLETE = dcd_reg->ENDPTCOMPLETE;
+
+ uint32_t guard = CI_HS_BUSY_SPIN;
+ while (dcd_reg->ENDPTPRIME && guard--) {}
+ flush_endpoints(dcd_reg, 0xFFFFFFFF);
+}
+
+/// Software-side reset handling, deferred to the port change ending the reset so the queue
+/// heads stay coherent until the stack is told - and so a prime issued by a task that had
+/// not yet seen BUS_RESET_START is flushed here rather than surviving re-enumeration.
+static void bus_reset_complete(uint8_t rhport) {
+ ci_hs_regs_t *dcd_reg = CI_HS_REG(rhport);
+ flush_endpoints(dcd_reg, 0xFFFFFFFF);
+
+ //------------- Queue Head & Queue TD -------------//
+ tu_memclr(&_dcd_data, sizeof(dcd_data_t));
+
+ //------------- Set up Control Endpoints (0 OUT, 1 IN) -------------//
+ _dcd_data.qhd[0][0].zero_length_termination = _dcd_data.qhd[0][1].zero_length_termination = 1;
+ _dcd_data.qhd[0][0].max_packet_size = _dcd_data.qhd[0][1].max_packet_size = CFG_TUD_ENDPOINT0_SIZE;
+ _dcd_data.qhd[0][0].qtd_overlay.next = _dcd_data.qhd[0][1].qtd_overlay.next = QTD_NEXT_INVALID;
+
+ _dcd_data.qhd[0][0].int_on_setup = 1; // OUT only
+
+ dcd_dcache_clean_invalidate(&_dcd_data, sizeof(dcd_data_t));
+}
+```
+
+- [ ] **Step 3: Route the two ISR branches to the new functions**
+
+In `dcd_int_handler()`, the `INTR_RESET` branch becomes:
+
+```c
+ if (int_status & INTR_RESET) {
+ bus_reset_begin(rhport);
+ _port_change_reason[rhport] = PORT_CHANGE_REASON_RESET;
+ dcd_event_bus_signal(rhport, DCD_EVENT_BUS_RESET_START, true);
+ }
+```
+
+and inside the `INTR_PORT_CHANGE` branch, the reset arm (the `else` of the resume test)
+becomes:
+
+```c
+ } else {
+ bus_reset_complete(rhport);
+ // PSPD: 0 full, 1 low, 2 high, 3 undefined (treated as full)
+ const uint32_t pspd = (dcd_reg->PORTSC1 & PORTSC1_PORT_SPEED) >> PORTSC1_PORT_SPEED_POS;
+ const tusb_speed_t speed = (pspd == 1) ? TUSB_SPEED_LOW : (pspd == 2) ? TUSB_SPEED_HIGH : TUSB_SPEED_FULL;
+ dcd_event_bus_reset(rhport, speed, true);
+ }
+```
+
+Delete the now-unused EP0 `ENDPTFLUSH` line that previously sat at the top of that arm —
+`bus_reset_complete()` flushes all endpoints.
+
+- [ ] **Step 4: Route the remaining flush sites through the helper**
+
+In `dcd_deinit()`, replace the flush block with:
+
+```c
+ // flush all endpoints
+ uint32_t guard = CI_HS_BUSY_SPIN;
+ while (dcd_reg->ENDPTPRIME && guard--) {}
+ flush_endpoints(dcd_reg, 0xFFFFFFFF);
+```
+
+In `dcd_edpt_iso_activate()`, replace the flush + spin with:
+
+```c
+ // Flush EP
+ flush_endpoints(dcd_reg, TU_BIT(epnum + (dir ? 16 : 0)));
+```
+
+- [ ] **Step 5: Build both ci_hs board families**
+
+Run:
+
+```bash
+cmake --build examples/cmake-build-mimxrt1064_evk && cmake --build examples/cmake-build-lpcxpresso18s37
+```
+
+Expected: both succeed with no new warnings.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/portable/chipidea/ci_hs/dcd_ci_hs.c
+git commit -m "dcd(ci_hs): report bus reset start at URI, finish at port change
+
+The RM wants the reset cleanup inside the reset window, but the negotiated
+speed only exists once the port reaches its operational state, so the stack
+was told nothing for the whole window - it kept believing it was configured
+while the queue heads had been zeroed under it, and a transfer a class
+driver started in that gap stayed primed across re-enumeration.
+
+Split the work along the register/software line: bus_reset_begin() does the
+register cleanup at URI and signals BUS_RESET_START, bus_reset_complete()
+re-flushes, resets the queue heads and reports BUS_RESET_END with the final
+speed at the port change. Zeroing the queue heads now happens in the same
+breath as telling the stack, and the second flush retires anything primed
+in between.
+
+Fold the five hand-rolled endpoint flushes into one bounded helper while
+the reset path is open."
+```
+
+---
+
+### Task 3: Make the setup-time EP0 flush wait, and stop dropping the SET_ADDRESS status prime
+
+**Files:**
+- Modify: `src/portable/chipidea/ci_hs/dcd_ci_hs.c` (`dcd_set_address()`; the `ENDPTSETUPSTAT` branch inside `dcd_int_handler()`)
+
+**Interfaces:**
+- Consumes: `flush_endpoints()` (Task 2); `qhd_start_xfer()` returning `bool`, already propagated by `dcd_edpt_xfer()`.
+
+- [ ] **Step 1: Wait for the setup-time flush to complete**
+
+In the ISR's setup branch, replace the fire-and-forget flush line
+
+```c
+ dcd_reg->ENDPTFLUSH = TU_BIT(0) | TU_BIT(16);
+```
+
+with
+
+```c
+ // Wait it out: the flush retires a status/handshake phase left primed by the previous
+ // control sequence (UM10503 25.10.8.1.1), and an unfinished flush would otherwise
+ // still be asserted when the task primes the response to this setup and would retire
+ // that instead. A flush waits for any packet already in progress - microseconds at
+ // high speed - and the guard caps wedged hardware.
+ flush_endpoints(dcd_reg, TU_BIT(0) | TU_BIT(16));
+```
+
+- [ ] **Step 2: Honour the status-prime result in `dcd_set_address`**
+
+Replace the body of `dcd_set_address()`:
+
+```c
+void dcd_set_address(uint8_t rhport, uint8_t dev_addr) {
+ // Response with status first before changing device address. A refused prime means a new
+ // setup superseded this transfer; staging an address whose ACK will never arrive would
+ // leave the device answering on it, so only arm the address when the status went out.
+ if (dcd_edpt_xfer(rhport, tu_edpt_addr(0, TUSB_DIR_IN), NULL, 0, false)) {
+ ci_hs_regs_t *dcd_reg = CI_HS_REG(rhport);
+ dcd_reg->DEVICEADDR = (dev_addr << 25) | TU_BIT(24);
+ }
+}
+```
+
+- [ ] **Step 3: Build and commit**
+
+Run: `cmake --build examples/cmake-build-mimxrt1064_evk && cmake --build examples/cmake-build-lpcxpresso18s37`
+Expected: both succeed.
+
+```bash
+git add src/portable/chipidea/ci_hs/dcd_ci_hs.c
+git commit -m "dcd(ci_hs): wait out the setup flush, honour the set-address prime
+
+The flush issued on every new setup was fire-and-forget. A flush waits for
+a packet already in progress, so it could still be asserted when the task
+primed the response to that setup and retire the fresh prime instead -
+leaving EP0 silent until the host gave up.
+
+dcd_set_address() also armed DEVICEADDR unconditionally, but the status
+prime can now be refused when a newer setup supersedes the transfer; the
+address was then staged behind an ACK that never came and the device sat at
+address 0. Only arm it when the status transfer actually started."
+```
+
+---
+
+### Task 4: Emit RESUME only when the port really left suspend
+
+**Files:**
+- Modify: `src/portable/chipidea/ci_hs/dcd_ci_hs.c` (the resume arm of the `INTR_PORT_CHANGE` branch in `dcd_int_handler()`)
+
+**Interfaces:** none consumed or produced.
+
+- [ ] **Step 1: Restore the hardware guard**
+
+In the `INTR_PORT_CHANGE` branch, the resume arm currently reads:
+
+```c
+ if (pci_reason == PORT_CHANGE_REASON_SUSPEND) {
+ dcd_event_bus_signal(rhport, DCD_EVENT_RESUME, true);
+ } else {
+```
+
+Replace that condition with one that also consults live hardware:
+
+```c
+ if (pci_reason == PORT_CHANGE_REASON_SUSPEND) {
+ // Only when the port actually left suspend: a starved snapshot can hold the resume's
+ // port change together with a second suspend, and reporting a resume there would
+ // leave the stack awake on a sleeping bus with no further event to correct it.
+ if (!(dcd_reg->PORTSC1 & PORTSC1_SUSPEND)) {
+ dcd_event_bus_signal(rhport, DCD_EVENT_RESUME, true);
+ }
+ } else {
+```
+
+- [ ] **Step 2: Build and commit**
+
+Run: `cmake --build examples/cmake-build-mimxrt1064_evk && cmake --build examples/cmake-build-lpcxpresso18s37`
+Expected: both succeed.
+
+```bash
+git add src/portable/chipidea/ci_hs/dcd_ci_hs.c
+git commit -m "dcd(ci_hs): only report resume when the port left suspend
+
+A suspend, resume and second suspend collapsed into one interrupt pass
+queued suspend then resume from the recorded cause alone, so the stack
+ended up awake while the bus was still suspended and nothing arrived to
+correct it. Consult PORTSC1 before reporting the resume."
+```
+
+---
+
+### Task 5: ip3511 — never deliver a knowingly-torn setup packet
+
+**Files:**
+- Modify: `src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c` (setup branch of `dcd_int_handler()`; the `dcd_edpt_clear_stall()` comment)
+
+**Interfaces:** none consumed or produced.
+
+- [ ] **Step 1: Deliver only when the copy is known good**
+
+Replace:
+
+```c
+ // a SETUP that raced in after the acks (its bit0 consumed above, this copy possibly torn):
+ // its latch is visible again - re-raise the endpoint interrupt so the next pass redelivers
+ // the newer payload
+ if (dcd_reg->DEVCMDSTAT & DEVCMDSTAT_SETUP_RECEIVED_MASK) {
+ dcd_reg->INTSETSTAT = TU_BIT(0);
+ }
+
+ dcd_event_setup_received(rhport, setup_copy, true);
+```
+
+with:
+
+```c
+ // a SETUP that raced in after the acks (its bit0 consumed above) makes this copy suspect:
+ // its latch is visible again, so re-raise the endpoint interrupt and let the next pass
+ // deliver the newer payload rather than passing up bytes that may be torn between the two
+ if (dcd_reg->DEVCMDSTAT & DEVCMDSTAT_SETUP_RECEIVED_MASK) {
+ dcd_reg->INTSETSTAT = TU_BIT(0);
+ } else {
+ dcd_event_setup_received(rhport, setup_copy, true);
+ }
+```
+
+- [ ] **Step 2: Add the TODO token to the USB.13 deferral**
+
+In `dcd_edpt_clear_stall()`, change the caveat's opening line from
+
+```c
+ // Known caveat (Errata LPC546xx USB.13, same semantics in UM11126): with RF/TV preserved at 1, TR
+```
+
+to
+
+```c
+ // TODO implement the Errata LPC546xx USB.13 work-around (same semantics in UM11126): with RF/TV preserved at 1, TR
+```
+
+- [ ] **Step 3: Build and commit**
+
+Run: `cmake --build examples/cmake-build-lpcxpresso11u37 && cmake --build examples/cmake-build-lpcxpresso55s28`
+Expected: both succeed.
+
+```bash
+git add src/portable/nxp/lpc_ip3511/dcd_lpc_ip3511.c
+git commit -m "dcd(ip3511): drop a setup packet the hardware may have overwritten
+
+The handler already notices when a new setup landed while it was copying
+the previous one, and re-raises the endpoint interrupt so the newer payload
+is delivered next pass - but it then passed the suspect copy up anyway.
+Usually harmless, since the redelivery supersedes it, but if that second
+event cannot be queued the torn bytes are processed as a real request.
+Deliver the copy only when no newer setup is pending."
+```
+
+---
+
+### Task 6: BSP cleanups — dead RHPORT block and the stale linker comment
+
+**Files:**
+- Modify: `hw/bsp/lpc55/boards/lpcxpresso55s28/board.cmake`
+- Modify: `hw/bsp/lpc11/boards/lpcxpresso11u37/lpc11u37.ld`
+
+**Interfaces:** none consumed or produced.
+
+- [ ] **Step 1: Delete the redundant RHPORT block**
+
+`hw/bsp/lpc55/family.cmake` already applies the identical guarded defaults (`RHPORT_DEVICE 1`,
+`RHPORT_HOST 0`) after including the board file, so remove these lines from
+`board.cmake` entirely:
+
+```cmake
+# device highspeed, host fullspeed; guarded so a -D override on the cmake command line wins
+if (NOT DEFINED RHPORT_DEVICE)
+ set(RHPORT_DEVICE 1)
+endif ()
+if (NOT DEFINED RHPORT_HOST)
+ set(RHPORT_HOST 0)
+endif ()
+```
+
+Leave `board.mk`'s `RHPORT_DEVICE ?= 1` / `RHPORT_HOST ?= 0` alone — `?=` is the idiomatic
+Make form and matches sibling boards.
+
+- [ ] **Step 2: Prove the defaults and the override still work**
+
+Run:
+
+```bash
+cd examples && rm -rf /tmp/rh-default /tmp/rh-override
+cmake -B /tmp/rh-default -DBOARD=lpcxpresso55s28 -G Ninja . > /tmp/rh-default.log 2>&1
+grep -m1 "RHPORT_DEVICE" /tmp/rh-default.log || cmake -B /tmp/rh-default -DBOARD=lpcxpresso55s28 -G Ninja -LA . | grep -E "^RHPORT_(DEVICE|HOST)"
+cmake -B /tmp/rh-override -DBOARD=lpcxpresso55s28 -DRHPORT_DEVICE=0 -DRHPORT_HOST=1 -G Ninja -LA . | grep -E "^RHPORT_(DEVICE|HOST)"
+```
+
+Expected: the default configure yields device 1 / host 0; the override configure yields
+device 0 / host 1. Then rebuild the real tree: `cmake --build cmake-build-lpcxpresso55s28`.
+
+- [ ] **Step 3: Correct the linker-script comment and relabel the ASSERT**
+
+In `lpc11u37.ld`, replace the comment block above `__user_stack_top` and the ASSERT with:
+
+```text
+ /* Main (MSP/ISR) stack lives at the top of the USB SRAM bank: the 8K main bank is packed so
+ tight that only ~280 B remained above .bss, and ISR frames overflowed into the topmost task
+ stack (cdc_msc_freertos hard fault). Nothing else is placed in this bank in either build
+ system, so the stack owns all 2 KB; the ASSERT is future-proofing in case USB buffers are
+ ever mapped here again. */
+ __user_stack_top = ORIGIN(RamUsb2) + LENGTH(RamUsb2);
+ ASSERT(__user_stack_top - (ADDR(.noinit_RAM2) + SIZEOF(.noinit_RAM2)) >= 0x200,
+ "main stack headroom in RamUsb2 below 512 bytes")
+```
+
+- [ ] **Step 4: Build both build systems for lpc11u37**
+
+Run:
+
+```bash
+cmake --build examples/cmake-build-lpcxpresso11u37
+cd examples/device/cdc_msc_freertos && make -j8 BOARD=lpcxpresso11u37 all && cd ../../..
+```
+
+Expected: both succeed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hw/bsp/lpc55/boards/lpcxpresso55s28/board.cmake hw/bsp/lpc11/boards/lpcxpresso11u37/lpc11u37.ld
+git commit -m "bsp: drop duplicated lpc55s28 rhport defaults, fix lpc11u37 comment
+
+hw/bsp/lpc55/family.cmake already applies the same guarded rhport defaults
+after including the board file, so the board-level copy only added a second
+place to keep in sync.
+
+The lpc11u37 linker comment still described USB buffers living in RamUsb2,
+a placement the same branch removed; nothing lands there now, so say so and
+label the headroom assert as future-proofing."
+```
+
+---
+
+### Task 7: Stop halting the target when a DCD legitimately refuses a transfer
+
+**Files:**
+- Modify: `src/device/usbd.c` (`usbd_edpt_xfer()` failure arm)
+
+**Interfaces:** none consumed or produced.
+
+- [ ] **Step 1: Remove the breakpoint from the DCD-refusal path**
+
+Replace the failure arm of `usbd_edpt_xfer()`:
+
+```c
+ } else {
+ // DCD error, mark endpoint as ready to allow next transfer
+ _usbd_dev.ep_status[epnum][dir] &= (uint8_t) ~(TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED);
+ TU_LOG_USBD("FAILED\r\n");
+ TU_BREAKPOINT();
+ return false;
+ }
+```
+
+with:
+
+```c
+ } else {
+ // Driver refused the transfer, mark endpoint as ready to allow next transfer. This is a
+ // recoverable condition (e.g. a new setup superseding a control response), not a bug, so
+ // do not break into the debugger - TU_BREAKPOINT() halts the CPU whenever a probe is
+ // attached, which on a test rig is always.
+ _usbd_dev.ep_status[epnum][dir] &= (uint8_t) ~(TU_EDPT_STATE_BUSY | TU_EDPT_STATE_CLAIMED);
+ TU_LOG_USBD("FAILED\r\n");
+ return false;
+ }
+```
+
+- [ ] **Step 2: Confirm no other stack path relies on that breakpoint**
+
+Run: `grep -n "TU_BREAKPOINT" src/device/*.c src/device/*.h`
+Expected: no remaining hits inside `usbd_edpt_xfer`; other occurrences (if any) are in
+unrelated assert macros and stay as they are.
+
+- [ ] **Step 3: Build and run unit tests**
+
+Run:
+
+```bash
+cmake --build examples/cmake-build-mimxrt1064_evk
+cd test/unit-test && ceedling test:all && cd ../..
+```
+
+Expected: build succeeds, all unit tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/device/usbd.c
+git commit -m "usbd: do not breakpoint when a driver refuses a transfer
+
+TU_BREAKPOINT() is not gated on CFG_TUSB_DEBUG - it halts the CPU whenever
+a debugger is attached, which on a test rig is always. A driver declining a
+transfer is recoverable (a new setup superseding a control response, for
+one) and the endpoint is already released for the retry, so a halted target
+turns a self-healing case into a dead board."
+```
+
+---
+
+### Task 8: Full validation on hardware
+
+**Files:** none modified — this task produces the evidence for the PR description.
+
+**Interfaces:** consumes the firmware built by Tasks 1-7.
+
+- [ ] **Step 1: Software gate**
+
+Run:
+
+```bash
+pre-commit run --all-files
+cd examples
+for b in mimxrt1064_evk lpcxpresso18s37 lpcxpresso11u37 lpcxpresso55s28; do
+ rm -rf cmake-build-$b && cmake -B cmake-build-$b -DBOARD=$b -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-$b || echo "FAILED $b"
+done
+cd ..
+```
+
+Expected: pre-commit all green; all four boards build every example.
+
+- [ ] **Step 2: Make-build regression checks**
+
+Run:
+
+```bash
+cd examples/host/cdc_msc_hid && make -j8 BOARD=lpcxpresso55s28 all && cd ../../..
+cd examples/device/cdc_msc_throughput && make -j8 BOARD=lpcxpresso11u37 all && cd ../../..
+```
+
+Expected: both link (these two were broken earlier in the branch and are the regression
+canaries for the BSP changes).
+
+- [ ] **Step 3: Flash with verification (mandatory)**
+
+The mimxrt1064_evk has twice accepted a flash that silently did not take, so every load in
+this task uses `verifyfile`. For each board, write a J-Link script of this shape and run it:
+
+```
+r
+h
+loadfile examples/cmake-build-<board>/device/usbtest/usbtest.elf
+verifyfile examples/cmake-build-<board>/device/usbtest/usbtest.elf
+r
+g
+qc
+```
+
+Probes and devices: `mimxrt1064_evk` = `-USB 000725299165 -device MIMXRT1064xxx6A`,
+`lpcxpresso55s28` = `-USB 000727031389 -device LPC55S28`,
+`lpcxpresso11u37` = `-USB 000724441579 -device LPC11U37/401`.
+Invoke as `JLinkExe <probe/device args> -if swd -speed 4000 -autoconnect 1 -NoGui 1 -CommandFile <script>`.
+Expected: `Verify` reports O.K. and the board re-enumerates as `cafe:4010` with its own
+serial before any test runs.
+
+- [ ] **Step 4: HIL batteries and stress**
+
+Hold each board's lock for its own leg (`python3 test/hil/hil_lock.py hold <board> --reason "reset-edge validation"`,
+release after), never run two batteries at once, and abort if CI is active
+(`pgrep -f "hil_test.py [-]-retry"`).
+
+```bash
+# per board: full battery
+timeout 700 python3 test/hil/usbtest.py --serial <serial> --json --keep-binding --timeout 60
+
+# mimxrt1064_evk only: queued-control stress and the unlink storm
+for i in $(seq 1 50); do timeout 200 python3 test/hil/usbtest.py --serial BAE96FB95AFA6DBB8F00005002001200 --tests 9,10 --json --keep-binding --timeout 60 > /dev/null || break; done
+for i in $(seq 1 10); do timeout 300 python3 test/hil/usbtest.py --serial BAE96FB95AFA6DBB8F00005002001200 --tests 11,12,24 --json --keep-binding --timeout 60 > /dev/null || break; done
+```
+
+Serials: 1064 `BAE96FB95AFA6DBB8F00005002001200`, 55s28 `2BF1839A7D51F553A15AB03FD08F70AB`,
+11u37 `17121919`.
+Expected: 30/30 on all three boards, 50/50 and 10/10 loops, and
+`ps -eo stat,comm | awk '$1 ~ /^D/'` empty after each leg.
+
+- [ ] **Step 5: Reset-path evidence with logging**
+
+Build and flash `device/cdc_msc` for `mimxrt1064_evk` with `-DLOG=2 -DLOGGER=rtt`, capture
+RTT during one unplug/replug cycle (`timeout 20s JLinkRTTClient > /tmp/reset.log`), then:
+
+```bash
+grep -cE "Bus Reset Start" /tmp/reset.log
+grep -cE "Bus Reset End" /tmp/reset.log
+grep -c "Resume" /tmp/reset.log
+```
+
+Expected: equal non-zero counts for start and end (one pair per enumeration) and no
+`Resume` lines during a plain plug-in.
+
+- [ ] **Step 6: Suspend/resume pairing**
+
+With the same RTT build attached, suspend the port from the host and resume it:
+
+```bash
+# find the 1064's busport, then:
+echo auto | sudo tee /sys/bus/usb/devices/<busport>/power/control
+sleep 5
+echo on | sudo tee /sys/bus/usb/devices/<busport>/power/control
+```
+
+Expected in the log: one `Suspend` followed by one `Resume`, and no `Bus Reset` of either
+edge from the suspend cycle alone.
+
+- [ ] **Step 7: Record the evidence**
+
+Append the numbers from Steps 1-6 to the PR description draft. No commit.
+
+## Self-Review
+
+**Spec coverage:** §1 event split → Task 1. §2 ci_hs bus_reset split → Task 2. §3 flush
+helper → Task 2 (Steps 1, 4). §4 mechanical: setup-flush wait and `dcd_set_address` → Task 3;
+RESUME guard → Task 4; ip3511 torn setup and USB.13 TODO → Task 5; usbd breakpoint → Task 7;
+BSP pair → Task 6. Verification matrix → Task 8 (legacy-DCD build guard is Task 1 Step 4).
+Deferred items are deliberately absent from every task. No gaps.
+
+**Placeholder scan:** no TBD/TODO-as-placeholder; the two literal `TODO` strings are
+deliverable code comments (Task 1 Step 3, Task 5 Step 2). Every code step carries the exact
+text to write; every run step carries the command and expected result.
+
+**Type consistency:** `flush_endpoints(ci_hs_regs_t *dcd_reg, uint32_t mask) -> bool` is
+defined in Task 2 Step 1 and used with that exact signature in Task 2 Steps 2/4 and Task 3
+Step 1. `DCD_EVENT_BUS_RESET_START` / `_END` are defined in Task 1 and used in Task 2 Step 3
+via `dcd_event_bus_signal()` / `dcd_event_bus_reset()`, whose signatures are quoted in Task 1's
+Interfaces block. `bus_reset_begin()` / `bus_reset_complete()` are defined and called with
+matching names in Task 2.
diff --git a/docs/superpowers/plans/2026-08-16-drop-ep0-prime-verify.md b/docs/superpowers/plans/2026-08-16-drop-ep0-prime-verify.md
new file mode 100644
index 000000000..aa999c9e3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-drop-ep0-prime-verify.md
@@ -0,0 +1,314 @@
+# Drop the EP0 Post-Prime Verify Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Remove the EP0 post-prime verification that was built on a theory the RT106x endpoint-conflict errata has superseded, and prove on hardware that nothing depended on it.
+
+**Architecture:** One deletion in `qhd_start_xfer()`, then a rebase onto current master, then an A/B validation whose "with it" arm is already banked (10x 30/30 batteries plus 40 targeted loops on 2026-08-16). No interfaces change: the pre-prime setup-lockout guard keeps `qhd_start_xfer()` returning `bool`, so `dcd_set_address()`'s gating and usbd's failure path stay exactly as they are.
+
+**Tech Stack:** C99, TinyUSB ChipIdea HS DCD (`src/portable/chipidea/ci_hs/`), CMake+Ninja and Make builds, J-Link (JLinkExe V9.66), `test/hil/usbtest.py` driving the Linux testusb battery.
+
+## Global Constraints
+
+- Branch `fix-ci-hs` in worktree `/home/hathach/.herdr/worktrees/tinyusb/fix-ci-hs`. Do NOT push; the user pushes.
+- C99, 2-space indent. Commit messages imperative, no `Co-Authored-By:` or `Claude-Session:` trailers (repo rule: hathach is sole author).
+- Pre-commit hook (trailing-whitespace, end-of-file-fixer, codespell, unique-PIDs, ceedling) must pass; if it rewrites a file, re-stage and retry the commit once.
+- Never edit anything under `hw/mcu/` or `lib/` (vendor code).
+- Rig etiquette: hold the board lock for hardware work (`python3 test/hil/hil_lock.py hold <board> --reason "..."`, release after); abort if CI is active (`pgrep -f "hil_test.py [-]-retry"`); NEVER use `uhubctl`, `pci-reset` or `pci-rebind`; never touch the actions-runner.
+- JLinkExe on this rig is **V9.66 and has no `verifyfile` command** — use `loadfile` (built-in Program & Verify) plus a mandatory enumeration check.
+- Board facts: `mimxrt1064_evk`, serial `BAE96FB95AFA6DBB8F00005002001200`, J-Link probe `000725299165`, device `MIMXRT1064xxx6A`, expected `cafe:4010`.
+- Design source of truth: `docs/superpowers/specs/2026-08-16-drop-ep0-prime-verify-design.md`.
+
+## File Structure
+
+| File | Responsibility in this plan |
+|---|---|
+| `src/portable/chipidea/ci_hs/dcd_ci_hs.c` | The only code change: delete the post-prime block in `qhd_start_xfer()` |
+
+Tasks 2 and 3 change no files; they rebase and validate.
+
+---
+
+### Task 1: Delete the EP0 post-prime verify
+
+**Files:**
+- Modify: `src/portable/chipidea/ci_hs/dcd_ci_hs.c` (the tail of `qhd_start_xfer()`)
+
+**Interfaces:**
+- Produces: `qhd_start_xfer()` keeps its existing signature `static bool qhd_start_xfer(uint8_t rhport, uint8_t epnum, uint8_t dir)` and still returns `false` from the pre-prime setup-lockout guard. No caller changes.
+
+- [ ] **Step 1: Apply the deletion**
+
+In `qhd_start_xfer()`, replace this (everything from the prime write to the closing `return true;`):
+
+```c
+ // start transfer
+ const uint32_t prime_bit = TU_BIT(epnum + (dir ? 16 : 0));
+ dcd_reg->ENDPTPRIME = prime_bit;
+
+ if (epnum == 0) {
+ // RM (RT1050 RM Executing a Transfer / UM10503 25.10.8): after priming EP0 the DCD must
+ // verify the prime completed - ENDPTPRIME bit clear AND the buffer reported ready in
+ // ENDPTSTAT - because the controller silently cancels an EP0 prime when a SETUP arrives
+ // during the prime operation. An undetected drop NAK-parks the endpoint forever: usbd never
+ // re-primes a busy endpoint. A very fast transfer may already have completed and retired the
+ // ENDPTSTAT bit, so ENDPTCOMPLETE also counts as the prime having taken.
+ uint32_t guard = CI_HS_BUSY_SPIN;
+ while (dcd_reg->ENDPTPRIME & prime_bit) {
+ if (!guard--) {
+ dcd_reg->ENDPTFLUSH = prime_bit; // never leave a wedged prime armed over a freed buffer
+ return false;
+ }
+ }
+ // Fail only when the cancel-cause is visibly pending: a completed transfer can have both
+ // status bits already retired by the ISR, and a cancel whose SETUP the ISR consumed is
+ // re-driven by that queued SETUP event anyway.
+ if (!((dcd_reg->ENDPTSTAT | dcd_reg->ENDPTCOMPLETE) & prime_bit) &&
+ (dcd_reg->ENDPTSETUPSTAT & TU_BIT(0))) {
+ return false; // prime cancelled (setup mid-prime): the pending SETUP re-drives EP0
+ }
+ }
+ return true;
+```
+
+with:
+
+```c
+ // start transfer
+ dcd_reg->ENDPTPRIME = TU_BIT(epnum + (dir ? 16 : 0));
+ return true;
+```
+
+Leave the `if (epnum == 0)` setup-lockout block ABOVE the prime write completely untouched —
+that one spins on `ENDPTSETUPSTAT` before priming and is required by UM10503 25.10.8.1.1
+step 4.
+
+- [ ] **Step 2: Confirm nothing else referenced the removed code**
+
+Run:
+
+```bash
+grep -n "ENDPTSTAT\|ENDPTCOMPLETE\|prime_bit" src/portable/chipidea/ci_hs/dcd_ci_hs.c
+```
+
+Expected: no `prime_bit` hits at all; `ENDPTCOMPLETE` hits only in `bus_reset_begin()` and the
+`INTR_USB` branch of `dcd_int_handler()`; `ENDPTSTAT` hits only in `ci_hs_type.h`-style register
+declarations if any appear — none inside `qhd_start_xfer()`.
+
+- [ ] **Step 3: Build both ci_hs board families**
+
+Run:
+
+```bash
+cmake --build examples/cmake-build-mimxrt1064_evk && cmake --build examples/cmake-build-lpcxpresso18s37
+```
+
+Expected: both succeed, no new warnings (in particular no "unused variable" for anything the
+deletion orphaned).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/portable/chipidea/ci_hs/dcd_ci_hs.c
+git commit -m "dcd(ci_hs): drop the EP0 post-prime verify
+
+The verify came from a theory that a setup arriving mid-prime silently
+cancels an EP0 prime, which was how the recurring wedge on the test rig
+looked at the time. The wedge turned out to be Errata i.MX RT1064_A
+ERR050101: with an isochronous IN endpoint active, an IN token to that
+endpoint number on another device sharing the host unprimes one of our OUT
+endpoints, undetectably and with no interrupt. Moving the usbtest iso IN
+endpoint clear of the conflict fixed it - 340 runs where the board used to
+wedge within hours.
+
+The capture that motivated the verify (EP0 status stage armed but unprimed,
+device a control transfer ahead of the host) is explained by that errata
+just as well, because it covers control OUT endpoints and a control status
+stage is one. So the verify has no independent evidence behind it, while it
+does cost two register spins on every EP0 transfer and can misread a
+transfer the interrupt handler already completed as a cancelled prime.
+
+The setup-lockout check before priming stays - that one is in the manual."
+```
+
+---
+
+### Task 2: Rebase onto current master and re-run the software gates
+
+**Files:** none modified by hand.
+
+**Interfaces:** none.
+
+- [ ] **Step 1: Rebase**
+
+Master has advanced (midi2/usbtmc/video changes) since this branch last rebased. Validating a
+tree that is not the one being merged would be a false pass.
+
+```bash
+git fetch origin master
+git rebase origin/master
+```
+
+Expected: clean rebase. If a conflict appears in `src/portable/chipidea/ci_hs/dcd_ci_hs.c` or
+`src/device/usbd.c`, resolve it hunk-by-hunk keeping BOTH sides' intent (never `git checkout
+--theirs/--ours` on a whole file), then `git rebase --continue`.
+
+- [ ] **Step 2: Rebuild everything from scratch**
+
+```bash
+cd examples
+for b in mimxrt1064_evk lpcxpresso18s37 lpcxpresso11u37 lpcxpresso55s28; do
+ rm -rf cmake-build-$b
+ cmake -B cmake-build-$b -DBOARD=$b -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-$b || echo "FAILED $b"
+done
+cd ..
+```
+
+Expected: all four boards build every example, no "FAILED" line.
+
+- [ ] **Step 3: Make link canaries**
+
+```bash
+cd examples/host/cdc_msc_hid && make -j8 BOARD=lpcxpresso55s28 all && cd ../../..
+cd examples/device/cdc_msc_throughput && make -j8 BOARD=lpcxpresso11u37 all && cd ../../..
+```
+
+Expected: both link. These two were broken earlier in the branch's life and are the regression
+canaries for the BSP changes.
+
+- [ ] **Step 4: Unit tests and pre-commit**
+
+```bash
+cd test/unit-test && ceedling test:all && cd ../..
+pre-commit run --all-files
+```
+
+Expected: all unit tests pass; every pre-commit hook passes.
+
+- [ ] **Step 5: No commit**
+
+This task produces no commit of its own — the rebase rewrites existing commits and the builds
+are throwaway. Record the resulting HEAD hash in the report for Task 3 to reference.
+
+---
+
+### Task 3: Hardware A/B on mimxrt1064_evk
+
+**Files:** none modified — this task produces the evidence.
+
+**Interfaces:** consumes the firmware built in Task 2 at
+`examples/cmake-build-mimxrt1064_evk/device/usbtest/usbtest.elf`.
+
+Only this board is tested: it is the sole ci_hs board on the rig. The lpcxpresso55s28 and
+lpcxpresso11u37 run the ip3511 driver, which this change does not touch.
+
+- [ ] **Step 1: Preconditions**
+
+```bash
+pgrep -f "hil_test.py [-]-retry" && echo "CI ACTIVE - wait" || echo "CI idle"
+ps -eo stat,pid,etimes,comm | awk '$1 ~ /^D/'
+python3 test/hil/hil_lock.py hold mimxrt1064_evk --reason "prime-verify removal A/B"
+```
+
+Expected: CI idle, no pre-existing D-state processes, lock acquired. If CI is active, wait for
+it to drain rather than running concurrently.
+
+- [ ] **Step 2: Flash with verification**
+
+```bash
+cat > /tmp/pv.jlink <<'EOF'
+r
+h
+loadfile examples/cmake-build-mimxrt1064_evk/device/usbtest/usbtest.elf
+r
+g
+qc
+EOF
+JLinkExe -device MIMXRT1064xxx6A -if SWD -speed 4000 -SelectEmuBySN 000725299165 \
+ -autoconnect 1 -nogui 1 -CommandFile /tmp/pv.jlink
+```
+
+Expected: `Program & Verify` reports O.K.
+
+- [ ] **Step 3: Confirm the right image is actually running**
+
+```bash
+sleep 5
+grep -l BAE96FB95AFA6DBB8F00005002001200 /sys/bus/usb/devices/*/serial
+sudo lsusb -v -d cafe:4010 2>/dev/null | grep -A3 "Isochronous" | grep bEndpointAddress
+```
+
+Expected: the board is present, and the iso IN endpoint reads **0x87**. If it reads 0x83 the
+flash did not take (this board has silently no-op'd a flash twice) — reflash and re-check
+before running anything.
+
+- [ ] **Step 4: 5x full battery**
+
+```bash
+for i in $(seq 1 5); do
+ timeout 700 python3 test/hil/usbtest.py --serial BAE96FB95AFA6DBB8F00005002001200 \
+ --json --keep-binding --timeout 60 2>/dev/null | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+bad=[str(c['num']) for c in d['cases'] if c['status']!='PASS']
+print(f\"run: {d['passed']}/30 speed={d['speed']}\" + (' FAILED:'+','.join(bad) if bad else ''))
+"
+ ps -eo stat,pid,etimes,comm | awk '$1 ~ /^D/ && $4=="testusb"'
+done
+```
+
+Expected: five lines each reading `30/30 speed=480`, and no testusb D-state line between runs.
+
+- [ ] **Step 5: 15x control-focused loop**
+
+These are the paths the removed verify actually protected — queued control, the ch9 subset, and
+both ctrl_out cases. A full battery samples each only once per run.
+
+```bash
+PASS=0
+for i in $(seq 1 15); do
+ timeout 300 python3 test/hil/usbtest.py --serial BAE96FB95AFA6DBB8F00005002001200 \
+ --tests 9,10,14,21 --json --keep-binding --timeout 60 >/dev/null 2>&1 && PASS=$((PASS+1)) || { echo "FAILED at iteration $i"; break; }
+ D=$(ps -eo stat,comm | awk '$1 ~ /^D/ && $2=="testusb"' | wc -l)
+ [ "$D" != "0" ] && { echo "D-STATE at iteration $i"; break; }
+done
+echo "control loops: $PASS/15"
+```
+
+Expected: `control loops: 15/15`, no FAILED or D-STATE line.
+
+- [ ] **Step 6: Release the lock and record**
+
+```bash
+python3 test/hil/hil_lock.py release mimxrt1064_evk
+ps -eo stat,pid,etimes,comm | awk '$1 ~ /^D/'
+```
+
+Expected: lock released, no leftover D-state.
+
+**Acceptance:** 5/5 batteries at 30/30, 15/15 control loops, 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. In that case: `git revert` the
+Task 1 commit, re-run Steps 4-5 to confirm the failure disappears, and record the result — that
+is a finding worth keeping, not a setback to hide.
+
+---
+
+## Self-Review
+
+**Spec coverage:** the spec's change section → Task 1; "rebase first, then rebuild" → Task 2
+Steps 1-2; software gates → Task 2 Steps 3-4; hardware preconditions, verified flash and the
+0x87 descriptor check → Task 3 Steps 1-3; 5x battery and 15x control loop → Task 3 Steps 4-5;
+acceptance and rollback trigger → Task 3's closing block. The spec's "deliberately kept" list is
+enforced negatively by Task 1 Step 1's instruction to leave the setup-lockout block untouched
+and by Task 1 Step 2's grep. No gaps.
+
+**Placeholder scan:** no TBD/TODO/"handle edge cases"; every step carries its exact command or
+code and its expected result.
+
+**Type consistency:** `qhd_start_xfer(uint8_t rhport, uint8_t epnum, uint8_t dir) -> bool` is
+unchanged by this plan and no caller is touched, so there are no cross-task signatures to
+reconcile. The only removed identifier, `prime_bit`, is local to the deleted block and Task 1
+Step 2 greps to confirm it has no remaining references.