diff options
| author | hathach <[email protected]> | 2026-07-09 23:34:29 +0700 |
|---|---|---|
| committer | hathach <[email protected]> | 2026-07-09 23:34:29 +0700 |
| commit | e3dd9245ef08c457d7c6e5837e3f8d41bad6fd8a (patch) | |
| tree | 4793e68120f4836e50cb3f51d2dd8d25a6095ab3 /.claude/workflows | |
| parent | fa750d6bf045f1df4314d283dfe0508d0d066559 (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.sh | 14 | ||||
| -rw-r--r-- | .claude/workflows/driver-review.js | 83 | ||||
| -rw-r--r-- | .claude/workflows/fanout-dev.js | 112 | ||||
| -rw-r--r-- | .claude/workflows/full-check.js | 34 | ||||
| -rw-r--r-- | .claude/workflows/hil-validate.js | 60 | ||||
| -rw-r--r-- | .claude/workflows/pr-babysit.js | 211 | ||||
| -rw-r--r-- | .claude/workflows/validate.js | 83 |
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 } |
