diff options
| -rw-r--r-- | .claude/agents/hil-operator.md | 4 | ||||
| -rw-r--r-- | .claude/agents/port-dev.md | 2 | ||||
| -rw-r--r-- | .claude/skills/hil/SKILL.md | 2 | ||||
| -rw-r--r-- | .claude/workflows/driver-review.js | 7 | ||||
| -rw-r--r-- | .claude/workflows/fanout-dev.js | 2 | ||||
| -rw-r--r-- | .claude/workflows/full-check.js | 2 | ||||
| -rw-r--r-- | .claude/workflows/hil-validate.js | 6 | ||||
| -rw-r--r-- | .claude/workflows/pr-babysit.js | 38 | ||||
| -rw-r--r-- | .claude/workflows/validate.js | 4 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-07-09-claude-agents-workflows.md | 158 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-07-09-smoke-results.md | 11 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md | 25 | ||||
| -rwxr-xr-x | test/hil/board_lock.py | 86 | ||||
| -rwxr-xr-x | test/hil/hil_test.py | 29 |
14 files changed, 247 insertions, 129 deletions
diff --git a/.claude/agents/hil-operator.md b/.claude/agents/hil-operator.md index 6fc5b448b..c48ceb8bd 100644 --- a/.claude/agents/hil-operator.md +++ b/.claude/agents/hil-operator.md @@ -15,7 +15,7 @@ You operate physical USB test hardware. These repo skills are your source of tru 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 (pre-holding would deadlock it). +- `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>" @@ -30,7 +30,7 @@ The GitHub Actions runner keeps running during your work. Per-board flock locks - HIL runs take 2–5 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` appended. 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. +- 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 diff --git a/.claude/agents/port-dev.md b/.claude/agents/port-dev.md index 1238ae64a..76bafb39b 100644 --- a/.claude/agents/port-dev.md +++ b/.claude/agents/port-dev.md @@ -20,7 +20,7 @@ When changing dcd/hcd register logic, cross-check the MCU reference manual / dat ## Finish checklist (in order) -1. Format only the lines you changed: `git clang-format` (no args — formats working-tree changes vs HEAD using the repo `.clang-format`). If it reformats anything, re-check your diff still builds. +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) diff --git a/.claude/skills/hil/SKILL.md b/.claude/skills/hil/SKILL.md index eb73060f4..d61ec05e4 100644 --- a/.claude/skills/hil/SKILL.md +++ b/.claude/skills/hil/SKILL.md @@ -18,7 +18,7 @@ Default to **local**. Use **remote** only when on `htpc` and the user says `remo 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. -- `hil_test.py` self-locks each board for the duration of its flash+test (holder reason `hil_test.py`). A locked board FAILS immediately (`FAILED (board locked: ...)`) without flashing — in CI, re-run the failed job once the lock is released. +- `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: diff --git a/.claude/workflows/driver-review.js b/.claude/workflows/driver-review.js index 074244ca5..3638aa179 100644 --- a/.claude/workflows/driver-review.js +++ b/.claude/workflows/driver-review.js @@ -9,7 +9,7 @@ export const meta = { } // args: { dirs: string[], dimensions?: string[], question?: string } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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? }') } @@ -19,6 +19,11 @@ const DIMS = args.question ? [args.question] : (args.dimensions || [ '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 = { diff --git a/.claude/workflows/fanout-dev.js b/.claude/workflows/fanout-dev.js index ae74df8aa..e98a86f1d 100644 --- a/.claude/workflows/fanout-dev.js +++ b/.claude/workflows/fanout-dev.js @@ -10,7 +10,7 @@ export const meta = { } // args: { task: string, items: string[], board?: string | Record<string,string>, review?: boolean, worktree?: boolean } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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? }') } diff --git a/.claude/workflows/full-check.js b/.claude/workflows/full-check.js index caaedff01..6e353bf72 100644 --- a/.claude/workflows/full-check.js +++ b/.claude/workflows/full-check.js @@ -6,7 +6,7 @@ export const meta = { } // args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[] } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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? }') } diff --git a/.claude/workflows/hil-validate.js b/.claude/workflows/hil-validate.js index f1fa6cd1f..aa0556abc 100644 --- a/.claude/workflows/hil-validate.js +++ b/.claude/workflows/hil-validate.js @@ -6,7 +6,7 @@ export const meta = { } // args: { boards: string[], force?: boolean } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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') } @@ -27,7 +27,7 @@ const runBoard = (b) => agent( : '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. wedged=true if the board/fixture is unresponsive after the run (capture dmesg | tail -50 into detail).', + '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 }, ) @@ -57,4 +57,4 @@ if (wedged.length) log(`WEDGED boards needing usb-recover: ${wedged.join(', ')}` // 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.length === args.boards.length && results.every(r => r.pass), results, wedged, locked } +return { pass: results.every(r => r.pass), results, wedged, locked } diff --git a/.claude/workflows/pr-babysit.js b/.claude/workflows/pr-babysit.js index c78b2e7be..8bb414114 100644 --- a/.claude/workflows/pr-babysit.js +++ b/.claude/workflows/pr-babysit.js @@ -6,11 +6,18 @@ export const meta = { } // args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run) } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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, @@ -89,7 +96,17 @@ const RESOLVE_RECIPE = '`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). ` + @@ -106,16 +123,15 @@ for (let cycle = 1; cycle <= maxCycles; cycle++) { // 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. - if (t.replies.length > 0 && args.autoPush === true) { + 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: post the reply with ` + - `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 reply>) and skip resolving. ` + - `After replying to an inline comment, mark its thread resolved. ${RESOLVE_RECIPE} ` + - `Replies: ${JSON.stringify(t.replies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where.`, + `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'}`) } @@ -198,10 +214,8 @@ for (let cycle = 1; cycle <= maxCycles; cycle++) { 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: post a threaded reply to its inline comment via ' + - `gh api repos/{owner}/{repo}/pulls/${args.pr}/comments/{commentId}/replies -f body=<body>, stating it is fixed in the pushed commit and one line on the change; ` + - `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 fix note>) and skip resolving. ` + - `After replying to an inline comment, mark its thread resolved. ${RESOLVE_RECIPE} ` + + `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 }, diff --git a/.claude/workflows/validate.js b/.claude/workflows/validate.js index 83cb18120..522548ee6 100644 --- a/.claude/workflows/validate.js +++ b/.claude/workflows/validate.js @@ -6,7 +6,7 @@ export const meta = { } // args: { boards: string[], examples?: string, base?: string, skip?: ('unit'|'size'|'pvs')[] } -if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args +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? }') } @@ -76,7 +76,7 @@ for (const b of args.boards) thunks.push(() => 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 . ` + + `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 }, diff --git a/docs/superpowers/plans/2026-07-09-claude-agents-workflows.md b/docs/superpowers/plans/2026-07-09-claude-agents-workflows.md index 32efe1a2e..02e064fc4 100644 --- a/docs/superpowers/plans/2026-07-09-claude-agents-workflows.md +++ b/docs/superpowers/plans/2026-07-09-claude-agents-workflows.md @@ -1,6 +1,14 @@ # TinyUSB Multi-Agent Dev/Test Harness Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **HISTORICAL RECORD — DO NOT EXECUTE.** This plan was fully executed on +> 2026-07-09/10 (all tasks done; boxes below are checked). It is kept only as +> the audit trail of how the harness was built. The embedded file bodies are +> STALE SNAPSHOTS — the shipped `.claude/agents/*.md` and `.claude/workflows/*.js` +> have since evolved (static-analyzer agent, schema and recipe changes); the +> living design doc is `docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md`. +> Do NOT run superpowers:executing-plans or subagent-driven-development on this +> file, and do not copy its `git commit --no-verify` instructions — they applied +> only to the original build-out. **Goal:** Add 5 custom worker agents, 6 workflows, and a `/pre-pr` skill that let Claude Code sessions develop and test TinyUSB with cheap-to-orchestrate multi-agent fan-out. @@ -38,7 +46,7 @@ - Produces (consumed by Tasks 6, 7 via `agentType: 'builder'`): final-message JSON `{"board": string, "pass": boolean, "builtCount": integer, "failures": [{"example": string, "class": string, "firstError": string}]}` -- [ ] **Step 1: Write the agent file** +- [x] **Step 1: Write the agent file** Write `.claude/agents/builder.md` with exactly this content: @@ -92,12 +100,12 @@ Your final message is parsed by a program. Return ONLY this JSON — no prose, n `pass` is true only when zero failures remain after retries. `builtCount` = number of examples that built. ```` -- [ ] **Step 2: Verify structure** +- [x] **Step 2: Verify structure** Run: `head -8 .claude/agents/builder.md` Expected: frontmatter block containing `name: builder`, `tools: Bash, Read, Grep, Glob`, `model: opus`. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/agents/builder.md @@ -116,12 +124,12 @@ git commit --no-verify -m "feat: add builder worker agent" - Produces (consumed by Tasks 7, 11 via `agentType: 'port-dev'`): final-message JSON `{"item": string, "diffstat": string, "buildOk": boolean, "board": string, "notes": string}` -- [ ] **Step 1: Install clang-format (host tool, one-time)** +- [x] **Step 1: Install clang-format (host tool, one-time)** Run: `sudo apt-get install -y clang-format && git clang-format -h | head -3` Expected: apt succeeds; `git clang-format -h` prints usage (proves both `clang-format` and the `git clang-format` subcommand exist). If the package is already installed this is a no-op. -- [ ] **Step 2: Write the agent file** +- [x] **Step 2: Write the agent file** Write `.claude/agents/port-dev.md` with exactly this content: @@ -166,12 +174,12 @@ Your final message is parsed by a program. Return ONLY this JSON — no prose, n `buildOk` is the result of step 2. Put datasheet gaps, judgment calls, and anything a reviewer must know into `notes`. ```` -- [ ] **Step 3: Verify structure** +- [x] **Step 3: Verify structure** Run: `head -6 .claude/agents/port-dev.md` Expected: frontmatter with `name: port-dev`, `model: opus`, and NO `tools:` line (port-dev needs edit tools — inherits all). -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add .claude/agents/port-dev.md @@ -190,7 +198,7 @@ git commit --no-verify -m "feat: add port-dev worker agent" - findings: `{"scope": string, "dimension": string, "findings": [{"file": string, "line": integer, "snippet": string, "why": string, "severity": "critical"|"major"|"minor", "confidence": "high"|"medium"|"low"}]}` - verification: `{"addresses": boolean, "reason": string}` (also used with keys `real`/`reason` when the prompt asks to refute a finding) -- [ ] **Step 1: Write the agent file** +- [x] **Step 1: Write the agent file** Write `.claude/agents/driver-reviewer.md` with exactly this content: @@ -223,12 +231,12 @@ Your final message is parsed by a program. Return ONLY the JSON shape your promp {"scope": "src/portable/...", "dimension": "...", "findings": [{"file": "...", "line": 123, "snippet": "...", "why": "...", "severity": "major", "confidence": "high"}]} ```` -- [ ] **Step 2: Verify structure** +- [x] **Step 2: Verify structure** Run: `head -8 .claude/agents/driver-reviewer.md` Expected: frontmatter with `name: driver-reviewer`, `tools: Bash, Read, Grep, Glob`, `model: opus`. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/agents/driver-reviewer.md @@ -246,7 +254,7 @@ git commit --no-verify -m "feat: add driver-reviewer worker agent" - Produces (consumed by Task 9 via `agentType: 'hil-operator'`): final-message JSON per calling prompt — board runs `{"board": string, "pass": boolean, "detail": string, "wedged": boolean}` - Consumes: `test/hil/board_lock.py` (Task 19) for manual hardware work. -- [ ] **Step 1: Write the agent file** +- [x] **Step 1: Write the agent file** Write `.claude/agents/hil-operator.md` with exactly this content: @@ -292,12 +300,12 @@ Your final message is parsed by a program. Return ONLY the JSON shape your promp {"board": "raspberry_pi_pico", "pass": true, "detail": "<per-test summary or first failure>", "wedged": false} ```` -- [ ] **Step 2: Verify structure** +- [x] **Step 2: Verify structure** Run: `head -8 .claude/agents/hil-operator.md` Expected: frontmatter with `name: hil-operator`, `tools: Bash, Read, Grep, Glob`, `model: opus`. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/agents/hil-operator.md @@ -315,7 +323,7 @@ git commit --no-verify -m "feat: add hil-operator worker agent" - Produces (consumed by Task 11 via `agentType: 'pr-monitor'`): final-message JSON `{"ci": {"status": "green"|"red"|"running", "infraRerun": [string], "realFailures": [{"check": string, "firstError": string, "files": [string]}]}, "findings": [{"source": string, "commentId": integer, "file": string, "line": integer, "claim": string, "verdict": "valid"|"invalid"|"stale", "reason": string, "fixHint": string}], "replies": [{"commentId": integer, "body": string}], "done": boolean}` -- [ ] **Step 1: Write the agent file** +- [x] **Step 1: Write the agent file** Write `.claude/agents/pr-monitor.md` with exactly this content: @@ -360,12 +368,12 @@ Your final message is parsed by a program. Return ONLY this JSON — no prose, n "done": false} ```` -- [ ] **Step 2: Verify structure** +- [x] **Step 2: Verify structure** Run: `head -8 .claude/agents/pr-monitor.md` Expected: frontmatter with `name: pr-monitor`, `tools: Bash, Read, Grep, Glob`, `model: opus`. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/agents/pr-monitor.md @@ -384,7 +392,7 @@ git commit --no-verify -m "feat: add pr-monitor triage agent" - `check.sh <file.js>` prints `OK: <file>` and exits 0 on valid workflow syntax (used by every later workflow task). - `validate` workflow — Consumes: `builder` agent (Task 1). Args `{boards: string[], examples?: string, base?: string, skip?: ('unit'|'size'|'pvs')[]}`. Returns `{pass: boolean, stages: [{stage, pass, detail}], failures: [...]}` (consumed by Task 10 via `workflow('validate', ...)`). -- [ ] **Step 1: Write the checker** +- [x] **Step 1: Write the checker** Write `.claude/workflows/check.sh` with exactly this content (then `chmod +x .claude/workflows/check.sh`): @@ -405,12 +413,12 @@ trap 'rm -f "$tmp"' EXIT node --check "$tmp" && echo "OK: $f" ``` -- [ ] **Step 2: Verify checker fails on bad input and passes on good** +- [x] **Step 2: Verify checker fails on bad input and passes on good** Run: `printf 'return }broken\n' > /tmp/bad.js; bash .claude/workflows/check.sh /tmp/bad.js; echo "exit=$?"` Expected: SyntaxError printed, `exit=1` (non-zero). -- [ ] **Step 3: Write validate.js** +- [x] **Step 3: Write validate.js** Write `.claude/workflows/validate.js` with exactly this content: @@ -500,12 +508,12 @@ log(`${results.length}/${thunks.length} stages completed, ${failures.length} fai return { pass: failures.length === 0 && dead === 0, stages: results, failures } ``` -- [ ] **Step 4: Syntax-check** +- [x] **Step 4: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/validate.js` Expected: `OK: .claude/workflows/validate.js` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add .claude/workflows/check.sh .claude/workflows/validate.js @@ -523,7 +531,7 @@ git commit --no-verify -m "feat: add validate workflow and workflow syntax check - Consumes: `port-dev` (Task 2), `builder` (Task 1), `driver-reviewer` (Task 3). - Args `{task: string, items: string[], board?: string | {[item]: string}, review?: boolean, worktree?: boolean}`. Returns array of `{item, diffstat, buildOk, board, notes, verifyBuild?, review?}`. -- [ ] **Step 1: Write the workflow** +- [x] **Step 1: Write the workflow** Write `.claude/workflows/fanout-dev.js` with exactly this content: @@ -642,12 +650,12 @@ log(`${done.length}/${args.items.length} items completed; ${done.filter(r => r.b return done ``` -- [ ] **Step 2: Syntax-check** +- [x] **Step 2: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/fanout-dev.js` Expected: `OK: .claude/workflows/fanout-dev.js` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/workflows/fanout-dev.js @@ -666,7 +674,7 @@ git commit --no-verify -m "feat: add fanout-dev workflow" - Args `{dirs: string[], dimensions?: string[], question?: string}`. Returns array of `{dir, dim, findings: [finding & {verdict: {real, reason}}]}` — confirmed findings only. - Note: supersedes the untracked prototype `.claude/workflows/port-audit.js` in the master working tree (deleted at merge time; nothing to do on this branch). -- [ ] **Step 1: Write the workflow** +- [x] **Step 1: Write the workflow** Write `.claude/workflows/driver-review.js` with exactly this content: @@ -756,12 +764,12 @@ log(`${confirmed.length} scan units produced confirmed findings`) return confirmed ``` -- [ ] **Step 2: Syntax-check** +- [x] **Step 2: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/driver-review.js` Expected: `OK: .claude/workflows/driver-review.js` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/workflows/driver-review.js @@ -779,7 +787,7 @@ git commit --no-verify -m "feat: add driver-review workflow (supersedes port-aud - Consumes: `hil-operator` (Task 4). Requires `examples/cmake-build-<board>/` to exist for every board. - Args `{boards: string[]}`. Returns `{pass: boolean, results: [{board, pass, detail, wedged}], wedged: [string]}` (consumed by Task 10). -- [ ] **Step 1: Write the workflow** +- [x] **Step 1: Write the workflow** Write `.claude/workflows/hil-validate.js` with exactly this content: @@ -846,12 +854,12 @@ if (locked.length) log(`still locked after retry: ${locked.join(', ')} — ask t return { pass: results.length === args.boards.length && results.every(r => r.pass), results, wedged, locked } ``` -- [ ] **Step 2: Syntax-check** +- [x] **Step 2: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/hil-validate.js` Expected: `OK: .claude/workflows/hil-validate.js` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/workflows/hil-validate.js @@ -869,7 +877,7 @@ git commit --no-verify -m "feat: add hil-validate workflow (serialized, lock-arb - Consumes: `validate` workflow (Task 6), `hil-validate` workflow (Task 9) via `workflow()` nesting (one level — legal). - Args `{boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[]}`. Returns `{pass, software, hardware}` (consumed by the `/pre-pr` skill, Task 12). -- [ ] **Step 1: Write the workflow** +- [x] **Step 1: Write the workflow** Write `.claude/workflows/full-check.js` with exactly this content: @@ -910,12 +918,12 @@ if (hardware && hardware.locked && hardware.locked.length) { return { pass: !!(hardware && hardware.pass), software, hardware } ``` -- [ ] **Step 2: Syntax-check** +- [x] **Step 2: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/full-check.js` Expected: `OK: .claude/workflows/full-check.js` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/workflows/full-check.js @@ -934,7 +942,7 @@ git commit --no-verify -m "feat: add full-check composed workflow" - Args `{pr: number, maxCycles?: number, autoPush?: boolean}`. Must run from a checkout of the PR branch. Returns `{pass, cycles, history, reason?, dryRun?}`. - Push authorization: invoking with `autoPush !== false` authorizes pushes to the PR branch (spec'd exception to hold-pushes rule). -- [ ] **Step 1: Write the workflow** +- [x] **Step 1: Write the workflow** Write `.claude/workflows/pr-babysit.js` with exactly this content: @@ -1152,12 +1160,12 @@ for (let cycle = 1; cycle <= maxCycles; cycle++) { return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' } ``` -- [ ] **Step 2: Syntax-check** +- [x] **Step 2: Syntax-check** Run: `bash .claude/workflows/check.sh .claude/workflows/pr-babysit.js` Expected: `OK: .claude/workflows/pr-babysit.js` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/workflows/pr-babysit.js @@ -1174,7 +1182,7 @@ git commit --no-verify -m "feat: add pr-babysit workflow" **Interfaces:** - Consumes: `full-check` workflow (Task 10), `test/hil/tinyusb.json` (board roster), `hw/bsp/*/family.cmake|family.mk` (family mapping). -- [ ] **Step 1: Write the skill** +- [x] **Step 1: Write the skill** Write `.claude/skills/pre-pr/SKILL.md` with exactly this content: @@ -1223,12 +1231,12 @@ Invoke the Workflow tool: - End with a clear ship / no-ship verdict and what to fix first. ```` -- [ ] **Step 2: Verify structure** +- [x] **Step 2: Verify structure** Run: `head -4 .claude/skills/pre-pr/SKILL.md` Expected: frontmatter with `name: pre-pr` and a `description:` line mentioning full-check. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/skills/pre-pr/SKILL.md @@ -1241,48 +1249,48 @@ git commit --no-verify -m "feat: add /pre-pr skill entry point" **Files:** none created; exercises Tasks 1, 6. -- [ ] **Step 1: Run** — Invoke the Workflow tool: `{ name: 'validate', args: { boards: ['stm32f407disco', 'raspberry_pi_pico'] } }`. (If board deps are missing the builder self-heals via get_deps.) -- [ ] **Step 2: Verify** — Returned object has `stages` entries for `unit`, `build:stm32f407disco`, `build:raspberry_pi_pico`, `size`, `pvs`; each `detail` is meaningful; `pass` consistent with stage results. Master is green, so expect `pass: true`; investigate any failure before proceeding (a real regression on master is possible but unlikely). -- [ ] **Step 3: Record** — Append the verdict JSON (and rough token usage from /workflows) to `docs/superpowers/plans/2026-07-09-smoke-results.md`; commit with `docs: record validate smoke result`. +- [x] **Step 1: Run** — Invoke the Workflow tool: `{ name: 'validate', args: { boards: ['stm32f407disco', 'raspberry_pi_pico'] } }`. (If board deps are missing the builder self-heals via get_deps.) +- [x] **Step 2: Verify** — Returned object has `stages` entries for `unit`, `build:stm32f407disco`, `build:raspberry_pi_pico`, `size`, `pvs`; each `detail` is meaningful; `pass` consistent with stage results. Master is green, so expect `pass: true`; investigate any failure before proceeding (a real regression on master is possible but unlikely). +- [x] **Step 3: Record** — Append the verdict JSON (and rough token usage from /workflows) to `docs/superpowers/plans/2026-07-09-smoke-results.md`; commit with `docs: record validate smoke result`. ### Task 14: Smoke test — `fanout-dev` (MAIN SESSION ONLY) -- [ ] **Step 1: Run** — Invoke Workflow: `{ name: 'fanout-dev', args: { task: 'Add a single comment line `// fanout-dev smoke test — remove me` at the very top of the main dcd_*.c source file in your assigned scope. Make no other change.', items: ['src/portable/raspberrypi/rp2040', 'src/portable/st/stm32_fsdev'], board: { 'src/portable/raspberrypi/rp2040': 'raspberry_pi_pico', 'src/portable/st/stm32_fsdev': 'stm32f072disco' } } }` -- [ ] **Step 2: Verify** — Both items return `buildOk: true` and `verifyBuild: true`; `git diff --stat` shows exactly 2 files, 1 insertion each; diffs are clang-format-clean (`git clang-format --diff` reports no changes). -- [ ] **Step 3: Revert the smoke edits** — `git checkout -- src/portable/` (verify `git status` clean afterwards). -- [ ] **Step 4: Record** — Append results to the smoke-results doc; commit. +- [x] **Step 1: Run** — Invoke Workflow: `{ name: 'fanout-dev', args: { task: 'Add a single comment line `// fanout-dev smoke test — remove me` at the very top of the main dcd_*.c source file in your assigned scope. Make no other change.', items: ['src/portable/raspberrypi/rp2040', 'src/portable/st/stm32_fsdev'], board: { 'src/portable/raspberrypi/rp2040': 'raspberry_pi_pico', 'src/portable/st/stm32_fsdev': 'stm32f072disco' } } }` +- [x] **Step 2: Verify** — Both items return `buildOk: true` and `verifyBuild: true`; `git diff --stat` shows exactly 2 files, 1 insertion each; diffs are clang-format-clean (`git clang-format --diff` reports no changes). +- [x] **Step 3: Revert the smoke edits** — `git checkout -- src/portable/` (verify `git status` clean afterwards). +- [x] **Step 4: Record** — Append results to the smoke-results doc; commit. ### Task 15: Smoke test — `driver-review` (MAIN SESSION ONLY) -- [ ] **Step 1: Run** — Invoke Workflow: `{ name: 'driver-review', args: { dirs: ['src/portable/renesas/rusb2', 'src/portable/nxp/lpc_ip3511'], question: 'unbounded busy-wait loops polling hardware status bits with no timeout or bail-out (whole-stack freeze risk if hardware never sets the bit)' } }` -- [ ] **Step 2: Verify** — Returns only confirmed findings, each carrying `verdict.real: true` with a reasoned `verdict.reason`; spot-check one finding by reading the cited code yourself. (rusb2's known FRDY wedge was bounded in a past fix — a clean result there is plausible; judge findings on the code, not on expectations.) -- [ ] **Step 3: Record** — Append results to the smoke-results doc; commit. +- [x] **Step 1: Run** — Invoke Workflow: `{ name: 'driver-review', args: { dirs: ['src/portable/renesas/rusb2', 'src/portable/nxp/lpc_ip3511'], question: 'unbounded busy-wait loops polling hardware status bits with no timeout or bail-out (whole-stack freeze risk if hardware never sets the bit)' } }` +- [x] **Step 2: Verify** — Returns only confirmed findings, each carrying `verdict.real: true` with a reasoned `verdict.reason`; spot-check one finding by reading the cited code yourself. (rusb2's known FRDY wedge was bounded in a past fix — a clean result there is plausible; judge findings on the code, not on expectations.) +- [x] **Step 3: Record** — Append results to the smoke-results doc; commit. ### Task 16: Smoke test — `hil-validate` + board locks (MAIN SESSION ONLY) -- [ ] **Step 1: Preconditions** — `ls examples/cmake-build-raspberry_pi_pico/` exists (from Task 13); Tasks 19–20 are done (`test/hil/board_lock.py` exists, `hil_test.py` guard in place); runner is ACTIVE: `systemctl is-active actions.runner.hathach-tinyusb.tinyusb.service` prints `active`. -- [ ] **Step 2: Lock-conflict run** — `python3 test/hil/board_lock.py hold raspberry_pi_pico --reason "smoke lock test"`, then invoke Workflow: `{ name: 'hil-validate', args: { boards: ['raspberry_pi_pico'] } }`. +- [x] **Step 1: Preconditions** — `ls examples/cmake-build-raspberry_pi_pico/` exists (from Task 13); Tasks 19–20 are done (`test/hil/board_lock.py` exists, `hil_test.py` guard in place); runner is ACTIVE: `systemctl is-active actions.runner.hathach-tinyusb.tinyusb.service` prints `active`. +- [x] **Step 2: Lock-conflict run** — `python3 test/hil/board_lock.py hold raspberry_pi_pico --reason "smoke lock test"`, then invoke Workflow: `{ name: 'hil-validate', args: { boards: ['raspberry_pi_pico'] } }`. Expected: the board entry FAILS fast, `detail` cites `board locked` with the holder JSON (reason `smoke lock test`), no flash occurred (no JLink/flasher output), and the result carries `locked: ['raspberry_pi_pico']` — the signal for the force/wait/accept user prompt. -- [ ] **Step 3: Force path (lock still held)** — invoke Workflow: `{ name: 'hil-validate', args: { boards: ['raspberry_pi_pico'], force: true } }`. +- [x] **Step 3: Force path (lock still held)** — invoke Workflow: `{ name: 'hil-validate', args: { boards: ['raspberry_pi_pico'], force: true } }`. Expected: real flash+test proceeds despite the held lock (operator ran `HIL_NO_BOARD_LOCK=1`), result has empty `locked`, and the `board_lock.py status` holder is still alive afterwards (bypass, not theft). -- [ ] **Step 4: Release and normal run** — `python3 test/hil/board_lock.py release raspberry_pi_pico`, invoke the Step 2 Workflow call again (no `force`). +- [x] **Step 4: Release and normal run** — `python3 test/hil/board_lock.py release raspberry_pi_pico`, invoke the Step 2 Workflow call again (no `force`). Expected: a real flash+test result via the normal self-locking path (pass expected — firmware is master-green). -- [ ] **Step 5: Runner untouched** — `systemctl is-active actions.runner.hathach-tinyusb.tinyusb.service` still prints `active`; the workflow made no stop/start calls. -- [ ] **Step 6: Record** — Append all three results to the smoke-results doc; update the memory note `~/.claude/projects/-home-hathach-code-tinyusb/memory/ci-rig-stop-actions-runner.md` to describe the lock protocol (with the caveat that CI enforces it only after this branch merges to master); commit the smoke-results doc. +- [x] **Step 5: Runner untouched** — `systemctl is-active actions.runner.hathach-tinyusb.tinyusb.service` still prints `active`; the workflow made no stop/start calls. +- [x] **Step 6: Record** — Append all three results to the smoke-results doc; update the memory note `~/.claude/projects/-home-hathach-code-tinyusb/memory/ci-rig-stop-actions-runner.md` to describe the lock protocol (with the caveat that CI enforces it only after this branch merges to master); commit the smoke-results doc. ### Task 17: Smoke test — `/pre-pr` end-to-end (MAIN SESSION ONLY) -- [ ] **Step 1: Run** — Invoke the `pre-pr` skill on this branch (its diff is docs + `.claude/` only, so expect the minimal path: software-only, `boards = [stm32f407disco]`). -- [ ] **Step 2: Verify** — The skill correctly detects "no C changes", runs `full-check` with the minimal args, and produces the per-stage summary + verdict. -- [ ] **Step 3: Record** — Append to the smoke-results doc; commit. +- [x] **Step 1: Run** — Invoke the `pre-pr` skill on this branch (its diff is docs + `.claude/` only, so expect the minimal path: software-only, `boards = [stm32f407disco]`). +- [x] **Step 2: Verify** — The skill correctly detects "no C changes", runs `full-check` with the minimal args, and produces the per-stage summary + verdict. +- [x] **Step 3: Record** — Append to the smoke-results doc; commit. ### Task 18: Smoke test — `pr-babysit` dry run (MAIN SESSION ONLY) -- [ ] **Step 1: Pick a target** — `gh pr list --limit 10 --json number,title,headRefName` — choose an open PR with completed CI and at least one bot review comment; check out its branch in a THROWAWAY worktree (`git worktree add /tmp/prsmoke <headRef>` after `git fetch`), and run from there so fix edits can't dirty this branch. -- [ ] **Step 2: Run** — Invoke Workflow from that checkout: `{ name: 'pr-babysit', args: { pr: <N>, maxCycles: 1, autoPush: false } }` -- [ ] **Step 3: Verify** — Triage classifies CI checks plausibly (compare with `gh pr checks <N>` yourself); each bot finding has a reasoned verdict (spot-check one against the code); result has `dryRun: true` if fixes were produced, and NOTHING was committed or pushed (`git -C /tmp/prsmoke status`, `gh pr view <N> --json comments` unchanged). -- [ ] **Step 4: Clean up** — `git worktree remove --force /tmp/prsmoke`. -- [ ] **Step 5: Record** — Append to the smoke-results doc; commit. +- [x] **Step 1: Pick a target** — `gh pr list --limit 10 --json number,title,headRefName` — choose an open PR with completed CI and at least one bot review comment; check out its branch in a THROWAWAY worktree (`git worktree add /tmp/prsmoke <headRef>` after `git fetch`), and run from there so fix edits can't dirty this branch. +- [x] **Step 2: Run** — Invoke Workflow from that checkout: `{ name: 'pr-babysit', args: { pr: <N>, maxCycles: 1, autoPush: false } }` +- [x] **Step 3: Verify** — Triage classifies CI checks plausibly (compare with `gh pr checks <N>` yourself); each bot finding has a reasoned verdict (spot-check one against the code); result has `dryRun: true` if fixes were produced, and NOTHING was committed or pushed (`git -C /tmp/prsmoke status`, `gh pr view <N> --json comments` unchanged). +- [x] **Step 4: Clean up** — `git worktree remove --force /tmp/prsmoke`. +- [x] **Step 5: Record** — Append to the smoke-results doc; commit. --- @@ -1294,7 +1302,7 @@ git commit --no-verify -m "feat: add /pre-pr skill entry point" **Interfaces:** - Produces (consumed by `hil-operator` agent, Task 20's guard shares the same lock files): CLI `hold <board...> [--all] [--config PATH] --reason TEXT` / `release <board...> [--all]` / `status`. Lock files: `/tmp/tinyusb-hil-locks/<board>.lock`, exclusive `fcntl.flock` held by a background holder process; JSON `{pid, reason, since}` written into the file. -- [ ] **Step 1: Write the tool** +- [x] **Step 1: Write the tool** Write `test/hil/board_lock.py` with exactly this content (then `chmod +x test/hil/board_lock.py`): @@ -1493,7 +1501,7 @@ if __name__ == '__main__': main() ``` -- [ ] **Step 2: Test the lock lifecycle** +- [x] **Step 2: Test the lock lifecycle** Run each line and check the expectation before the next: @@ -1521,7 +1529,7 @@ python3 test/hil/board_lock.py release fakeboard # - rm -f /tmp/tinyusb-hil-locks/fakeboard.lock ``` -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add test/hil/board_lock.py @@ -1539,12 +1547,12 @@ git commit --no-verify -m "feat(hil): add per-board advisory lock tool" - Consumes: the lock files of Task 19 (`/tmp/tinyusb-hil-locks/<board>.lock`). - Produces: a locked board FAILS immediately with `board locked: <holder JSON>` (no flash); while testing, `hil_test.py` holds the board's flock and writes its own holder info (`reason: "hil_test.py"`) so the reverse conflict reports truthfully. CI semantics: job fails for locked boards, `re-run failed` passes after release. -- [ ] **Step 1: Locate the per-board entry point** +- [x] **Step 1: Locate the per-board entry point** Run: `grep -n "Pool(\|\.map\|\.imap\|def test_board\|def run_board" test/hil/hil_test.py` Identify the function the `multiprocessing.Pool` maps over the board list (each worker process handles one board's flash+test) and how it reports failure (inspect how a flash error is reported/raised so the locked case matches that convention exactly). -- [ ] **Step 2: Add the guard** +- [x] **Step 2: Add the guard** Add near the top of `hil_test.py` (module level, after existing imports — `fcntl` and `os` may need importing): @@ -1609,7 +1617,7 @@ finally: Match indentation and the file's existing style exactly; keep the diff minimal (guard function + one try/finally wrap). -- [ ] **Step 3: Test the locked path (no hardware touched)** +- [x] **Step 3: Test the locked path (no hardware touched)** ```bash python3 test/hil/board_lock.py hold raspberry_pi_pico --reason "guard test" @@ -1618,12 +1626,12 @@ python3 test/hil/board_lock.py release raspberry_pi_pico ``` Expected: the run fails FAST (seconds, no JLink/flasher invocation in output), the board's failure message contains `board locked: {"pid": ..., "reason": "guard test", ...}`, exit code non-zero. (The unlocked happy path is exercised on real hardware in Task 16.) -- [ ] **Step 4: Sanity-check no syntax damage** +- [x] **Step 4: Sanity-check no syntax damage** Run: `python3 -m py_compile test/hil/hil_test.py && echo OK` Expected: `OK` -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add test/hil/hil_test.py @@ -1637,7 +1645,7 @@ git commit --no-verify -m "feat(hil): fail fast on dev-locked boards instead of **Files:** - Modify: `.claude/skills/hil/SKILL.md` (replace the "Stop the CI runner first" section, currently lines 17–29) -- [ ] **Step 1: Replace the runner-stop section** +- [x] **Step 1: Replace the runner-stop section** In `.claude/skills/hil/SKILL.md`, replace the entire section from the heading `## Stop the CI runner first (on \`ci\`)` up to (not including) `## Prerequisites` with: @@ -1663,12 +1671,12 @@ python3 test/hil/board_lock.py release BOARD [BOARD...] - Caveat until this branch merges to master: CI's checkout of `hil_test.py` does not yet enforce locks — keep dev hardware sessions short and check `gh run list --status in_progress` first. ```` -- [ ] **Step 2: Verify** +- [x] **Step 2: Verify** Run: `grep -n "svc.sh stop" .claude/skills/hil/SKILL.md; grep -c "board_lock.py" .claude/skills/hil/SKILL.md` Expected: no `svc.sh stop` occurrences remain; `board_lock.py` appears ≥ 3 times. -- [ ] **Step 3: Commit** +- [x] **Step 3: Commit** ```bash git add .claude/skills/hil/SKILL.md diff --git a/docs/superpowers/plans/2026-07-09-smoke-results.md b/docs/superpowers/plans/2026-07-09-smoke-results.md index 7fb26a347..a15638247 100644 --- a/docs/superpowers/plans/2026-07-09-smoke-results.md +++ b/docs/superpowers/plans/2026-07-09-smoke-results.md @@ -65,3 +65,14 @@ Stop-gate extra (done this session): board_lock `cmd_hold` holder-signaled succe - 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). diff --git a/docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md b/docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md index f37929439..63788720c 100644 --- a/docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md +++ b/docs/superpowers/specs/2026-07-09-claude-agents-workflows-design.md @@ -59,24 +59,31 @@ log-heavy). 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 (stale locks impossible; `/tmp` clears on reboot): +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); `release - <boards|--all>` kills holders; `status` lists them. `--all` is required - before rig-wide operations (uhubctl power cycling, pci-rebind — bus - renumbering affects every board). + `{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. If already held, FAIL the board immediately — - `FAILED (board locked: <holder info>)` — no flash, no waiting. + 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 defaults to proceeding if the - lock dir is absent/odd. + 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. diff --git a/test/hil/board_lock.py b/test/hil/board_lock.py index 42df80b35..c35e13705 100755 --- a/test/hil/board_lock.py +++ b/test/hil/board_lock.py @@ -3,12 +3,13 @@ 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, so stale locks are -impossible (/tmp also clears on reboot). +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 test/hil/tinyusb.json] --reason TEXT + board_lock.py hold --all [--config CONFIG.json] --reason TEXT board_lock.py release BOARD [BOARD...] | release --all board_lock.py status @@ -69,11 +70,9 @@ def is_locked(board: str) -> bool: def cmd_hold(boards, reason): os.makedirs(LOCK_DIR, exist_ok=True) - already = [b for b in boards if is_locked(b)] - if already: - for b in already: - print(f'ERROR: {b} already locked: {read_info(b)}', file=sys.stderr) - return 1 + # 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. @@ -88,7 +87,11 @@ def cmd_hold(boards, reason): if ok: print(f'held: {", ".join(boards)}') return 0 - print('ERROR: holder failed to acquire locks (lost a race?)', file=sys.stderr) + 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() @@ -96,6 +99,10 @@ def cmd_hold(boards, reason): 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) @@ -125,31 +132,71 @@ def cmd_hold(boards, reason): os._exit(1) # lost a race; parent reports the failure os.write(w_fd, b'1') os.close(w_fd) - signal.signal(signal.SIGTERM, lambda *_: os._exit(0)) + + 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): - pids = set() + rc = 0 + victims = set() for b in boards: - if not is_locked(b): + 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 - info = read_info(b) or {} - if info.get('pid'): - pids.add(info['pid']) - for holder in sorted(pids): + # 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 0 + return rc def cmd_status(): @@ -176,7 +223,10 @@ def main(): 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='test/hil/tinyusb.json') + 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='*') diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py index 2e5085079..4b87154b6 100755 --- a/test/hil/hil_test.py +++ b/test/hil/hil_test.py @@ -75,8 +75,12 @@ def acquire_board_lock(board_name): 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: - return None # odd lock dir (perms, path collision): proceed unlocked + 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: @@ -1767,6 +1771,14 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]: 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() @@ -1837,7 +1849,13 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool) -> str: 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) - acc.get(name, {}).pop('board-locked', None) + 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) @@ -1900,6 +1918,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 |
