summaryrefslogtreecommitdiff
path: root/.claude/workflows
diff options
context:
space:
mode:
authorhathach <[email protected]>2026-07-09 23:34:29 +0700
committerhathach <[email protected]>2026-07-09 23:34:29 +0700
commite3dd9245ef08c457d7c6e5837e3f8d41bad6fd8a (patch)
tree4793e68120f4836e50cb3f51d2dd8d25a6095ab3 /.claude/workflows
parentfa750d6bf045f1df4314d283dfe0508d0d066559 (diff)
feat: Claude Code multi-agent dev/test harness for TinyUSB
Add worker agents (builder, port-dev, driver-reviewer, hil-operator, pr-monitor), deterministic workflows (validate, fanout-dev, driver-review, hil-validate, full-check, pr-babysit) and a /pre-pr gate skill, so sessions can fan build/test/review/PR-triage work out to tiered subagents. pr-babysit drives a PR to green: triage CI + bot reviews, fix validated findings, verify, push, and reply-to + resolve each inline review thread (fixed or refuted). Replace the stop-the-runner HIL discipline with per-board flock locks: test/hil/board_lock.py plus a fail-open guard in hil_test.py let CI and dev sessions share the rig per board (locked boards fail fast and re-run; HIL_NO_BOARD_LOCK=1 is a user-authorized bypass). The actions-runner is never stopped. Design spec, implementation plan, and real-rig smoke evidence under docs/superpowers/. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Rn1AN5DsTdFhRwhugfgKZi
Diffstat (limited to '.claude/workflows')
-rwxr-xr-x.claude/workflows/check.sh14
-rw-r--r--.claude/workflows/driver-review.js83
-rw-r--r--.claude/workflows/fanout-dev.js112
-rw-r--r--.claude/workflows/full-check.js34
-rw-r--r--.claude/workflows/hil-validate.js60
-rw-r--r--.claude/workflows/pr-babysit.js211
-rw-r--r--.claude/workflows/validate.js83
7 files changed, 597 insertions, 0 deletions
diff --git a/.claude/workflows/check.sh b/.claude/workflows/check.sh
new file mode 100755
index 000000000..80213d99b
--- /dev/null
+++ b/.claude/workflows/check.sh
@@ -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"
diff --git a/.claude/workflows/driver-review.js b/.claude/workflows/driver-review.js
new file mode 100644
index 000000000..074244ca5
--- /dev/null
+++ b/.claude/workflows/driver-review.js
@@ -0,0 +1,83 @@
+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') args = JSON.parse(args) // tolerate stringified invocation args
+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)',
+])
+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
diff --git a/.claude/workflows/fanout-dev.js b/.claude/workflows/fanout-dev.js
new file mode 100644
index 000000000..ae74df8aa
--- /dev/null
+++ b/.claude/workflows/fanout-dev.js
@@ -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') args = JSON.parse(args) // tolerate stringified invocation args
+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
diff --git a/.claude/workflows/full-check.js b/.claude/workflows/full-check.js
new file mode 100644
index 000000000..caaedff01
--- /dev/null
+++ b/.claude/workflows/full-check.js
@@ -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') args = JSON.parse(args) // tolerate stringified invocation args
+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 }
diff --git a/.claude/workflows/hil-validate.js b/.claude/workflows/hil-validate.js
new file mode 100644
index 000000000..f1fa6cd1f
--- /dev/null
+++ b/.claude/workflows/hil-validate.js
@@ -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') args = JSON.parse(args) // tolerate stringified invocation args
+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. 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.length === args.boards.length && results.every(r => r.pass), results, wedged, locked }
diff --git a/.claude/workflows/pr-babysit.js b/.claude/workflows/pr-babysit.js
new file mode 100644
index 000000000..9df263b5d
--- /dev/null
+++ b/.claude/workflows/pr-babysit.js
@@ -0,0 +1,211 @@
+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. Invoking with autoPush enabled authorizes pushes to that branch.',
+ phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
+}
+
+// args: { pr: number, maxCycles?: number, autoPush?: boolean }
+if (typeof args === 'string') args = JSON.parse(args) // tolerate stringified invocation args
+if (!args || !args.pr) {
+ throw new Error('args must be { pr: number, maxCycles?, autoPush? }; run from a checkout of the PR branch')
+}
+const maxCycles = args.maxCycles ?? 3
+
+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!){repository(owner:$o,name:$r){pullRequest(number:$p){reviewThreads(first:100){nodes{id isResolved comments(first:50){nodes{databaseId}}}}}}}\' -F o=OWNER -F r=REPO -F p=' + args.pr + '` ' +
+ '(paginate with the endCursor if there are more than 100 threads), 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.'
+
+const history = []
+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.
+ if (t.replies.length > 0 && args.autoPush !== false) {
+ 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.`,
+ { label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
+ )
+ 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 === false) {
+ log('autoPush=false: 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: 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, ` +
+ `then mark its thread resolved. ${RESOLVE_RECIPE} ` +
+ `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' }
diff --git a/.claude/workflows/validate.js b/.claude/workflows/validate.js
new file mode 100644
index 000000000..a00240262
--- /dev/null
+++ b/.claude/workflows/validate.js
@@ -0,0 +1,83 @@
+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') args = JSON.parse(args) // tolerate stringified invocation args
+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 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 -b ${args.boards[0]} -e device/cdc_msc . ` +
+ '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 per .claude/skills/pvs/SKILL.md, but with a DEDICATED build dir so you do not collide with parallel build agents: ' +
+ `cd examples && cmake -B cmake-build-pvs -DBOARD=${args.boards[0]} -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-pvs. ` +
+ 'Then: pvs-studio-analyzer analyze -f examples/cmake-build-pvs/compile_commands.json -R .PVS-Studio/.pvsconfig -o pvs-report.log -j12 ' +
+ '--security-related-issues --misra-c-version 2023 --misra-cpp-version 2008 --use-old-parser ' +
+ 'and view with: plog-converter -a GA:1,2 -t errorfile pvs-report.log. ' +
+ `pass=false only if GA:1 diagnostics exist in files changed vs ${base} (git diff --name-only ${base}...HEAD). ` +
+ 'detail = GA:1/GA:2 counts plus any diagnostics in changed files.',
+ { label: 'pvs', phase: 'Validate', model: 'sonnet', effort: 'low', schema: STAGE },
+ ).then(r => r && { stage: 'pvs', ...r }))
+
+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 }