validate: make the Claude review opt-in validate was the last workflow still spending a Claude review by default: its reviewer set was derived from `skip` alone, so an unconfigured run scheduled both providers. Every other caller already reached the verifier through code-verify, which defaults to Codex. Take the router's own vocabulary — reviewProvider codex|claude|both, defaulting to codex — and forward it through full-check so /pre-pr can still ask for the second reviewer. Skipping a reviewer that was explicitly selected is contradictory input and now throws: otherwise `reviewProvider: 'claude'` plus skip:['review'] would report green having reviewed nothing. Skipping the default reviewer stays legal but is logged, since skip:['codex'] used to mean "review with Claude" and now means no review at all.
diff --git a/.claude/workflows/full-check.js b/.claude/workflows/full-check.js index 6e353bf..76c98dd 100644 --- a/.claude/workflows/full-check.js +++ b/.claude/workflows/full-check.js
@@ -5,15 +5,17 @@ phases: [{ title: 'Software' }, { title: 'Hardware' }], } -// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[] } +// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, +// skip?: string[], reviewProvider?: 'codex'|'claude'|'both' } 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? }') + throw new Error('args must be { boards: string[], hilBoards?, examples?, base?, skip?, reviewProvider? }') } phase('Software') const software = await workflow('validate', { boards: args.boards, examples: args.examples, base: args.base, skip: args.skip, + reviewProvider: args.reviewProvider, }) if (!software || !software.pass) { log('software validation failed — skipping HIL')
diff --git a/.claude/workflows/test/test-code-verify.mjs b/.claude/workflows/test/test-code-verify.mjs index 16cbbd6..c776a2a 100644 --- a/.claude/workflows/test/test-code-verify.mjs +++ b/.claude/workflows/test/test-code-verify.mjs
@@ -189,7 +189,7 @@ const workflow = async () => { throw new Error('validate cannot nest a workflow') } const parallel = thunks => Promise.all(thunks.map(run => run())) const result = await fn( - { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 }, + { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 }, agent, null, parallel, () => {}, () => {}, workflow, null, ) assert.deepEqual(calls.sort(), ['builder', 'code-verifier', 'codex-code-verifier']) @@ -198,7 +198,7 @@ blocking = true const blocked = await fn( - { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 }, + { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 }, agent, null, parallel, () => {}, () => {}, workflow, null, ) assert.equal(blocked.stages.find(s => s.stage === 'review').pass, false) @@ -206,12 +206,86 @@ failCodex = true const partial = await fn( - { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1 }, + { boards: ['test'], skip: ['unit', 'size', 'pvs'], reviewProvider: 'both', maxCycles: 1 }, agent, null, parallel, () => {}, () => {}, workflow, null, ) assert.equal(partial.stages.find(s => s.stage === 'review').detail, 'claude') assert.equal(partial.stages.find(s => s.stage === 'codex').detail, 'stage agent died') }) +await check('validate reviews with codex unless claude is explicitly selected', async () => { + const src = readFileSync(new URL('../validate.js', import.meta.url), 'utf8').replace(/^export /m, '') + const fn = new AsyncFunction( + 'args', 'agent', 'pipeline', 'parallel', 'phase', 'log', 'workflow', 'budget', src) + const parallel = thunks => Promise.all(thunks.map(run => run())) + const runValidate = async extra => { + const calls = [] + const agent = async (prompt, options) => { + calls.push(options.agentType) + if (options.agentType === 'builder') return { board: 'test', pass: true, builtCount: 1, failures: [] } + return { pass: true, detail: options.agentType, findings: [] } + } + const result = await fn( + { boards: ['test'], skip: ['unit', 'size', 'pvs'], maxCycles: 1, ...extra }, + agent, null, parallel, () => {}, () => {}, null, null, + ) + return { calls, result } + } + + // the default must never spend a Claude review + for (const extra of [{}, { reviewProvider: 'codex' }]) { + const { calls, result } = await runValidate(extra) + assert.deepEqual(calls.sort(), ['builder', 'codex-code-verifier']) + assert.equal(result.stages.some(s => s.stage === 'review'), false) + assert.equal(result.stages.find(s => s.stage === 'codex').pass, true) + } + + const claudeOnly = await runValidate({ reviewProvider: 'claude' }) + assert.deepEqual(claudeOnly.calls.sort(), ['builder', 'code-verifier']) + assert.equal(claudeOnly.result.stages.some(s => s.stage === 'codex'), false) + + const both = await runValidate({ reviewProvider: 'both' }) + assert.deepEqual(both.calls.sort(), ['builder', 'code-verifier', 'codex-code-verifier']) + + // skip still turns a selected reviewer off + const skipped = await runValidate({ reviewProvider: 'both', skip: ['unit', 'size', 'pvs', 'review'] }) + assert.deepEqual(skipped.calls.sort(), ['builder', 'codex-code-verifier']) + + // selecting a reviewer and skipping it is contradictory input, not silence + await assert.rejects( + runValidate({ reviewProvider: 'claude', skip: ['unit', 'size', 'pvs', 'review'] }), + /reviewProvider "claude" is cancelled by skip/, + ) + await assert.rejects( + runValidate({ reviewProvider: 'both', skip: ['unit', 'size', 'pvs', 'review', 'codex'] }), + /reviewProvider "both" is cancelled by skip/, + ) + + // skipping the default reviewer is legal but leaves nothing reviewing the diff + const noReview = await runValidate({ skip: ['unit', 'size', 'pvs', 'codex'] }) + assert.deepEqual(noReview.calls.sort(), ['builder']) + assert.equal(noReview.result.stages.some(s => s.stage === 'review' || s.stage === 'codex'), false) + + for (const reviewProvider of ['', 'auto', 'CODEX']) { + await assert.rejects(runValidate({ reviewProvider }), /reviewProvider must be codex, claude, or both/) + } +}) + +await check('full-check forwards the reviewer selection to validate', async () => { + const src = readFileSync(new URL('../full-check.js', import.meta.url), 'utf8').replace(/^export /m, '') + const fn = new AsyncFunction( + 'args', 'agent', 'pipeline', 'parallel', 'phase', 'log', 'workflow', 'budget', src) + const seen = [] + const workflow = async (name, workflowArgs) => { + seen.push({ name, args: workflowArgs }) + return { pass: true } + } + await fn({ boards: ['test'], reviewProvider: 'both' }, + null, null, null, () => {}, () => {}, workflow, null) + assert.deepEqual(seen.map(s => s.name), ['validate']) + assert.equal(seen[0].args.reviewProvider, 'both') + assert.deepEqual(seen[0].args.boards, ['test']) +}) + console.log(failed ? `\n${failed} FAILED` : '\nall checks passed') process.exit(failed ? 1 : 0)
diff --git a/.claude/workflows/validate.js b/.claude/workflows/validate.js index b946532..ddf8dae 100644 --- a/.claude/workflows/validate.js +++ b/.claude/workflows/validate.js
@@ -1,6 +1,6 @@ export const meta = { name: 'validate', - description: 'Pre-PR software validation loop: unit tests + per-board build sweeps + code-size compare + PVS + diff reviews (claude + codex) in parallel; a red verdict dispatches a fix agent for the confirmed findings, then the affected stages re-run — up to maxCycles (default 5) validation passes; a fix that edits a workflow file stops with restartRequired so the caller re-invokes it', + description: 'Pre-PR software validation loop: unit tests + per-board build sweeps + code-size compare + PVS + the diff review (Codex by default; Claude only when reviewProvider selects it) in parallel; a red verdict dispatches a fix agent for the confirmed findings, then the affected stages re-run — up to maxCycles (default 5) validation passes; a fix that edits a workflow file stops with restartRequired so the caller re-invokes it', whenToUse: 'Before opening or updating a PR, after any non-trivial change', phases: [ { title: 'Validate', detail: 'unit + builds + size + pvs + reviews in parallel' }, @@ -9,7 +9,8 @@ } // args: { boards: string[], examples?: string, base?: string, -// skip?: ('unit'|'size'|'pvs'|'review'|'codex')[], maxCycles?: number } +// skip?: ('unit'|'size'|'pvs'|'review'|'codex')[], +// reviewProvider?: 'codex'|'claude'|'both', maxCycles?: number } 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?, maxCycles? }') @@ -121,12 +122,27 @@ // names: 'unit', 'build:<board>', 'size', 'pvs', and the dual-provider // 'reviews' scheduler, which emits the public 'review' and 'codex' rows. // --------------------------------------------------------------------------- +// Same default as the code-verify router: Codex reviews, Claude only when +// asked for. An unconfigured run must never spend a second reviewer silently. +const requestedProvider = args.reviewProvider ?? 'codex' +if (!['codex', 'claude', 'both'].includes(requestedProvider)) { + throw new Error('reviewProvider must be codex, claude, or both') +} const reviewStageNames = [] -if (!skip.includes('review')) reviewStageNames.push('review') -if (!skip.includes('codex')) reviewStageNames.push('codex') +if (requestedProvider !== 'codex' && !skip.includes('review')) reviewStageNames.push('review') +if (requestedProvider !== 'claude' && !skip.includes('codex')) reviewStageNames.push('codex') const reviewProvider = reviewStageNames.length === 2 ? 'both' : reviewStageNames[0] === 'review' ? 'claude' : reviewStageNames[0] === 'codex' ? 'codex' : null +// Asking for a reviewer and skipping it is contradictory input, not a request +// for silence — the run would otherwise report green having reviewed nothing. +if (args.reviewProvider && !reviewProvider) { + throw new Error(`reviewProvider "${requestedProvider}" is cancelled by skip: ${skip.join(', ')}`) +} +// 'codex' alone is now the default reviewer, so skipping it leaves no diff +// review at all — legal (skip means skip), but never silent: under the old +// contract skip:['codex'] meant "review with Claude". +if (!reviewProvider) log('no diff review will run — every reviewer is skipped') const reviewPrompt = `Code-review this branch's diff vs ${base} (git diff ${base}...HEAD), coverage-first: walk every hunk, no spot checks. ` + 'Find pass — candidate defects across all dimensions: correctness/logic, ISR & concurrency safety, ' +
diff --git a/CLAUDE.md b/CLAUDE.md index ebe2ba6..ae42228 100644 --- a/CLAUDE.md +++ b/CLAUDE.md
@@ -22,7 +22,7 @@ - Use `/codex:rescue` for substantial bounded implementation, diagnosis, or a second pass when Claude is stuck; use its `--background`, `--resume`, and `--fresh` controls when needed. - When Codex should use a named TinyUSB role, select its `.codex/agents/<role>.toml` adapter; the adapter loads `.claude/agents/<role>.md` as the canonical role. - Reviews and research may run beside Claude. For write-capable delegation, use a separate worktree if Claude continues editing; otherwise yield the current worktree to Codex until it finishes. Never let both edit overlapping files in one worktree. -- `.claude/workflows/*.js` remain the canonical Claude Code orchestration. Root workflows route `code-verifier` work through `code-verify`: Codex by default, or Claude/both when selected. Because workflows nest only one level, `validate` dispatches the same verifier agents directly when called by `full-check`. The Codex subprocess lives only in `.claude/agents/codex-code-verifier.md`; never copy it into a workflow. +- `.claude/workflows/*.js` remain the canonical Claude Code orchestration. Root workflows route `code-verifier` work through `code-verify`: Codex by default, or Claude/both when selected. Because workflows nest only one level, `validate` dispatches the same verifier agents directly when called by `full-check`, with the same default: pass `reviewProvider: 'claude'|'both'` (through `full-check` too) to spend a Claude review, or nothing runs but Codex. The Codex subprocess lives only in `.claude/agents/codex-code-verifier.md`; never copy it into a workflow. ## Ground Rules