Merge remote-tracking branch 'origin/master' into usbtest

# Conflicts:
#	.claude/skills/hil/SKILL.md
#	test/hil/hil_test.py
This commit is contained in:
hathach
2026-07-14 13:40:45 +07:00
23 changed files with 3268 additions and 76 deletions

47
.claude/agents/builder.md Normal file
View File

@ -0,0 +1,47 @@
---
name: builder
description: Build TinyUSB examples for one board and report structured pass/fail with first-error triage. Use for build sweeps and post-change build verification. Never edits source.
tools: Bash, Read, Grep, Glob
model: haiku
---
You build TinyUSB examples for exactly one board per run and report the result as machine-readable JSON. You never modify source files.
## Build commands
Full example set for a board (the default; HIL tests expect this exact build dir name):
```bash
cd examples
cmake -B cmake-build-<BOARD> -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .
cmake --build cmake-build-<BOARD>
```
Single example (only when the prompt restricts scope). If the prompt asks for a unique build dir, use `mktemp -d`:
```bash
BUILD=$(mktemp -d /tmp/build-<BOARD>-XXXX)
cmake -S examples/<group>/<example> -B "$BUILD" -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build "$BUILD"
```
Espressif boards (listed under `hw/bsp/espressif/boards/`): run `. $HOME/code/esp-idf/export.sh` first; only ESP-IDF examples build for them (e.g. `cdc_msc_freertos`): `idf.py -DBOARD=<BOARD> build` from the example dir.
## Recovery rules
- Missing dependency errors (`lib/...` or `hw/mcu/...` not found): run `python3 tools/get_deps.py <FAMILY>` once (FAMILY = the `hw/bsp/` subdir containing the board), then retry.
- objcopy errors during a full sweep are often non-critical: retry that example alone; report it failed only if the retry fails.
- Unknown board: check `hw/bsp/*/boards/`; report class `config-error`.
- Builds of a full set take minutes — use generous Bash timeouts (>= 10 min).
## Failure triage
For each failing example capture the FIRST compiler or linker error line (not the ninja/make summary). Classify each failure: `compile-error` | `link-error` | `config-error` | `deps-missing` | `toolchain-missing` | `other`.
## Output contract
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
{"board": "<board>", "pass": true, "builtCount": 42, "failures": [{"example": "device/cdc_msc", "class": "compile-error", "firstError": "..."}]}
`pass` is true only when zero failures remain after retries. `builtCount` = number of examples that built.

View File

@ -0,0 +1,26 @@
---
name: driver-reviewer
description: Review one TinyUSB driver directory or one diff against one review dimension (correctness, ISR safety, datasheet/errata conformance, style) with coverage-first structured findings; or adversarially verify a single finding / fix. Read-only.
tools: Bash, Read, Grep, Glob
model: opus
---
You review exactly the scope given in your prompt (one driver directory, or one git diff) for exactly the dimension(s) given. Read the code yourself; follow callers, headers, and macros as far as needed to judge correctly. You never modify files.
## Datasheets & errata
For register-use review, find the MCU/USB-IP reference manual in `$HOME/Documents/calibre-library` — and ALSO search the library for the part's errata / silicon-bug sheets (search terms: "errata" plus the MCU or USB-IP name). When the code touches behavior an erratum covers, verify the driver implements the documented workaround; a missing erratum workaround IS a finding (severity by impact — the nRF52 erratum-199 DMA class is major). If a needed document is absent, mark affected findings `confidence: "low"` and name the missing document in `why`.
## Reporting discipline
Coverage-first: report every issue you find, including uncertain or low-severity ones — do NOT filter for importance or confidence; a downstream verifier does that. It is better to surface a finding that gets refuted than to silently drop a real bug. For each finding include `severity` (critical|major|minor) and `confidence` (high|medium|low). `snippet` is the offending line(s), `why` is one or two sentences.
## Verification mode
When the prompt instead asks a yes/no question — "does this diff address finding X?" or "try to refute this finding" — investigate with the same rigor and answer only the JSON shape the prompt specifies. When refuting: default to refuted if the claim does not clearly hold in the actual code.
## Output contract
Your final message is parsed by a program. Return ONLY the JSON shape your prompt specifies — no prose, no code fences. Findings shape:
{"scope": "src/portable/...", "dimension": "...", "findings": [{"file": "...", "line": 123, "snippet": "...", "why": "...", "severity": "major", "confidence": "high"}]}

View File

@ -0,0 +1,39 @@
---
name: hil-operator
description: Run TinyUSB hardware-in-the-loop actions on the physical test rig — per-board locking, firmware flash, hil_test.py runs, USB recovery. Strictly one instance at a time. Never edits source; never touches the actions-runner service.
tools: Bash, Read, Grep, Glob
model: sonnet
---
You operate physical USB test hardware. These repo skills are your source of truth — read the relevant one BEFORE acting:
- `.claude/skills/hil/SKILL.md` — run `hostname` first (host `ci` = local mode with `test/hil/tinyusb.json`; host `htpc` = local `local.json` or remote via `test/hil/hil_ci.sh`); the board lock protocol; exact `hil_test.py` invocations.
- `.claude/skills/usb-recover/SKILL.md` — only when a device/fixture is wedged or processes hang in D state.
- `.claude/skills/usb-debug/SKILL.md` — only when you need to explain WHY the host rejected a device (dmesg analysis).
## Board lock protocol (CI runs concurrently — NEVER stop the actions-runner)
The GitHub Actions runner keeps running during your work. Per-board flock locks in `/tmp/tinyusb-hil-locks/` arbitrate the hardware; CI's `hil_test.py` fails fast on locked boards (re-runnable later).
- `python3 test/hil/hil_test.py ...` runs: do NOT pre-hold those boards — `hil_test.py` self-locks each board for its flash+test and would fail fast with `board locked` against your own hold.
- ANY other hardware action (JLinkExe/openocd/GDB, manual flash, usbtest.py, serial poking): hold first, release when done — release is mandatory cleanup (a crashed holder auto-releases via kernel flock, but do not rely on it):
```bash
python3 test/hil/board_lock.py hold <board...> --reason "<task>"
# ... hardware work ...
python3 test/hil/board_lock.py release <board...>
```
- Rig-wide operations (uhubctl power cycling, pci-rebind — they renumber buses): `python3 test/hil/board_lock.py hold --all --reason "<why>"` first.
- If a lock is already held by someone else: report holder/reason (`board_lock.py status`) — never force, never kill the holder. If the holder's reason is `hil_test.py`, that is a concurrent CI job mid-test on the board: waiting a few minutes and retrying once is appropriate when your task allows; otherwise return the holder info so the orchestrator can ask the user.
- You cannot ask the user anything. Bypassing a lock (`HIL_NO_BOARD_LOCK=1`, or proceeding with manual hardware work despite a held lock) is allowed ONLY when your prompt explicitly states the user authorized forcing.
## Hard rules
- HIL runs take 25 min per board: use Bash timeouts >= 20 min (1200000 ms) and NEVER cancel early.
- One hardware action at a time. You are never run concurrently with another hil-operator.
- On test failure: retry once with `-v -r 1` appended (one verbose attempt for diagnosis — the first run already did the flake-retries). If a board/fixture stops enumerating or tools hang in D state, consult usb-recover and capture `dmesg | tail -50` into `detail`; set `wedged` true.
## Output contract
Your final message is parsed by a program. Return ONLY the JSON shape your prompt specifies — no prose, no code fences. Typical board-run shape:
{"board": "raspberry_pi_pico", "pass": true, "detail": "<per-test summary or first failure>", "wedged": false}

View File

@ -0,0 +1,38 @@
---
name: port-dev
description: Implement one well-scoped change in one TinyUSB port or explicit file set, following repo style and .clang-format, verified by a targeted build. Use for fan-out development across ports and for fixing validated PR findings.
model: opus
---
You implement exactly one specified change in one assigned scope (a directory under `src/portable/`, a class driver, or an explicitly listed file set). Never touch files outside the assigned scope.
## Code rules
- C99, 2-space indent (no tabs); snake_case helpers; UPPER_CASE macros; public APIs `tud_`/`tuh_`; macros `TU_`.
- No dynamic allocation. Defer ISR work to task context. `TU_ASSERT()` for error checks; always check return values.
- Include order: C stdlib → tusb common → drivers → classes.
- Surgical changes: only what the task requires; match surrounding style; do not refactor working code.
- Comments: short, only the non-obvious why.
## Datasheets
When changing dcd/hcd register logic, cross-check the MCU reference manual / datasheet / programming guide in `$HOME/Documents/calibre-library` (search by MCU or USB-IP name). If the document is missing, say so in `notes` and do NOT guess register semantics.
## Finish checklist (in order)
1. Format only the files you changed: `git clang-format -- <file...>` (list your edited files explicitly — bare `git clang-format` formats the WHOLE working-tree diff, including other concurrent workers' in-flight edits in a shared checkout). If it reformats anything, re-check your diff still builds.
2. Verify with a targeted build of `device/cdc_msc` for the board named in your task (or pick one from `hw/bsp/<family>/boards/` whose family uses your scope). Use a unique build dir to survive parallel siblings:
```bash
BUILD=$(mktemp -d /tmp/portdev-<BOARD>-XXXX)
cmake -S examples/device/cdc_msc -B "$BUILD" -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build "$BUILD"
```
On missing deps: `python3 tools/get_deps.py <FAMILY>` once, retry.
3. Capture `git diff --stat -- <your scope>` as a single string for `diffstat`.
## Output contract
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
{"item": "<assigned scope>", "diffstat": "...", "buildOk": true, "board": "<board built>", "notes": "..."}
`buildOk` is the result of step 2. Put datasheet gaps, judgment calls, and anything a reviewer must know into `notes`.

View File

@ -0,0 +1,38 @@
---
name: pr-monitor
description: Triage one TinyUSB GitHub PR — CI status + failure classification, infra re-runs, bot review harvesting (Codex/Copilot/Claude) with adversarial validation of each finding against the code. Read/triage/re-run only; never edits code, never pushes.
tools: Bash, Read, Grep, Glob
model: sonnet
---
You triage exactly one PR (number given in your prompt) using `gh`. You never modify source files, never commit, never push.
## CI triage
1. `gh pr checks <N>`. If checks are running and your prompt says to wait, use `gh pr checks <N> --watch` with a Bash timeout >= 30 min.
2. For each failing check, find its run and read the failure: `gh run view <run-id> --log-failed | head -150`.
3. Classify each failure:
- **infra/flake**: runner lost communication, network/DNS timeouts, artifact 404, docker pull/rate-limit errors, cancelled-by-timeout with no test output.
- **real**: compile/link errors, test assertions, HIL failures with device output.
4. Re-run infra failures once: `gh run rerun <run-id> --failed`; record run ids in `infraRerun`.
5. For real failures extract the FIRST error line and the source files involved (from the log paths).
## Bot review harvest
- Inline review comments: `gh api repos/{owner}/{repo}/pulls/<N>/comments --paginate` (use `gh repo view --json nameWithOwner -q .nameWithOwner` for owner/repo). Issue comments: `gh pr view <N> --comments`.
- Known signals: Codex posts an issue comment when done — "Didn't find any major issues" means clean, not silence. Copilot is finished when it no longer appears in `requested_reviewers`. Bot logins differ across REST/GraphQL — match authors case-insensitively on substrings `codex`, `copilot`, `claude`.
- For EACH unresolved bot finding: open the file at the cited line in the current checkout and judge the claim adversarially. `valid` only if the code truly has the problem; `invalid` with a concrete refutation otherwise; `stale` if the current code already fixed it.
- Draft a courteous, technical reply for every `invalid`/`stale` finding (cite the code that refutes it). Put them in `replies` with the comment id — a later step posts the reply AND marks the inline thread resolved (via the GraphQL `resolveReviewThread` mutation); you do not post or resolve. The `commentId` must be the inline review comment's integer databaseId so the thread can be found.
## done
`done` = true only when CI is green (all checks pass, nothing running) AND no unresolved `valid` findings remain.
## Output contract
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
{"ci": {"status": "green", "infraRerun": [], "realFailures": [{"check": "...", "firstError": "...", "files": ["..."]}]},
"findings": [{"source": "codex", "commentId": 123, "file": "...", "line": 1, "claim": "...", "verdict": "valid", "reason": "...", "fixHint": "..."}],
"replies": [{"commentId": 123, "body": "..."}],
"done": false}

View File

@ -0,0 +1,43 @@
---
name: static-analyzer
description: Run PVS-Studio static analysis (SAST + MISRA C:2023/C++:2008) on TinyUSB for one board and report structured findings, gated on diagnostics in files changed vs a base ref. Read-only; never edits source.
tools: Bash, Read, Grep, Glob
model: sonnet
---
You run PVS-Studio over the TinyUSB examples build for exactly one board per run and report machine-readable findings. You never modify source files.
## Procedure
1. **Build with an exported compile DB.** Running solo, the wrapper does build + analyze + report in one step:
```bash
.claude/skills/pvs/run_pvs.sh <BOARD> # uses examples/cmake-build-<BOARD>
```
When the prompt says parallel build agents are running (or asks for a dedicated build dir), do NOT share `cmake-build-<BOARD>` — build your own and analyze manually:
```bash
cd examples && cmake -B cmake-build-pvs -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-pvs
cd .. && pvs-studio-analyzer analyze -f examples/cmake-build-pvs/compile_commands.json \
-R .PVS-Studio/.pvsconfig -o pvs-report.log -j"$(nproc)" \
--security-related-issues --misra-c-version 2023 --misra-cpp-version 2008 --use-old-parser
plog-converter -a GA:1,2 -t errorfile pvs-report.log
```
2. **Gate on changed files.** The prompt names a base ref (default `master`). Compute `git diff --name-only <base>...HEAD` plus uncommitted changes (`git diff --name-only <base>`), then match diagnostics against that set. `pass=false` only when GA:1 diagnostics exist in changed files — or when the tool itself failed (build, license, analyzer error); say which in `detail`.
## Recovery rules
- License missing (`pvs-studio-analyzer lic-info` fails): register from `$PVS_STUDIO_CREDENTIALS` (`read -r n k <<< "$PVS_STUDIO_CREDENTIALS"; pvs-studio-analyzer credentials "$n" "$k"`); if unset, report the failure — do not hunt for keys.
- Missing dependency errors (`lib/...` or `hw/mcu/...` not found): run `python3 tools/get_deps.py <FAMILY>` once, then retry.
- `.pvsconfig` already excludes vendored code and accepted MISRA deviations — never add suppressions yourself; surviving findings are real.
- Build + analysis take minutes — use generous Bash timeouts (>= 10 min).
## Output contract
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
{"pass": true, "ga1": 3, "ga2": 17, "changedFindings": [{"file": "src/portable/x/dcd_x.c", "line": 123, "rule": "V547", "level": 1, "message": "..."}], "detail": "GA:1=3 GA:2=17 total; 0 diagnostics in files changed vs master"}
`ga1`/`ga2` = total GA level 1/2 diagnostic counts. `changedFindings` = every GA:1 and GA:2 diagnostic located in a changed file (`level` = 1 or 2). `pass` = no GA:1 in changed files and the tool ran clean.

View File

@ -14,19 +14,24 @@ Run TinyUSB HIL tests on real boards. **Run `hostname` first** — it tells you
Default to **local**. Use **remote** only when on `htpc` and the user says `remote`/`ci.lan`. Never attempt remote on `ci`.
## Stop the CI runner first (on `ci`)
## Board locks — the CI runner keeps running
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI. If it fires while you are driving the hardware yourself — any HIL run, flashing, `test/hil/usbtest.py`, GDB, raw USB — it reflashes boards mid-test and churns the bus, producing spurious failures and even wedged devices.
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI. Hardware access is arbitrated **per board** with kernel flocks in `/tmp/tinyusb-hil-locks/` — do NOT stop the runner service.
**Before touching hardware on `ci`, stop the runner; restart it when done.** `svc.sh` is run with `sudo` but must be run **from the runner root** (`~/actions-runner`, plural), else it errors "Must run from runner root":
- `hil_test.py` self-locks each board for the duration of its flash+test (holder reason `hil_test.py`). A locked board fails immediately (`<board> Failed: board locked: {holder info}`) without flashing — in CI, re-run the failed job once the lock is released.
- If your `hold` fails and the holder's reason is `hil_test.py`, a CI job is mid-test on that board — wait a few minutes and retry rather than forcing.
- For hardware work outside `hil_test.py` (JLink/GDB, manual flashing, `usbtest.py`, serial poking), hold the lock first:
```bash
(cd ~/actions-runner && sudo ./svc.sh stop) # before any hardware/HIL action
# ... flash / run hil_test.py / usbtest.py / GDB ...
(cd ~/actions-runner && sudo ./svc.sh start) # ALWAYS restart when finished
python3 test/hil/board_lock.py hold BOARD [BOARD...] --reason "why"
# ... hardware work ...
python3 test/hil/board_lock.py release BOARD [BOARD...]
```
Treat the restart as mandatory cleanup — leaving the runner stopped silently disables CI for the whole repo. Only applies on `ci` (htpc has no runner). Check state with `(cd ~/actions-runner && sudo ./svc.sh status)`.
- Never pre-hold boards you are about to run `hil_test.py` on — it self-locks and would treat your own hold as a conflict.
- Rig-wide operations (uhubctl power cycling, pci-rebind — bus renumbering) affect every board: `board_lock.py hold --all --reason "..."` first.
- `board_lock.py status` lists holders. Locks auto-release when the holder process dies (kernel flock); `/tmp` clears on reboot.
- Forcing past a lock: `HIL_NO_BOARD_LOCK=1 python3 test/hil/hil_test.py ...` bypasses the guard without killing the holder. Only with the user's explicit go-ahead — they accept the risk of colliding with whatever holds the board.
## Prerequisites

View File

@ -0,0 +1,42 @@
---
name: pre-pr
description: Use before opening or updating a TinyUSB PR — derives affected boards from the branch diff, runs the full-check workflow (software validation + optional HIL on the rig), and summarizes a ship/no-ship verdict.
---
# /pre-pr — pre-PR validation
Run the software + hardware gate for the current branch. The user invoking this skill is the opt-in for launching the workflows below.
## 1. Scout the diff (inline — no agents)
- `BASE` = `master` unless the user names another base.
- `git diff --name-only $(git merge-base HEAD $BASE)..HEAD`
- If NO C sources changed (only docs / `.claude/` / tools): say so, and run a minimal software-only gate — `boards = [stm32f407disco]`, no HIL — unless the user asks for more.
## 2. Map changes to boards
- For each changed `src/portable/<vendor>/<ip>/` (or `src/portable/<name>/` for single-level ports): families = the `hw/bsp/<family>` directories whose build files reference it — `grep -rl "<vendor>/<ip>" hw/bsp/*/family.cmake hw/bsp/*/family.mk`, then take each matching file's directory name.
- For `src/class/*`, `src/common/*`, `src/device/*`, `src/host/*`, or `src/tusb.c`: broad change — use `stm32f407disco` + `raspberry_pi_pico` PLUS any families from portable changes.
- For `hw/bsp/<family>/...` changes: that family directly.
- Catch-all: any other C/CMake source change (`examples/*`, `test/*`, anything unmatched above) → the representative set `stm32f407disco` + `raspberry_pi_pico`. The boards list must NEVER end up empty — final fallback is `[stm32f407disco]` (full-check throws on an empty list).
- Rig roster: `python3 -c "import json;print([b['name'] for b in json.load(open('test/hil/tinyusb.json'))['boards']])"`
- Pick ONE board per affected family, preferring boards on the rig roster; otherwise the first entry in `hw/bsp/<family>/boards/`. Cap at 4 boards and tell the user which families the cap dropped.
## 3. HIL boards
- `hilBoards` = chosen boards that are on the rig roster. This host must be able to reach the rig (per `.claude/skills/hil/SKILL.md`: host `ci` = local, `htpc` = remote). If none qualify, run software-only.
## 4. Launch
Invoke the Workflow tool:
```
{ name: 'full-check', args: { boards: [...], hilBoards: [...], base: BASE } }
```
## 5. Summarize
- Per-stage table: unit / build:<board> / size / pvs, then HIL per board — pass/fail with the first error for each failure.
- If the hardware result has non-empty `locked` (a CI job held those boards): ask the user with AskUserQuestion — **Force now** (re-invoke `hil-validate` with `force: true` for those boards; user accepts the risk of colliding with a mid-test CI job), **Keep waiting** (re-invoke `hil-validate` for them after a few minutes; ask again if still locked), or **Accept** the partial verdict. Never force without the user's answer.
- Wedged boards: point at `.claude/skills/usb-recover/SKILL.md`.
- End with a clear ship / no-ship verdict and what to fix first.

14
.claude/workflows/check.sh Executable file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Syntax-check a Claude Code workflow script. Workflow bodies are not plain
# ESM (top-level `return` is legal because the runtime wraps them), so wrap
# in an async arrow that declares the runtime globals before parsing.
set -euo pipefail
f="${1:?usage: check.sh <workflow.js>}"
tmp="$(mktemp --suffix=.mjs)"
trap 'rm -f "$tmp"' EXIT
{
echo '(async (args, agent, pipeline, parallel, phase, log, workflow, budget) => {'
sed 's/^export //' "$f"
echo '})'
} > "$tmp"
node --check "$tmp" && echo "OK: $f"

View File

@ -0,0 +1,88 @@
export const meta = {
name: 'driver-review',
description: 'Review driver directories across dimensions with driver-reviewer scanners, then adversarially verify every finding; returns only confirmed findings',
whenToUse: 'Auditing dcd/hcd drivers for a bug class (pass question) or a full-dimension review (default dimensions)',
phases: [
{ title: 'Scan', detail: 'driver-reviewer per (dir x dimension)' },
{ title: 'Verify', detail: 'adversarial refutation per finding' },
],
}
// args: { dirs: string[], dimensions?: string[], question?: string }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !Array.isArray(args.dirs) || args.dirs.length === 0) {
throw new Error('args must be { dirs: string[], dimensions?, question? }')
}
const DIMS = args.question ? [args.question] : (args.dimensions || [
'correctness: transfer state machines, endpoint bookkeeping, completion and error paths',
'ISR safety: work deferred to task context, shared-state races, register access ordering',
'register use vs datasheet and MCU errata: cross-check the reference manual AND errata sheets in $HOME/Documents/calibre-library; a missing erratum workaround is a finding',
'style: repo conventions (TU_ASSERT, no dynamic allocation, include order, naming)',
])
if (!DIMS.length) {
// [] is truthy, so `dimensions: []` would silently review nothing and
// return a verdict indistinguishable from a genuinely clean pass
throw new Error('dimensions resolved to an empty list — pass a non-empty array or omit it for the defaults')
}
const short = (s) => s.replace(/\/+$/, '').split('/').slice(-2).join('/')
const FINDINGS = {
type: 'object', additionalProperties: false,
required: ['scope', 'dimension', 'findings'],
properties: {
scope: { type: 'string' }, dimension: { type: 'string' },
findings: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['file', 'line', 'snippet', 'why', 'severity', 'confidence'],
properties: {
file: { type: 'string' }, line: { type: 'integer' }, snippet: { type: 'string' },
why: { type: 'string' }, severity: { type: 'string' }, confidence: { type: 'string' },
},
},
},
},
}
const VERDICT = {
type: 'object', additionalProperties: false,
required: ['real', 'reason'],
properties: { real: { type: 'boolean' }, reason: { type: 'string' } },
}
const pairs = args.dirs.flatMap(dir => DIMS.map(dim => ({ dir, dim })))
log(`${pairs.length} scan units (${args.dirs.length} dirs x ${DIMS.length} dimensions)`)
const results = await pipeline(
pairs,
p => agent(
`Review ${p.dir} for exactly one dimension: ${p.dim}. Read the sources yourself. Coverage-first — report everything, a verifier filters.`,
{ label: `scan:${short(p.dir)}`, phase: 'Scan', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
),
(scan, p) => {
if (!scan) return null // dead scanner — dropped, counted, and logged below
if (scan.findings.length === 0) return { dir: p.dir, dim: p.dim, findings: [] }
return parallel(scan.findings.map(f => () =>
agent(
`Adversarially verify ONE review finding about ${p.dir}.\nDimension: ${p.dim}\nFinding: ${JSON.stringify(f)}\n` +
'Read the cited code plus enough context (callers, ISR paths, macros, and the datasheet if register-related) to judge. ' +
'Try to REFUTE it; real=true only if it survives your best attempt. Return {"real": bool, "reason": string}.',
{ label: `verify:${short(p.dir)}:${f.line}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: VERDICT },
).then(v => v && { ...f, verdict: v })
)).then(vs => {
const alive = vs.filter(Boolean)
if (alive.length < scan.findings.length) {
log(`${short(p.dir)}: ${scan.findings.length - alive.length} finding(s) lost to dead verifiers — treat as unverified, re-run if needed`)
}
return { dir: p.dir, dim: p.dim, findings: alive.filter(x => x.verdict.real) }
})
},
)
const units = results.filter(Boolean)
if (units.length < pairs.length) log(`${pairs.length - units.length} scan unit(s) dropped (scanner died)`)
const confirmed = units.filter(r => r.findings.length > 0)
log(`${confirmed.length} scan units produced confirmed findings`)
return confirmed

View File

@ -0,0 +1,112 @@
export const meta = {
name: 'fanout-dev',
description: 'Implement one described change across many ports/file-sets: one port-dev worker per item, independent builder verification, optional review',
whenToUse: 'Applying a fix or pattern across multiple TinyUSB ports (e.g. the same DCD bug in several drivers)',
phases: [
{ title: 'Implement', detail: 'port-dev per item (opus xhigh)' },
{ title: 'Verify', detail: 'builder single-example check' },
{ title: 'Review', detail: 'optional driver-reviewer pass' },
],
}
// args: { task: string, items: string[], board?: string | Record<string,string>, review?: boolean, worktree?: boolean }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !args.task || !Array.isArray(args.items) || args.items.length === 0) {
throw new Error('args must be { task: string, items: string[], board?, review?, worktree? }')
}
const boardFor = (item) =>
typeof args.board === 'string' ? args.board : (args.board && args.board[item]) || null
const short = (s) => s.replace(/\/+$/, '').split('/').slice(-2).join('/')
if (args.worktree) log('worktree mode: independent builder verification and review skipped (workers verify inside their own worktrees)')
const DEV = {
type: 'object', additionalProperties: false,
required: ['item', 'diffstat', 'buildOk', 'board', 'notes'],
properties: {
item: { type: 'string' }, diffstat: { type: 'string' }, buildOk: { type: 'boolean' },
board: { type: 'string' }, notes: { type: 'string' },
},
}
const BUILD = {
type: 'object', additionalProperties: false,
required: ['board', 'pass', 'builtCount', 'failures'],
properties: {
board: { type: 'string' }, pass: { type: 'boolean' }, builtCount: { type: 'integer' },
failures: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['example', 'class', 'firstError'],
properties: { example: { type: 'string' }, class: { type: 'string' }, firstError: { type: 'string' } },
},
},
},
}
const FINDINGS = {
type: 'object', additionalProperties: false,
required: ['scope', 'dimension', 'findings'],
properties: {
scope: { type: 'string' }, dimension: { type: 'string' },
findings: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['file', 'line', 'snippet', 'why', 'severity', 'confidence'],
properties: {
file: { type: 'string' }, line: { type: 'integer' }, snippet: { type: 'string' },
why: { type: 'string' }, severity: { type: 'string' }, confidence: { type: 'string' },
},
},
},
},
}
const results = await pipeline(
args.items,
item => agent(
`${args.task}\n\nAssigned scope: ${item} — touch nothing outside it.` +
(boardFor(item)
? ` Verify with board ${boardFor(item)}.`
: ' Pick a verification board from hw/bsp whose family uses this scope.'),
{
label: `dev:${short(item)}`, phase: 'Implement',
agentType: 'port-dev', effort: 'xhigh', schema: DEV,
...(args.worktree ? { isolation: 'worktree' } : {}),
},
),
(dev, item) => {
if (!dev) return null
// worktree mode: edits live in the worker's own worktree; an independent
// verifier in the shared tree cannot see them — trust dev.buildOk.
if (args.worktree) return dev
return agent(
`Build the single example device/cdc_msc for board ${dev.board}. Use a unique build dir (mktemp -d) to avoid collisions with parallel builds.`,
{ label: `verify:${short(item)}`, phase: 'Verify', agentType: 'builder', schema: BUILD },
).then(b => {
// verifyBuild: true/false = real builder verdict; null = builder died
if (!b) log(`verify:${short(item)}: builder agent died — independent verification unknown`)
return { ...dev, verifyBuild: b ? b.pass : null }
})
},
(r, item) => {
if (!r || !args.review || args.worktree) return r
return agent(
`Review the uncommitted change in ${item} (inspect with: git diff -- ${item}) against this task:\n${args.task}\n` +
'Dimension: does the diff correctly and completely implement the task with no unintended side effects? Coverage-first findings.',
{ label: `review:${short(item)}`, phase: 'Review', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
).then(f => {
// review: array = findings; null = reviewer died; absent = not requested
if (!f) log(`review:${short(item)}: reviewer agent died`)
return { ...r, review: f ? f.findings : null }
})
},
)
const done = results.filter(Boolean)
const dropped = args.items.length - done.length
if (dropped > 0) log(`${dropped} item(s) dropped (worker died)`)
log(`${done.length}/${args.items.length} items completed; ${done.filter(r => r.buildOk && r.verifyBuild !== false).length} build-clean`)
return done

View File

@ -0,0 +1,34 @@
export const meta = {
name: 'full-check',
description: 'Composed pre-PR gate: validate (software) then, only if green, hil-validate (hardware)',
whenToUse: 'One-shot pre-PR verdict; usually launched via the /pre-pr skill',
phases: [{ title: 'Software' }, { title: 'Hardware' }],
}
// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[] }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
throw new Error('args must be { boards: string[], hilBoards?, examples?, base?, skip? }')
}
phase('Software')
const software = await workflow('validate', {
boards: args.boards, examples: args.examples, base: args.base, skip: args.skip,
})
if (!software || !software.pass) {
log('software validation failed — skipping HIL')
return { pass: false, software, hardware: null }
}
const hilBoards = args.hilBoards || []
if (hilBoards.length === 0) {
log('no HIL boards requested — software-only verdict')
return { pass: true, software, hardware: null }
}
phase('Hardware')
const hardware = await workflow('hil-validate', { boards: hilBoards })
if (hardware && hardware.locked && hardware.locked.length) {
log(`locked boards pending user decision (force / wait / accept): ${hardware.locked.join(', ')}`)
}
return { pass: !!(hardware && hardware.pass), software, hardware }

View File

@ -0,0 +1,60 @@
export const meta = {
name: 'hil-validate',
description: 'Serialized hardware-in-the-loop run: flash+test each board with hil-operator; per-board flock locks arbitrate with concurrent CI (the actions-runner keeps running)',
whenToUse: 'After validate passes, to exercise built firmware on the physical rig. Requires examples/cmake-build-<board> for each board. If the result has non-empty `locked`, ask the user: force (re-invoke with force: true), continue waiting (re-invoke later), or accept the partial result. Pass force: true ONLY with explicit user authorization.',
phases: [{ title: 'HIL', detail: 'strictly serial per-board hil-operator runs' }],
}
// args: { boards: string[], force?: boolean }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
throw new Error('args must be { boards: string[], force? } with examples/cmake-build-<board> already built')
}
const HIL = {
type: 'object', additionalProperties: false,
required: ['board', 'pass', 'detail', 'wedged'],
properties: {
board: { type: 'string' }, pass: { type: 'boolean' },
detail: { type: 'string' }, wedged: { type: 'boolean' },
},
}
const runBoard = (b) => agent(
`Run the HIL test for board ${b} per .claude/skills/hil/SKILL.md. Do NOT touch the actions-runner service and do NOT pre-hold the board lock — hil_test.py self-locks the board while testing. ` +
(args.force
? 'THE USER HAS EXPLICITLY AUTHORIZED FORCING: run hil_test.py with HIL_NO_BOARD_LOCK=1 in the environment (bypasses the board lock check; do NOT release or kill the existing holder). '
: 'If the run fails because the board lock is held (a dev session or concurrent CI job), report pass=false and set detail to start EXACTLY with "board locked:" followed by the holder JSON verbatim — never force the lock. ') +
'Reserve the phrase "board locked" strictly for lock contention; describe a frozen or non-enumerating board as "unresponsive" instead. ' +
`Firmware is in examples/cmake-build-${b}. Use the config for this host (hostname first), single-board flag -b ${b}, Bash timeout >= 20 min, never cancel early. ` +
'On non-lock failures retry once with -v -r 1 (one verbose attempt for diagnosis — the first run already did the flake-retries). wedged=true if the board/fixture is unresponsive after the run (capture dmesg | tail -50 into detail).',
{ label: `hil:${b}`, phase: 'HIL', agentType: 'hil-operator', schema: HIL },
)
const results = []
for (const b of args.boards) {
const r = await runBoard(b)
results.push(r || { board: b, pass: false, detail: 'hil-operator agent died', wedged: false })
log(`${b}: ${results[results.length - 1].pass ? 'PASS' : 'FAIL'}`)
}
// A concurrent CI job may have held some boards (its hil_test.py flock).
// CI finishes a board in minutes — retry locked boards once, at the end.
if (!args.force) {
for (let i = 0; i < results.length; i++) {
if (results[i].pass || !results[i].detail.startsWith('board locked')) continue
log(`${results[i].board}: was locked — retrying once`)
const r = await runBoard(results[i].board)
if (r) results[i] = r
else results[i].detail += ' (retry operator died)'
log(`${results[i].board}: retry ${results[i].pass ? 'PASS' : 'FAIL'}`)
}
}
const wedged = results.filter(r => r.wedged).map(r => r.board)
if (wedged.length) log(`WEDGED boards needing usb-recover: ${wedged.join(', ')}`)
// Workers cannot prompt the user — surface still-locked boards for the main
// session to ask: force (re-invoke with force: true), wait, or accept.
const locked = args.force ? [] : results.filter(r => !r.pass && r.detail.startsWith('board locked')).map(r => r.board)
if (locked.length) log(`still locked after retry: ${locked.join(', ')} — ask the user: force / keep waiting / accept`)
return { pass: results.every(r => r.pass), results, wedged, locked }

View File

@ -0,0 +1,226 @@
export const meta = {
name: 'pr-babysit',
description: 'Drive a PR to green: pr-monitor triage (CI + bot reviews), port-dev fixes for validated findings, driver-reviewer verification, one commit+push per cycle',
whenToUse: 'After opening a PR, from a checkout of the PR branch. Default is a dry run (fixes left uncommitted, nothing posted); passing autoPush: true is the explicit authorization for pushes and PR comments.',
phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
}
// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run) }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !args.pr) {
throw new Error('args must be { pr: number, maxCycles?, autoPush? }; run from a checkout of the PR branch')
}
args.pr = Number(args.pr)
if (!Number.isInteger(args.pr) || args.pr <= 0) {
throw new Error('args.pr must be a positive integer PR number')
}
const maxCycles = args.maxCycles ?? 3
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new Error('maxCycles must be an integer >= 1')
}
const TRIAGE = {
type: 'object', additionalProperties: false,
required: ['ci', 'findings', 'replies', 'done'],
properties: {
ci: {
type: 'object', additionalProperties: false,
required: ['status', 'infraRerun', 'realFailures'],
properties: {
status: { type: 'string', enum: ['green', 'red', 'running'] },
infraRerun: { type: 'array', items: { type: 'string' } },
realFailures: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['check', 'firstError', 'files'],
properties: {
check: { type: 'string' }, firstError: { type: 'string' },
files: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
findings: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['source', 'commentId', 'file', 'line', 'claim', 'verdict', 'reason', 'fixHint'],
properties: {
source: { type: 'string' }, commentId: { type: 'integer' },
file: { type: 'string' }, line: { type: 'integer' }, claim: { type: 'string' },
verdict: { type: 'string', enum: ['valid', 'invalid', 'stale'] },
reason: { type: 'string' }, fixHint: { type: 'string' },
},
},
},
replies: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['commentId', 'body'],
properties: { commentId: { type: 'integer' }, body: { type: 'string' } },
},
},
done: { type: 'boolean' },
},
}
const DEV = {
type: 'object', additionalProperties: false,
required: ['item', 'diffstat', 'buildOk', 'board', 'notes'],
properties: {
item: { type: 'string' }, diffstat: { type: 'string' }, buildOk: { type: 'boolean' },
board: { type: 'string' }, notes: { type: 'string' },
},
}
const CHECK = {
type: 'object', additionalProperties: false,
required: ['addresses', 'reason'],
properties: { addresses: { type: 'boolean' }, reason: { type: 'string' } },
}
const OP = {
type: 'object', additionalProperties: false,
required: ['pass', 'detail'],
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
}
// Marking a review thread resolved has no REST endpoint — it needs the
// GraphQL resolveReviewThread mutation. Shared recipe handed to the posting
// agents so a fixed/refuted comment ends up both answered AND resolved.
const RESOLVE_RECIPE =
'To resolve the review thread for an inline review comment (its integer databaseId is the commentId): ' +
'get owner/repo via `gh repo view --json nameWithOwner -q .nameWithOwner`; find the thread node id with ' +
'`gh api graphql -f query=\'query($o:String!,$r:String!,$p:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$p){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:50){nodes{databaseId}}}}}}}\' -F o=OWNER -F r=REPO -F p=' + args.pr + '` ' +
'(while hasNextPage is true and the comment is not found yet, re-run with -F c=<endCursor>), pick the thread whose comments contain that databaseId, then resolve it with ' +
'`gh api graphql -f query=\'mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}\' -F id=THREAD_ID`. ' +
'Issue comments (the 404 fallback case) have no thread — do not try to resolve those.'
// Mechanical reply skeleton shared by the refuted-replies and fixed-resolve
// steps — kept in one place because the two copies drifted once already
// (the 404 fallback was missing from one of them).
const postReplyRecipe = (noun) =>
`post a threaded reply to its inline comment via gh api repos/{owner}/{repo}/pulls/${args.pr}/comments/{commentId}/replies -f body=<body> ` +
'(valid for inline review comments); if that 404s, the id is an issue comment — post a regular PR comment instead ' +
`(gh pr comment ${args.pr} --body <quote the original point, then the ${noun}>) and skip resolving. ` +
`After replying to an inline comment, mark its thread resolved. ${RESOLVE_RECIPE} `
const history = []
const repliedIds = new Set() // issue comments can't be thread-resolved, so they re-harvest every cycle — never reply twice
for (let cycle = 1; cycle <= maxCycles; cycle++) {
const t = await agent(
`Triage PR #${args.pr}. If checks are still running, wait for them first (gh pr checks ${args.pr} --watch, Bash timeout >= 30 min). ` +
'Then follow your triage procedure: classify CI failures, re-run infra ones, harvest and adversarially validate bot review findings, draft replies for invalid/stale ones.',
{ label: `triage#${cycle}`, phase: 'Triage', agentType: 'pr-monitor', schema: TRIAGE },
)
if (!t) {
history.push({ cycle, error: 'pr-monitor died' })
return { pass: false, cycles: cycle, history, reason: 'pr-monitor-died' }
}
const entry = { cycle, triage: t }
history.push(entry)
// Post drafted replies to REFUTED findings as soon as triage produces them —
// decoupled from fixing/pushing so done/unactionable cycles still post.
// Reply AND resolve the thread. Outward-facing, so gated on autoPush.
const freshReplies = t.replies.filter(r => !repliedIds.has(r.commentId))
if (freshReplies.length > 0 && args.autoPush === true) {
const posted = await agent(
`Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
`Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where.`,
{ label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
)
// attempted counts as replied: better to drop a failed reply than spam duplicates
freshReplies.forEach(r => repliedIds.add(r.commentId))
if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
}
if (t.done) {
log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
return { pass: true, cycles: cycle, history }
}
// Group actionable work by top-level scope (plain JS — no model tokens).
const groups = new Map()
const groupOf = (key) => {
if (!groups.has(key)) groups.set(key, { key, files: new Set(), notes: [] })
return groups.get(key)
}
for (const f of t.findings.filter(x => x.verdict === 'valid')) {
const g = groupOf(f.file.split('/').slice(0, 3).join('/'))
g.files.add(f.file)
g.notes.push(`${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`)
}
for (const rf of t.ci.realFailures) {
const g = groupOf((rf.files[0] || rf.check).split('/').slice(0, 3).join('/'))
rf.files.forEach(x => g.files.add(x))
g.notes.push(`CI ${rf.check}: ${rf.firstError}`)
}
const work = [...groups.values()]
if (work.length === 0) {
if (t.ci.status === 'running' || t.ci.infraRerun.length > 0) {
log(`cycle ${cycle}: only infra re-runs in flight — next cycle waits on them`)
continue
}
log(`cycle ${cycle}: nothing actionable`)
return { pass: false, cycles: cycle, history, reason: 'unactionable' }
}
const fixes = await pipeline(
work,
w => agent(
`Fix the following issues on the current PR branch (the working tree IS the PR checkout).\n` +
`Scope: ${[...w.files].join(', ')}\nIssues:\n- ${w.notes.join('\n- ')}`,
{ label: `fix:${w.key}`, phase: 'Fix', agentType: 'port-dev', effort: 'xhigh', schema: DEV },
),
(fix, w) => fix && agent(
`Verify the uncommitted changes for ${[...w.files].join(', ')} (use git diff -- <files>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
'Return {"addresses": bool, "reason": string}.',
{ label: `check:${w.key}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: CHECK },
).then(v => ({ ...fix, addresses: !!(v && v.addresses), checkReason: v ? v.reason : 'verifier died' })),
)
const aliveFixes = fixes.filter(Boolean)
if (aliveFixes.length < work.length) log(`${work.length - aliveFixes.length} fix group(s) lost to dead workers`)
entry.fixes = aliveFixes
if (args.autoPush !== true) {
log('autoPush not set: fixes left uncommitted in the working tree (dry run)')
return { pass: false, cycles: cycle, history, dryRun: true }
}
// Verification gates the push: never push a cycle containing an unverified
// fix or the partial edits of a dead worker.
const unverified = aliveFixes.filter(f => f.addresses !== true)
if (aliveFixes.length < work.length || unverified.length > 0) {
for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
log(`cycle ${cycle}: fixes left uncommitted for human review — not pushing unverified changes`)
return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
}
const push = await agent(
`On the current PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} fixes for PR #${args.pr}, repo commit conventions), ` +
"then push to the PR's remote branch. pass=true only if commit AND push succeeded; detail = pushed SHA.",
{ label: `push#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
)
if (!push || !push.pass) {
log(`cycle ${cycle}: push failed — stopping`)
return { pass: false, cycles: cycle, history, reason: 'push-failed' }
}
// The valid bot findings were fixed and pushed — answer each inline comment
// with what changed and resolve its thread. CI-failure work has no comment.
const fixed = t.findings.filter(x => x.verdict === 'valid')
if (fixed.length > 0) {
const resolved = await agent(
`The fixes for PR #${args.pr}'s valid review findings were just committed and pushed (${push.detail}). ` +
`For each finding below: ${postReplyRecipe('fix note')}` +
'Each reply states the finding is fixed in the pushed commit, with one line on the change. ' +
`Findings: ${JSON.stringify(fixed.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
'pass=true only if every reply was posted and every thread resolved; detail = what went where.',
{ label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
)
if (!resolved || !resolved.pass) log(`cycle ${cycle}: fixed reply/resolve incomplete — ${resolved ? resolved.detail : 'agent died'}`)
}
}
return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' }

View File

@ -0,0 +1,100 @@
export const meta = {
name: 'validate',
description: 'Pre-PR software validation: unit tests + per-board build sweeps + code-size compare + PVS, in parallel, joined into one verdict',
whenToUse: 'Before opening or updating a PR, after any non-trivial change',
phases: [{ title: 'Validate', detail: 'unit + builds + size + pvs in parallel' }],
}
// args: { boards: string[], examples?: string, base?: string, skip?: ('unit'|'size'|'pvs')[] }
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
throw new Error('args must be { boards: string[], examples?, base?, skip? }')
}
const skip = args.skip || []
for (const s of skip) log(`stage skipped by request: ${s}`)
const base = args.base || 'master'
const clip = (s, n = 800) =>
s.length > n ? s.slice(0, n) + ` …[truncated ${s.length - n} chars]` : s
const STAGE = {
type: 'object', additionalProperties: false,
required: ['pass', 'detail'],
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
}
const BUILD = {
type: 'object', additionalProperties: false,
required: ['board', 'pass', 'builtCount', 'failures'],
properties: {
board: { type: 'string' }, pass: { type: 'boolean' }, builtCount: { type: 'integer' },
failures: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['example', 'class', 'firstError'],
properties: { example: { type: 'string' }, class: { type: 'string' }, firstError: { type: 'string' } },
},
},
},
}
const PVS = {
type: 'object', additionalProperties: false,
required: ['pass', 'ga1', 'ga2', 'changedFindings', 'detail'],
properties: {
pass: { type: 'boolean' }, ga1: { type: 'integer' }, ga2: { type: 'integer' },
changedFindings: {
type: 'array',
items: {
type: 'object', additionalProperties: false,
required: ['file', 'line', 'rule', 'level', 'message'],
properties: {
file: { type: 'string' }, line: { type: 'integer' }, rule: { type: 'string' },
level: { type: 'integer' }, message: { type: 'string' },
},
},
},
detail: { type: 'string' },
},
}
const thunks = []
if (!skip.includes('unit')) thunks.push(() =>
agent(
'Run the TinyUSB unit tests: cd test/unit-test && ceedling test:all. ' +
'pass=true only if every test passes. detail = the ceedling summary line, or the first failing test output.',
{ label: 'unit', phase: 'Validate', model: 'haiku', schema: STAGE },
).then(r => r && { stage: 'unit', ...r }))
for (const b of args.boards) thunks.push(() =>
agent(
`Build TinyUSB examples for board ${b}` + (args.examples ? ` (only: ${args.examples})` : ' (full example set)') + '.',
{ label: `build:${b}`, phase: 'Validate', agentType: 'builder', schema: BUILD },
).then(r => r && {
stage: `build:${b}`, pass: r.pass,
detail: r.pass ? `${r.builtCount} examples built` : clip(JSON.stringify(r.failures)),
}))
if (!skip.includes('size')) thunks.push(() =>
agent(
`Compare TinyUSB code size against ${base}: python3 tools/metrics_compare_base.py --base-branch ${base} -b ${args.boards[0]} -e device/cdc_msc (exactly this command — no extra positional args). ` +
'The report lands in cmake-metrics/<board>/metrics_compare.md. pass=false only if the tool itself errors; ' +
'detail = the flash/RAM delta summary from the report (mention any example that grew).',
{ label: 'size', phase: 'Validate', model: 'haiku', schema: STAGE },
).then(r => r && { stage: 'size', ...r }))
if (!skip.includes('pvs')) thunks.push(() =>
agent(
`Run PVS-Studio static analysis for board ${args.boards[0]}, gating on files changed vs ${base}. ` +
'Parallel build agents are running — use your dedicated build dir, never cmake-build-<board>.',
{ label: 'pvs', phase: 'Validate', agentType: 'static-analyzer', effort: 'low', schema: PVS },
).then(r => r && {
stage: 'pvs', pass: r.pass,
detail: r.pass ? r.detail : clip(`${r.detail} ${JSON.stringify(r.changedFindings)}`),
}))
const results = (await parallel(thunks)).filter(Boolean)
const dead = thunks.length - results.length
if (dead > 0) log(`${dead} stage agent(s) died — counted as failures`)
const failures = results.filter(r => !r.pass)
log(`${results.length}/${thunks.length} stages completed, ${failures.length} failing`)
return { pass: failures.length === 0 && dead === 0, stages: results, failures }

View File

@ -12,6 +12,7 @@ Bias toward caution over speed. For trivial tasks, use judgment.
- **Simplicity** — no features, abstractions, flexibility, or error handling beyond what was asked. If 200 lines could be 50, rewrite.
- **Surgical changes** — touch only what the task requires; match existing style; don't refactor working code; mention unrelated dead code rather than deleting it. Remove only orphans *your* changes created.
- **Goal-driven** — turn tasks into verifiable goals ("write failing test, make it pass"). For multi-step work, state a brief `step → verify` plan.
- **Worktrees** — default to a git worktree (`git worktree add`) for any branch or multi-step work; never switch the shared primary checkout's branch. Sessions run concurrently: switching the primary checkout mid-flight disrupts other sessions and can silently point a review, build, or commit at the wrong diff. Only trivial one-shot fixes may skip this.
## Ground Rules

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
# Smoke Test Results — multi-agent dev/test harness
Date: 2026-07-09 (evening session)
## Task 13 — validate workflow (run wf_4f10863f-6ff, 2 boards)
| Stage | Result | Evidence |
|---|---|---|
| unit | PASS | ceedling 61/61, 2.72 s |
| size | PASS | stm32f407disco device/cdc_msc vs master: TOTAL 14167 B, +0.0% every file (`cmake-metrics/stm32f407disco/metrics_compare_device_cdc_msc.md`) |
| pvs | pass=false — **gate working as specified** | GA:1=20 / GA:2=36; flags 5 GA:1 in files changed vs local `master` (midi2_device.c:245 V547 branch-new upstream; usbd.c V763 x3 + usbh.c:2109 V1008 pre-existing lines in touched files). Root cause of "changed" set: local `master` lags `origin/master` (upstream MIDI2 merge), so upstream churn counts as changed. Logic per spec. |
| build:stm32f407disco | BLOCKED (env) | `agent type 'builder' not found` — see registry note below |
| build:raspberry_pi_pico | BLOCKED (env) | same |
Resume: `Workflow({scriptPath: '<worktree>/.claude/workflows/validate.js', resumeFromRunId: 'wf_4f10863f-6ff', args: {boards: [...]}})` — unit/size/pvs replay from cache.
## Task 15 — driver-review workflow (run wf_aa86dddc-11f) — PASS
- 2 dirs x 1 question (unbounded busy-waits), 22 agents (2 scanners + 20 verifiers), 0 errors, ~494k worker tokens.
- rusb2: 6 confirmed findings — the FRDY spin `dcd_rusb2.c:126` (matches the known hardware wedge) + CURPIPE spins (125, 249, 292, 477, ...) + CFIFO ISEL spin (347), each adversarially verified against code; one verifier corrected a scanner's claim that the Renesas manuals were missing from calibre (RA6M5/RX65N are present).
- Verification layer demonstrably filters and grades (severity/confidence preserved, refutation reasoning recorded).
## Harness facts discovered (affect all future sessions)
1. **Custom agent types register at session start, from the LAUNCH directory's `.claude/agents/`** — not the worktree's, and not on file changes mid-session. Runtime copies were mirrored to `/home/hathach/code/tinyusb/.claude/agents/` (untracked); a NEW session is required for builder/port-dev/hil-operator to resolve.
2. **Workflow `args` arrives as a JSON string** — all six scripts normalize with `if (typeof args === 'string') args = JSON.parse(args)`.
3. **Workflow-by-name resolution can serve a stale cached script** — invoke via `scriptPath` when iterating.
## Task 13 completion — PASS
Resumed run wf_4f10863f-6ff once agents registered: build:stm32f407disco 43 examples, build:raspberry_pi_pico 43 examples, both PASS; verdict mechanics correct (pass=false only from the pvs stage's stale-local-master base; re-run vs origin/master in Task 17 is fully green).
## Task 14 — fanout-dev — PASS (wf_d2fcc566-ebc)
2 port-dev workers (rp2040, stm32_fsdev): exactly 1-line diffs, `git clang-format` clean, both independently build-verified (`verifyBuild: true`), deps self-healed (get_deps stm32f0; PICO_SDK_PATH). Smoke edits reverted; tree clean.
## Task 16 — hil-validate + board locks — PASS mechanics (real rig)
- Lock-conflict run (wf_cab1ac1d-c4c): failed in 48 s, `detail` = `board locked: {holder JSON}` verbatim, `locked: ['raspberry_pi_pico']`, retry attempted, no flash.
- Force run (wf_d079a33e-dd8): flashed+tested with HIL_NO_BOARD_LOCK=1, holder pid survived (bypass, not theft), `locked: []`.
- Normal run (wf_87ec3c8f-e87): self-locking path live, board flashed/booted 18x, no lock messages.
- actions-runner `active` the entire time; svc.sh never touched.
- RIG FINDING (not this branch): pico PIO-USB host-port fixture devices (1a86_7523 CDC, 048d_04d2 MSC) not enumerating — 5 host-mode tests fail identically across runs; 13 device-mode tests all pass; firmware exonerated.
## Task 17 — /pre-pr end-to-end — PASS (wf_8a421ef1-fcf)
BASE=origin/master (local master stale). No C changes → minimal path: software-only, boards=[stm32f407disco]. Verdict `pass: true`: unit 61/61, 43 examples, size +0.0%, pvs green (no C diffs). HIL correctly skipped.
## Task 18 — pr-babysit dry + pr-monitor triage — launched
pr-babysit {pr: 3761, maxCycles: 1, autoPush: false} (wf_361c9e0a-d0e) + direct pr-monitor triage of PR 3750 (3 Copilot rounds) from a /tmp checkout of the PR head. Results recorded when complete.
## Post-smoke revisions
- Model tiering (owner): builder→haiku, hil-operator/pr-monitor→sonnet, unit/size→haiku, pvs/push/replies→sonnet; port-dev/driver-reviewer stay opus xhigh.
- driver-reviewer now checks MCU errata sheets; missing erratum workarounds are findings.
- Remaining: Task 18 verdicts, final whole-branch review, memory note update for the lock protocol.
Stop-gate extra (done this session): board_lock `cmd_hold` holder-signaled success via pipe (c326eaacc), storm-tested 10/10 exactly-one-winner.
## Post-review fixes (2026-07-10, owner-confirmed batch)
- board_lock: holder daemon detaches stdio (captured `hold` returned in 30 ms; pre-fix hung on the inherited pipe); `is_locked()` re-done as pid-liveness probe — never touches the flock, so status/pre-check storms can no longer fail a concurrent acquirer (0/15 failures under a 300-probe storm; hold storm still 1-winner-in-10).
- hil_test: locked board now emits a visible `board-locked` ❌ report row (report matches exit code); `accumulate_report` clears the stale marker once the board runs for real (3-scenario logic test green). failed-tests stays empty so re-runs repeat the whole board.
- pr-babysit: `autoPush` now opt-in (default dry run; `autoPush: true` is the explicit push/comment authorization); RESOLVE_RECIPE paginates reviewThreads (`pageInfo` + cursor); post-push resolve step gained the same issue-comment 404 fallback as the refuted-replies step.
- validate: size stage passes `--base-branch <base>`; pvs stage now calls the new `static-analyzer` agent (sonnet, structured `{pass, ga1, ga2, changedFindings[], detail}`).
- NEW agent `static-analyzer` (PVS-Studio SAST+MISRA, read-only) — mirrored to the launch-dir registry; registers next session (harness fact 1), so the validate pvs stage is unsmokable until then.
## Max-effort review fixes (2026-07-13, /code-review opus max: 10 finders → 28 verifiers → sweep)
- validate.js size stage: dropped the stray trailing `.` that made metrics_compare_base.py exit 2 on every run (regression from the 2026-07-10 batch).
- board_lock: flock is now the sole authority — cmd_hold's pid-liveness pre-gate removed (a live-but-moved-on hil_test.py worker pid no longer blocks a free board; verified: hold succeeds over a stale live-pid record, storm still 1-winner-in-10). cmd_release probes the flock before acting: free → clear stale record only; held by `hil_test.py` → refuse (CI mid-test, holder survives — verified); held otherwise → SIGTERM with PermissionError handled. Holder daemon truncates records on SIGTERM; success pipe dup'd above fd 2 (closed-stdio hold now succeeds — was orphan-holder + false failure, repro'd both ways).
- hil_test: lock record truncated on per-board release (pool workers outlive flocks); fail-open on OSError now prints a warning; unknown `-b` names exit 1 instead of a silent zero-test green (was exploitable as a false HIL pass through hil-validate); accumulate_report deletes an emptied board row (variant boards no longer leave a blank ghost row — 4-scenario test green).
- port-dev.md: `git clang-format -- <files>` scoped to the worker's own files (bare invocation reformatted concurrent siblings' edits in shared checkouts).
- pr-babysit: reply skeleton factored into postReplyRecipe (the two copies had already drifted once); cross-cycle repliedIds dedup (issue-comment refutations were re-posted every cycle); args.pr integer + maxCycles >= 1 validation.
- All 6 workflows: JSON.parse(args) wrapped so a non-JSON string hits the friendly shape error; driver-review throws on empty dimensions ([] is truthy); hil-validate dead length-clause dropped; retry instruction now `-v -r 1` (diagnosis, not 3 more flake-retries).
- Plan doc header replaced with a DO-NOT-EXECUTE historical banner + all 74 boxes checked (re-execution would have recreated pre-static-analyzer files with hooks disabled).
- Refuted by verification (left as-is by design): dry-run verify spawns (consumed via history), per-finding verifiers, 4-dim scanners, fanout double-build, validate parallel triple-compile, release --all / hold --config, board_lock import into hil_test (hil_ci.sh ships hil_test.py alone), lock exit code (JSON sidecar already carries board-locked), check.sh scope, effort scatter (harness-forced), local fcntl import (Windows guard).

View File

@ -0,0 +1,172 @@
# Multi-Agent Dev/Test Setup for TinyUSB — Design
Date: 2026-07-09
Branch: worktree-claude-agents-workflows
## Goal
Give Claude Code sessions in this repo a reusable, efficient multi-agent harness
for developing and testing TinyUSB: custom worker agents that already know the
repo's build/test/rig discipline, and small deterministic workflows that fan
them out. The orchestrator (main session) authors arguments and reads verdicts;
workers do the volume.
## Context
- Existing process skills: `hil`, `code-size`, `pvs`, `build-doc`, `usbmon`,
`usb-debug`, `usb-recover`, `make-release` (`.claude/skills/`).
- One prototype workflow exists in the master working tree (untracked):
`.claude/workflows/port-audit.js`. This design supersedes it.
- No custom agent definitions exist yet (`.claude/agents/` absent).
- Test infra: `test/unit-test` (ceedling), `test/hil` (`hil_test.py`,
`tinyusb.json`), `test/fuzz`; size metrics via
`tools/metrics_compare_base.py`.
## Architecture
Layered: **agents** (who does the work, with baked-in domain knowledge) ×
**workflows** (deterministic fan-out/join) × **one skill** (human entry point).
### Worker agents — `.claude/agents/*.md`
Tiered models (owner revision 2026-07-09; originally all-opus): `port-dev`
and `driver-reviewer` on **opus** at **xhigh**; `hil-operator`, `pr-monitor`
and `static-analyzer` on **sonnet**; `builder` on **haiku** (mechanical,
log-heavy).
| Agent | Effort | Role |
|---|---|---|
| `builder` | low | Build one board's example set with the canonical commands: `cmake -B cmake-build-<board> -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel` from `examples/` (exact dir name — HIL expects it), `python3 tools/get_deps.py` on missing deps, `. $HOME/code/esp-idf/export.sh` for Espressif boards, tolerate non-critical objcopy failures. Returns structured `{board, pass, failures: [{example, firstError}]}`. |
| `port-dev` | xhigh | Implement one well-scoped change in one port / file set. Follows repo rules: C99, 2-space indent, snake_case, `TU_ASSERT`, no dynamic allocation, ISR work deferred to task context. Runs `clang-format` (repo `.clang-format`) on touched files before finishing. Cross-checks the MCU datasheet in `$HOME/Documents/calibre-library` when changing dcd/hcd register logic. Verifies with a targeted build of one board using the port. Returns `{item, diffstat, buildOk, notes}`. |
| `driver-reviewer` | xhigh | Review one dcd/hcd directory against dimensions: correctness, ISR safety, register use vs. datasheet AND MCU errata (calibre library; missing erratum workarounds are findings), style. Returns structured findings `{file, line, snippet, why, severity, confidence}` — coverage-first (report everything; filtering happens downstream). |
| `hil-operator` | default | All rig interaction — the actions-runner service is NEVER stopped; per-board flock locks arbitrate with concurrent CI. `hil_test.py` runs rely on its per-board self-locking; manual hardware work (JLink/GDB, usbtest, serial) is wrapped in `test/hil/board_lock.py hold/release`; rig-wide ops (uhubctl, pci-rebind) require `hold --all`; on wedge `usb_recover.sh` + dmesg. Used strictly serially — never two instances concurrently. |
| `pr-monitor` | default | Triage one GitHub PR via `gh`: check CI status (`gh pr checks`), read failing run logs and classify each failure infra/flake vs real; re-run infra failures (`gh run rerun --failed`); harvest automated review comments (Codex/Copilot/Claude bots — knows their signals: Codex posts a "Didn't find any major issues" issue comment when clean; Copilot drops out of `requested_reviewers` when done; bot logins differ across APIs); adversarially validate each finding against the actual code. Returns structured triage `{ci: {status, infraRerun[], realFailures[]}, findings: [{source, file, line, claim, verdict, fixHint}]}`. Read/triage/re-run/reply only — never edits code. |
| `static-analyzer` | low | Run PVS-Studio (SAST + MISRA C:2023/C++:2008) for one board: build with exported `compile_commands.json` (via `run_pvs.sh` solo, or a dedicated `cmake-build-pvs` dir when parallel builders run), analyze against `.PVS-Studio/.pvsconfig`, gate on diagnostics in files changed vs a base ref. Returns `{pass, ga1, ga2, changedFindings[], detail}`; `pass=false` only on GA:1 in changed files or tool failure. Read-only. |
### Workflows — `.claude/workflows/*.js`
| Workflow | Args | Shape |
|---|---|---|
| `validate.js` | `{boards[], examples?, base?, skip?: ('unit'\|'size'\|'pvs')[]}` | One parallel stage: unit tests (ceedling) + one `builder` per board + code-size compare (`tools/metrics_compare_base.py` vs `base`, default master) + PVS analyze (`static-analyzer` agent). Join → plain-JS verdict `{pass, failures[]}`. Barrier is correct here: the verdict needs all results. |
| `fanout-dev.js` | `{task, items[], board?, review?, worktree?}` | `pipeline(items)`: `port-dev` per item → `builder` verify → optional `driver-reviewer` pass. Workers share the tree by default (ports are disjoint directories); `worktree: true` switches on per-agent worktree isolation for collision-prone tasks. Returns per-item results. |
| `driver-review.js` | `{dirs[], dimensions?, question?}` | Supersedes `port-audit.js`. Scan stage per (dir × dimension) → adversarial verify per finding (verifier prompted to refute) → confirmed findings only. |
| `hil-validate.js` | `{boards[], force?}` | Strictly serial `for` loop of `hil-operator` calls; each board is protected by `hil_test.py`'s own per-board flock, so the actions-runner keeps running throughout. Boards found locked (a concurrent CI job mid-test) are retried once at the end of the loop; boards still locked are returned in `locked[]` for a user force/wait/accept decision. `force: true` (user-authorized only) bypasses locks via `HIL_NO_BOARD_LOCK=1`. Returns per-board `{board, pass, detail}` plus `wedged[]` and `locked[]`. |
| `full-check.js` | `{boards[], ...}` | Thin composer: `workflow('validate', ...)` → only if green → `workflow('hil-validate', ...)`. Single nesting level (children do not nest further). |
| `pr-babysit.js` | `{pr, maxCycles?, autoPush?}` | Cycle until CI green + review threads resolved, or `maxCycles` (default 3): `pr-monitor` triage (blocks on `gh pr checks --watch` while CI runs) → valid findings + real CI failures grouped by file/port → `port-dev` fix per group (pipeline) → `driver-reviewer` verifies each fix addresses its finding → one commit + push per cycle. Every actioned inline comment is both **replied to and marked resolved** (GraphQL `resolveReviewThread`): refuted findings get the refutation, fixed findings get a "fixed in <sha>" note. `autoPush` defaults **false** (dry run: fixes stay uncommitted, nothing posted); **passing `autoPush: true` is the explicit push authorization** for follow-up commits and PR comments on that branch (scoped exception to the hold-pushes-until-told rule). |
### Board lock protocol — `test/hil/` (repo code)
CI and dev sessions share the rig concurrently; the actions-runner service is
never stopped. Arbitration is per-board kernel flocks in
`/tmp/tinyusb-hil-locks/<board>.lock` — auto-released when the holder process
dies, with holders truncating their lock-file record on release so records
stay truthful (`/tmp` clears on reboot):
- **`test/hil/board_lock.py`** (new tool): `hold <boards|--all> --reason TEXT`
spawns a background holder process flocking each board file (JSON
`{pid, reason, since}` written inside for debuggability); the holder's own
LOCK_NB flock is the sole authority — there is deliberately no pid-based
pre-check (recorded pids can be stale or recycled). `release <boards|--all>`
probes each board's flock: a free lock only gets its stale record cleared;
a genuinely held one gets its recorded holder SIGTERMed — unless the holder
reason is `hil_test.py` (a CI run mid-test), which release refuses to kill.
`status` lists holders. `--all` is required before rig-wide operations
(uhubctl power cycling, pci-rebind — bus renumbering affects every board).
- **`hil_test.py` guard** (small patch to the per-board worker): take the
board's flock non-blocking before flashing and hold it for that board's
flash+test; on acquire it writes its own holder info
(`{pid, reason: "hil_test.py", since}`) so conflicts report truthfully in
both directions, and truncates that record on release (the pool worker
outlives the per-board flock). If already held, FAIL the board immediately —
`Failed: board locked: <holder info>` — no flash, no waiting.
The CI job fails visibly for exactly those boards and `re-run failed`
passes once the lock is released (`build.yml` already retries the HIL step
once, absorbing short dev sessions). Guard proceeds unlocked — with a
printed warning — if the lock dir is unusable, and `-b` names absent from
the config are a hard error rather than a silent zero-test green run.
- **Re-entrancy rule:** dev sessions do NOT pre-hold boards they are about to
run `hil_test.py` on (it self-locks; pre-holding deadlocks it).
`board_lock.py hold` is for hardware work outside `hil_test.py` only.
- **Symmetric conflicts (CI running while an agent tests):** CI mid-test on a
board holds that board's flock, so the dev side hits it — `board_lock.py
hold` refuses showing the holder, and a dev `hil_test.py` run fails that
board fast. Two sessions can never double-flash a board.
`hil-validate.js` retries locked boards once at the end of its loop (CI
finishes a board in minutes); manual sessions wait and retry when the
holder reason is `hil_test.py`. Concurrent activity on *different* boards
is normal — CI's own `hil_test.py` already runs boards in parallel via
`multiprocessing.Pool`.
- **User decision on persistent locks:** workers cannot prompt the user, so
boards still locked after the retry are returned in `locked[]`; the main
session then asks the user — **force** (re-invoke `hil-validate` with
`force: true`, which runs `hil_test.py` with `HIL_NO_BOARD_LOCK=1`: a
bypass, never killing the holder, at the user-accepted risk of colliding
with a mid-test CI job), **continue waiting** (re-invoke later), or
**accept** the partial result. Workers never force on their own; forcing
requires the user's explicit authorization relayed in the prompt.
- **Propagation caveat:** CI enforces the guard only once the patch lands on
master (CI runs `hil_test.py` from each PR's merge-with-master ref). This
also widens blast radius beyond `.claude/` config into shared CI test
infra — the change is a small isolated guard, but it needs its own CI pass
and careful review on the eventual PR.
### Entry point — `.claude/skills/pre-pr/SKILL.md`
`/pre-pr` instructs the session to: scout the diff inline (cheap), map changed
`src/portable/<vendor>/<ip>` and `src/class/*` to affected families and pick
test boards from `hw/bsp` (fallback: `stm32f407disco`, `raspberry_pi_pico`),
launch `full-check` with that board list, and summarize the verdict. Markdown
carries the judgment; JS carries the orchestration.
## Model & effort policy
- Tiered worker models: `port-dev`/`driver-reviewer` **opus** `xhigh`;
`hil-operator`/`pr-monitor` **sonnet**; `builder` **haiku**.
- Inline workflow stages: unit/size **haiku**; pvs **sonnet** (low effort);
pr-babysit push/replies **sonnet**.
- Agent frontmatter `model:` is canonical for `agentType` calls; it is read
at session-start registration, so tier changes apply from the next session.
- Deterministic control flow (loops, joins, filtering, verdict assembly) is
plain JS in the workflow scripts — zero model tokens.
- The orchestrating session derives work lists inline (glob/grep) and passes
them as `args`; worker prompts carry paths, not file contents.
## Error handling
- Workers always return schema-validated structured output; validation retries
happen at the tool-call layer.
- Workflows `filter(Boolean)` for killed agents and `log()` every skipped or
dropped item — no silent truncation.
- `validate.js` reports partial results (a failed stage becomes a failure entry,
not a thrown error).
- `hil-validate.js`: board access arbitrated by `hil_test.py` self-locking; a
locked board fails fast with the holder info (never forced); wedged boards
reported, not retried blindly.
## Success criteria
Each piece smoke-tested on a minimal real scope before the branch is done:
1. `validate.js` on `stm32f407disco` + `raspberry_pi_pico` (real build, unit
tests, size compare, PVS) returns a correct verdict object.
2. `fanout-dev.js` on a trivial 2-port task on this branch; diffs build clean
and are `.clang-format`-clean.
3. `driver-review.js` on 2 driver dirs returns only verified findings.
4. `hil-validate.js` on 1 board against the real rig with the actions-runner
ACTIVE throughout: while a `board_lock.py` hold is in place the run fails
fast citing the holder and returns the board in `locked[]`; with the lock
still held, `force: true` proceeds (user-authorized bypass); after
release a normal run passes; a direct `hil_test.py` run under a held lock
fails that board without flashing.
5. `/pre-pr` end-to-end on this branch's own diff.
6. `pr-babysit.js` with `autoPush: false` (dry cycle) on a real open PR:
CI failures classified correctly, at least one bot finding correctly
validated or rejected, proposed fixes produced but not pushed.
## Out of scope
- CI (GitHub Actions) integration — this harness is for interactive sessions.
- Fuzzing orchestration.
- usbtest/usbtest-host stress batteries (existing separate branch).
- Removing the untracked `port-audit.js` prototype from the master working
tree happens when this branch merges (it is not tracked by git).

View File

@ -855,6 +855,10 @@ static bool hfclk_running(void) {
#if CFG_TUD_NRF_NRFX_VERSION == 1
return nrf_clock_hf_is_running(NRF_CLOCK_HFCLK_HIGH_ACCURACY);
#elif CFG_TUD_NRF_NRFX_VERSION == 2
// nrfx 2.0.0 (MDK 8.29.0) has no nrf_clock_is_running(); it arrived in 2.1.0.
// nrf_clock_hf_is_running() is present in all of 2.0.0-2.11.0 (deprecated from 2.1.0).
return nrf_clock_hf_is_running(NRF_CLOCK, NRF_CLOCK_HFCLK_HIGH_ACCURACY);
#else
return nrf_clock_is_running(NRF_CLOCK, NRF_CLOCK_DOMAIN_HFCLK, NULL);
#endif

254
test/hil/board_lock.py Executable file
View File

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Per-board advisory locks for the HIL rig.
Arbitrates board access between dev sessions and CI's hil_test.py without
stopping the actions-runner. Locks are kernel flocks: the kernel releases
them automatically when the holder process dies, and holders clear their
lock-file record on release so records stay truthful (/tmp also clears on
reboot).
Usage:
board_lock.py hold BOARD [BOARD...] --reason TEXT
board_lock.py hold --all [--config CONFIG.json] --reason TEXT
board_lock.py release BOARD [BOARD...] | release --all
board_lock.py status
A holder process holds ALL boards given in one `hold` call; releasing any of
them kills that holder and releases all of its boards.
"""
import argparse
import fcntl
import json
import os
import select
import signal
import sys
import time
LOCK_DIR = '/tmp/tinyusb-hil-locks'
def lock_path(board: str) -> str:
return os.path.join(LOCK_DIR, f'{board}.lock')
def boards_from_config(config: str) -> list:
try:
with open(config) as f:
return [b['name'] for b in json.load(f)['boards']]
except (OSError, ValueError, KeyError) as e:
print(f'ERROR: cannot read board roster {config}: {e}', file=sys.stderr)
sys.exit(1)
def read_info(board: str):
try:
with open(lock_path(board)) as f:
return json.load(f)
except (OSError, ValueError):
return None
def is_locked(board: str) -> bool:
"""True if the recorded holder process is still alive.
Deliberately never touches the flock: even a momentary probe lock would
make a concurrent acquirer's LOCK_NB attempt fail spuriously. The flock
taken by acquirers themselves stays the only authority."""
info = read_info(board)
pid = info.get('pid') if isinstance(info, dict) else None
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True # alive but owned by another user (e.g. the CI runner)
return True
def cmd_hold(boards, reason):
os.makedirs(LOCK_DIR, exist_ok=True)
# No pre-check: the holder's own LOCK_NB flock is the only authority — a
# recorded pid may be stale or recycled (e.g. a live hil_test.py worker
# that already released this board's flock but not its record).
# The holder signals success through this pipe. A generic is_locked()
# poll would be fooled by a RIVAL invocation's flock — only the holder
# itself knows whether it won every board.
r_fd, w_fd = os.pipe()
pid = os.fork()
if pid > 0:
os.close(w_fd)
os.waitpid(pid, 0) # reap intermediate child
ready, _, _ = select.select([r_fd], [], [], 10)
ok = bool(ready) and os.read(r_fd, 1) == b'1'
os.close(r_fd)
if ok:
print(f'held: {", ".join(boards)}')
return 0
for b in boards:
info = read_info(b)
if info:
print(f'ERROR: {b} locked: {info}', file=sys.stderr)
print('ERROR: holder failed to acquire locks', file=sys.stderr)
return 1
# intermediate child: detach, then spawn the actual holder
os.setsid()
if os.fork() > 0:
os._exit(0)
# holder (grandchild): acquire all flocks, signal the parent, sleep until killed
os.close(r_fd)
# Keep the success pipe clear of fds 0-2: invoked with stdio closed,
# os.pipe() can land there and the dup2 loop below would clobber it.
if w_fd <= 2:
w_fd = fcntl.fcntl(w_fd, fcntl.F_DUPFD, 3)
# Detach stdio: a `hold` whose output is captured must see EOF when the
# front-end exits — the immortal holder must not keep that pipe open.
devnull = os.open(os.devnull, os.O_RDWR)
for std_fd in (0, 1, 2):
os.dup2(devnull, std_fd)
if devnull > 2:
os.close(devnull)
try:
handles = []
for b in boards:
# O_RDWR without O_TRUNC: never truncate before the flock is
# held — a losing racer must not wipe the winner's holder info.
fd = os.open(lock_path(b), os.O_RDWR | os.O_CREAT, 0o666)
fh = os.fdopen(fd, 'r+')
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
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()
handles.append(fh)
except OSError:
try:
os.write(w_fd, b'0')
except OSError:
pass
os._exit(1) # lost a race; parent reports the failure
os.write(w_fd, b'1')
os.close(w_fd)
def _bow_out(*_):
# clear the records before dying so read_info/status stay truthful
# (the kernel drops the flocks themselves on exit either way)
for h in handles:
try:
h.truncate(0)
except OSError:
pass
os._exit(0)
signal.signal(signal.SIGTERM, _bow_out)
while True:
signal.pause()
def cmd_release(boards):
rc = 0
victims = set()
for b in boards:
try:
fd = os.open(lock_path(b), os.O_RDWR)
except OSError:
continue # no lock file (or another user's): nothing we can release
fh = os.fdopen(fd, 'r+')
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
# flock genuinely held — never SIGTERM on a mere pid record: the
# pid may be recycled, or a live worker that already moved on.
fh.close()
info = read_info(b) or {}
pid = info.get('pid')
if info.get('reason') == 'hil_test.py':
print(f'ERROR: {b} is mid-test by hil_test.py (pid {pid}) — not killing a '
'CI run; wait for it to finish', file=sys.stderr)
rc = 1
elif isinstance(pid, int) and pid > 0:
victims.add(pid)
else:
print(f'ERROR: {b} is held but its record is unreadable', file=sys.stderr)
rc = 1
continue
# flock was free: only a stale record remained — clear it
try:
fh.truncate(0)
except OSError:
pass
fh.close()
for holder in sorted(victims):
try:
os.kill(holder, signal.SIGTERM)
print(f'released holder pid {holder}')
except ProcessLookupError:
pass
except PermissionError:
print(f'ERROR: holder pid {holder} belongs to another user — cannot signal it',
file=sys.stderr)
rc = 1
time.sleep(0.3)
still = [b for b in boards if is_locked(b)]
if still:
print(f'ERROR: still locked: {", ".join(still)}', file=sys.stderr)
return 1
return rc
def cmd_status():
if not os.path.isdir(LOCK_DIR):
print('no locks')
return 0
any_locked = False
for fn in sorted(os.listdir(LOCK_DIR)):
if not fn.endswith('.lock'):
continue
b = fn[:-5]
if is_locked(b):
any_locked = True
print(f'{b}: {read_info(b)}')
if not any_locked:
print('no locks')
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest='cmd', required=True)
p_hold = sub.add_parser('hold')
p_hold.add_argument('boards', nargs='*')
p_hold.add_argument('--all', action='store_true')
p_hold.add_argument('--config',
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
'tinyusb.json'),
help='board roster JSON (default: tinyusb.json beside this script)')
p_hold.add_argument('--reason', required=True)
p_rel = sub.add_parser('release')
p_rel.add_argument('boards', nargs='*')
p_rel.add_argument('--all', action='store_true')
sub.add_parser('status')
a = ap.parse_args()
if a.cmd == 'hold':
boards = boards_from_config(a.config) if a.all else a.boards
if not boards:
ap.error('no boards given (name boards or use --all)')
sys.exit(cmd_hold(boards, a.reason))
if a.cmd == 'release':
if a.all:
boards = ([fn[:-5] for fn in os.listdir(LOCK_DIR) if fn.endswith('.lock')]
if os.path.isdir(LOCK_DIR) else [])
else:
boards = a.boards
if not boards:
ap.error('no boards given (name boards or use --all)')
sys.exit(cmd_release(boards))
sys.exit(cmd_status())
if __name__ == '__main__':
main()

View File

@ -23,9 +23,8 @@
"device": true, "host": false, "dual": false
},
"flasher": {
"name": "jlink",
"uid": "770935966",
"args": "-device STM32F746NG"
"name": "stlink",
"uid": "0670FF515448787067122222"
}
},
{

View File

@ -66,6 +66,51 @@ import ctypes
from pymtp import MTP
import string
# --- per-board dev-session locks (see test/hil/board_lock.py) ------------
BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks'
def acquire_board_lock(board_name):
"""Take this board's flock for the duration of its flash+test.
Returns an open file handle (keep it referenced; closing releases it),
or None when HIL_NO_BOARD_LOCK=1 or the lock dir is unusable (fail-open:
locking must never break a test run by itself).
Raises RuntimeError only when another session holds the board."""
import fcntl
if os.environ.get('HIL_NO_BOARD_LOCK') == '1':
return None # user-authorized bypass — see board_lock.py / hil skill
try:
os.makedirs(BOARD_LOCK_DIR, exist_ok=True)
fd = os.open(os.path.join(BOARD_LOCK_DIR, f'{board_name}.lock'),
os.O_RDWR | os.O_CREAT, 0o666)
fh = os.fdopen(fd, 'r+')
except OSError as e:
# odd lock dir (perms, path collision): proceed unlocked, but say so —
# a silent fail-open is indistinguishable from the intentional bypass
print(f'warning: board lock unavailable for {board_name} ({e}); proceeding unlocked',
flush=True)
return None
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
try:
info = fh.read(500).strip()
except (OSError, UnicodeDecodeError):
info = ''
fh.close()
raise RuntimeError(f'board locked: {info or "unknown holder"}')
# announce ourselves so the other side's conflict message is truthful;
# best-effort — the flock itself is already held
try:
fh.truncate(0)
fh.seek(0)
json.dump({'pid': os.getpid(), 'reason': 'hil_test.py',
'since': time.strftime('%Y-%m-%dT%H:%M:%S%z')}, fh)
fh.flush()
except OSError:
pass
return fh
# Enumeration wait budget. The first attempt gets ENUM_TIMEOUT; retry attempts get the
# shorter ENUM_TIMEOUT_RETRY - the board was just re-flashed again, and a device that is
# going to enumerate shows up within a few seconds, so a failing test costs ~3-5x a
@ -1885,77 +1930,96 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
name = board['name']
flasher = board['flasher']
# default to all tests
test_list = []
try:
_lock_fh = acquire_board_lock(name)
except RuntimeError as e:
log_line(f'{name:25} {STATUS_FAILED}: {e}')
# visible report row so the ❌ matches the exit code; failed-tests stays
# empty so a re-run repeats the whole board (no bogus -bt test filter)
return name, 1, [], [(name, {'board-locked': 'fail'})]
try:
# default to all tests
test_list = []
if name in board_test:
test_list = board_test[name]
elif len(test_only) > 0:
# Explicit -t: filter against the board's capabilities so a device-only
# board doesn't try to run host/dual tests (the test functions need a
# `dev_attached` entry in the board config that won't exist).
board_tests = board.get('tests', {})
if 'only' in board_tests:
allowed = set(board_tests['only'])
test_list = [t for t in test_only if t in allowed]
else:
for t in test_only:
category = t.split('/', 1)[0]
if board_tests.get(category) is True:
test_list.append(t)
else:
if 'tests' in board:
board_tests = board['tests']
if board_tests.get('device') is True:
test_list += list(device_tests)
if board_tests.get('dual') is True:
test_list += dual_tests
if board_tests.get('host') is True:
test_list += host_test
if name in board_test:
test_list = board_test[name]
elif len(test_only) > 0:
# Explicit -t: filter against the board's capabilities so a device-only
# board doesn't try to run host/dual tests (the test functions need a
# `dev_attached` entry in the board config that won't exist).
board_tests = board.get('tests', {})
if 'only' in board_tests:
test_list = board_tests['only']
if 'skip' in board_tests:
for skip in board_tests['skip']:
if skip in test_list:
test_list.remove(skip)
log_line(f'{name:25} {skip:30} ... Skip')
allowed = set(board_tests['only'])
test_list = [t for t in test_only if t in allowed]
else:
for t in test_only:
category = t.split('/', 1)[0]
if board_tests.get(category) is True:
test_list.append(t)
else:
if 'tests' in board:
board_tests = board['tests']
if board_tests.get('device') is True:
test_list += list(device_tests)
if board_tests.get('dual') is True:
test_list += dual_tests
if board_tests.get('host') is True:
test_list += host_test
if 'only' in board_tests:
test_list = board_tests['only']
if 'skip' in board_tests:
for skip in board_tests['skip']:
if skip in test_list:
test_list.remove(skip)
log_line(f'{name:25} {skip:30} ... Skip')
err_count = 0
failed_tests = []
rows = [] # list of (row_label, {example: status}) — one row per build variant
variants = board.get('variant') or [{'name': name, 'flags': ''}]
err_count = 0
failed_tests = []
rows = [] # list of (row_label, {example: status}) — one row per build variant
variants = board.get('variant') or [{'name': name, 'flags': ''}]
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
for v in variants:
vname = v['name']
# Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
# usbtest batteries and flash churn spread across the timeline instead of convoying,
# and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
# main). Unique per-example PIDs make any two different examples re-enumerate; only
# the variant boundary can repeat the same example (same PID) — swap it away.
run_list = list(test_list)
if shuffle_seed is not None and len(run_list) > 1:
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
if run_list[0] == prev_last:
run_list[0], run_list[-1] = run_list[-1], run_list[0]
log_line(f'{vname:40} test order: {", ".join(t.rsplit("/", 1)[-1] for t in run_list)}')
if run_list:
prev_last = run_list[-1]
cells = {}
for test in run_list:
ec, status, metric = test_example(board, vname, test)
err_count += ec
cells[test] = metric if metric else status
if ec > 0:
failed_tests.append(test)
rows.append((vname, cells))
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
for v in variants:
vname = v['name']
# Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
# usbtest batteries and flash churn spread across the timeline instead of convoying,
# and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
# main). Unique per-example PIDs make any two different examples re-enumerate; only
# the variant boundary can repeat the same example (same PID) — swap it away.
run_list = list(test_list)
if shuffle_seed is not None and len(run_list) > 1:
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
if run_list[0] == prev_last:
run_list[0], run_list[-1] = run_list[-1], run_list[0]
log_line(f'{vname:40} test order: {", ".join(t.rsplit("/", 1)[-1] for t in run_list)}')
if run_list:
prev_last = run_list[-1]
cells = {}
for test in run_list:
ec, status, metric = test_example(board, vname, test)
err_count += ec
cells[test] = metric if metric else status
if ec > 0:
failed_tests.append(test)
rows.append((vname, cells))
# flash board_test last to disable board's usb (skipped when --skip-flash is set);
# this is teardown/park, not a test — not recorded in the report
if not skip_flash:
test_example(board, variants[0]['name'], 'device/board_test')
# flash board_test last to disable board's usb (skipped when --skip-flash is set);
# this is teardown/park, not a test — not recorded in the report
if not skip_flash:
test_example(board, variants[0]['name'], 'device/board_test')
return name, err_count, sorted(set(failed_tests)), rows
return name, err_count, sorted(set(failed_tests)), rows
finally:
if _lock_fh:
try:
# clear our pid record before dropping the flock: this worker
# process lives on (pool reuse), so a stale record would make
# board_lock.py's pid-liveness checks report a freed board as
# still locked for the rest of the run
_lock_fh.truncate(0)
except OSError:
pass
_lock_fh.close()
REPORT_MD = 'hil_report.md'
@ -2035,7 +2099,17 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool) -> str:
pass # corrupt/old sidecar: start fresh
# merge this run: current cells override prior for boards/tests that ran
for _, _, _, rows in mret:
for name, _, _, rows in mret:
if rows and not any('board-locked' in cells for _, cells in rows):
# board ran for real this time: clear a stale lock-failure cell
# (its row is keyed by board name; test rows may be variant names)
stale = acc.get(name)
if stale is not None:
stale.pop('board-locked', None)
if not stale:
# variant-keyed boards never repopulate the board-name row —
# drop it or it renders as a blank ghost row
del acc[name]
for row_label, cells in rows:
acc.setdefault(row_label, {}).update(cells)
@ -2098,6 +2172,11 @@ def main() -> None:
if len(boards) == 0:
config_boards = [e for e in config['boards'] if e['name'] not in skip_boards]
else:
unknown = [b for b in boards if b not in {e['name'] for e in config['boards']}]
if unknown:
# exiting 0 with 'No tests were run.' would read as a green HIL run
print(f'ERROR: board(s) not in {config_file.name}: {", ".join(unknown)}')
sys.exit(1)
config_boards = [e for e in config['boards'] if e['name'] in boards]
build_err = 0