diff --git a/README.md b/README.md index fcfc15e..18a1dd2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # devloop -`devloop` runs a Codex implementation loop with Claude as the reviewer. +`devloop` runs a configurable implementation and review loop. By default, Codex codes and Claude Code reviews. -Codex makes the change. Claude reviews it. If Claude rejects it, Codex gets the review and tries again. The loop stops when the work is accepted, stalls, becomes unclear, reaches the max turn count, or an agent fails. +The coder makes the change. The reviewer reviews it. If the reviewer rejects it, the coder gets the review and tries again. The loop stops when the work is accepted, stalls, becomes unclear, reaches the max turn count, or an agent fails. ## Install @@ -31,7 +31,7 @@ devloop .specs/change.md Full form: ```sh -devloop [--plain|--tui] [--in-place] [--no-strict] [--report-format html|markdown] spec.md [max=5] +devloop [--plain|--tui] [--in-place] [--no-strict] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] spec.md [max=5] ``` Common examples: @@ -41,6 +41,7 @@ devloop .specs/change.md devloop --plain .specs/change.md devloop --tui .specs/change.md devloop --report-format markdown .specs/change.md 3 +devloop --coder claude --reviewer codex .specs/change.md ``` ## Write A Spec @@ -66,14 +67,15 @@ Strict mode is on by default. In strict mode, the spec must include: By default, `devloop`: - runs up to 5 passes, clamped between 1 and 10 +- uses Codex as the coder and Claude Code as the reviewer - uses the TUI in a terminal and plain output elsewhere - writes an HTML report -- requires Claude to pass every acceptance criterion +- requires the reviewer to pass every acceptance criterion - creates an isolated sibling git worktree and runs agents there - creates a local branch and commit when the run is accepted - never pushes or opens a PR -Use `--plain` for CI. Use `--tui` to force the TUI. Use `--in-place` to opt out of the isolated worktree and run in the current checkout. Use `--no-strict` only when you want weaker acceptance gates. +Use `--plain` for CI. Use `--tui` to force the TUI. Use `--coder` and `--reviewer` to choose `codex` or `claude` for either role. Use `--in-place` to opt out of the isolated worktree and run in the current checkout. Use `--no-strict` only when you want weaker acceptance gates. ## Output @@ -85,13 +87,14 @@ Each run writes files under `.codex/`: .codex/reports/.html .codex/reports/.md .codex/logs/ -.codex/sessions/ +.codex/sessions/-coder-.id +.codex/sessions/-reviewer-.id .codex/specs/.md ``` With the default isolated worktree, these files are written inside the generated sibling worktree named `-`. The original checkout is left on its current branch, and uncommitted files in that checkout are not included in the run. The spec is snapshotted into `.codex/specs/.md` inside the worktree. The final CLI/TUI output prints the worktree path and absolute report/track paths. -Before creating the worktree, `devloop` asks Codex to read the spec and repository and return the semantic work item identity. That identity supplies ``, branch type, and breaking-change status. Explicit spec frontmatter wins when set: +Before creating the worktree, `devloop` asks the configured coder to read the spec and repository and return the semantic work item identity. That identity supplies ``, branch type, and breaking-change status. Explicit spec frontmatter wins when set: ```yaml type: fix @@ -130,7 +133,7 @@ If `worktree remove` reports local modifications, inspect the worktree first or ## Development -Prereqs: `bun`, `codex`, `claude`, and `git`. +Prereqs: `bun`, `git`, and the agents you configure. The defaults require `codex` and `claude`. ```sh bun scripts/install.ts diff --git a/src/cli.ts b/src/cli.ts index d32a124..20979ef 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,18 +54,25 @@ function printResult(result: { max: number; report: string; track: string; + branch?: string; + commit?: string; worktree?: string; sourceRepo?: string; + coder?: string; + reviewer?: string; }) { console.log(""); - console.log(`result: ${result.status}`); - console.log(`passes: ${result.passes} / ${result.max}`); - if ("branch" in result) console.log(`branch: ${result.branch}`); - if ("commit" in result) console.log(`commit: ${result.commit || "none"}`); + console.log(resultLine("result", result.status)); + console.log(resultLine("passes", `${result.passes} / ${result.max}`)); + if (result.coder) console.log(resultLine("coder", result.coder)); + if (result.reviewer) console.log(resultLine("reviewer", result.reviewer)); + if (result.branch) console.log(resultLine("branch", result.branch)); + if (result.commit !== undefined) + console.log(resultLine("commit", result.commit || "none")); if (hasWorktreeInfo(result) && isIsolatedWorktree(result)) - console.log(`worktree: ${result.worktree}`); - console.log(`report: ${displayPath(result, result.report)}`); - console.log(`track: ${displayPath(result, result.track)}`); + console.log(resultLine("worktree", result.worktree)); + console.log(resultLine("report", displayPath(result, result.report))); + console.log(resultLine("track", displayPath(result, result.track))); } function hasWorktreeInfo(result: { @@ -81,3 +88,7 @@ function displayPath( ) { return hasWorktreeInfo(result) ? resultPath(result, file) : file; } + +function resultLine(label: string, value: string) { + return `${`${label}:`.padEnd(10)}${value}`; +} diff --git a/src/devloop.ts b/src/devloop.ts index 9ac3323..d73631f 100644 --- a/src/devloop.ts +++ b/src/devloop.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; export type ReportFormat = "html" | "markdown"; +export type Agent = "codex" | "claude"; export type Verdict = "ACCEPT" | "REJECT" | "UNCLEAR"; export type Status = | "accepted" @@ -20,8 +21,8 @@ export type Status = | "max-turns" | "unclear" | "no-verdict" - | "codex-error" - | "claude-error" + | "coder-error" + | "reviewer-error" | "review-missing" | "commit-error"; @@ -38,6 +39,8 @@ export type Options = { reportFormat: ReportFormat; strict: boolean; worktree: boolean; + coder: Agent; + reviewer: Agent; cwd: string; }; @@ -52,8 +55,10 @@ export type Result = { commitMessage: string; worktree: string; sourceRepo: string; - codexSessionId: string; - claudeSessionId: string; + coder: Agent; + reviewer: Agent; + coderSessionId: string; + reviewerSessionId: string; }; export type Event = @@ -94,7 +99,7 @@ export const LOGO = [ export function welcome() { return `${LOGO} -Spec-driven code and review loop. Codex implements, Claude reviews. +Spec-driven code and review loop. Codex implements and Claude Code reviews by default. Usage: devloop [options] [max=5] @@ -104,11 +109,14 @@ Common commands: devloop --tui .specs/change.md devloop --plain .specs/change.md devloop --report-format markdown .specs/change.md 3 + devloop --coder claude --reviewer codex .specs/change.md bun scripts/install.ts Options: --tui force the collapsed TUI --plain force plain output + --coder codex|claude choose the implementation agent + --reviewer codex|claude choose the review agent --report-format html|markdown choose report format --no-strict weaken acceptance gates --in-place run in the current worktree @@ -122,6 +130,8 @@ export function parseArgs( let reportFormat: ReportFormat = "html"; let strict = true; let worktree = true; + let coder: Agent = "codex"; + let reviewer: Agent = "claude"; let spec = ""; let maxRaw = "5"; let maxSet = false; @@ -133,6 +143,14 @@ export function parseArgs( if (value !== "html" && value !== "markdown" && value !== "md") return usage(); reportFormat = value === "md" ? "markdown" : value; + } else if (arg === "--coder") { + const value = parseAgent(argv[++i]); + if (!value) return `coder must be codex or claude\n${usage()}`; + coder = value; + } else if (arg === "--reviewer") { + const value = parseAgent(argv[++i]); + if (!value) return `reviewer must be codex or claude\n${usage()}`; + reviewer = value; } else if (arg === "--html") reportFormat = "html"; else if (arg === "--markdown" || arg === "--md") reportFormat = "markdown"; else if (arg === "--no-strict") strict = false; @@ -157,12 +175,14 @@ export function parseArgs( reportFormat, strict, worktree, + coder, + reviewer, cwd, }; } export function usage() { - return "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--report-format html|markdown] [max=5]"; + return "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [max=5]"; } export function parseCriteria(markdown: string): string[] { @@ -264,7 +284,7 @@ export async function runDevloop( await sink.event({ type: "step", id: namingId, - title: "derive branch name", + title: `derive branch name with ${agentLabel(options.coder)}`, }); let namingLog = ""; let namingError = ""; @@ -277,6 +297,7 @@ export async function runDevloop( "naming.log", ); return resolveWorkItem({ + agent: options.coder, runner: makeRunner(sourceRepo, sink), repo: sourceRepo, spec, @@ -342,8 +363,8 @@ export async function runDevloop( ).trim(); const track = `.codex/tracks/${slug}.md`; const report = `.codex/reports/${slug}.${options.reportFormat === "html" ? "html" : "md"}`; - const codexSession = `.codex/sessions/${slug}-codex.id`; - const claudeSession = `.codex/sessions/${slug}-claude.id`; + const coderSession = `.codex/sessions/${slug}-coder-${options.coder}.id`; + const reviewerSession = `.codex/sessions/${slug}-reviewer-${options.reviewer}.id`; const runner = makeRunner(repo, sink); await initTrack(path.join(repo, track), { spec: runSpec, @@ -357,6 +378,8 @@ export async function runDevloop( max: options.max, reportFormat: options.reportFormat, strict: options.strict, + coder: options.coder, + reviewer: options.reviewer, type: work.type, breaking: work.breaking, }); @@ -369,19 +392,20 @@ export async function runDevloop( let finalBranch = runBranch; for (pass = 1; pass <= options.max; pass++) { - const codexLog = `.codex/logs/${slug}-r${pass}-codex.log`; - const codexId = `codex-${pass}`; + const coderLog = `.codex/logs/${slug}-r${pass}-coder.log`; + const coderId = `coder-${pass}`; await sink.event({ type: "step", - id: codexId, - title: `pass ${pass}/${options.max} codex`, + id: coderId, + title: `pass ${pass}/${options.max} ${agentLabel(options.coder)} implementation`, }); - const codex = await runCodex( + const coded = await runAgent( + options.coder, runner, repo, - path.join(repo, codexSession), - path.join(repo, codexLog), - codexPrompt({ + path.join(repo, coderSession), + path.join(repo, coderLog), + coderPrompt({ spec: runSpec, track, pass, @@ -389,32 +413,35 @@ export async function runDevloop( previous: `.codex/reviews/${slug}-r${pass - 1}.md`, criteria, }), + coderId, ); await sink.event({ type: "done", - id: codexId, - ok: codex, - detail: codex ? "completed" : "failed", + id: coderId, + ok: coded, + detail: coded ? "completed" : "failed", }); - if (!codex) { - status = "codex-error"; + if (!coded) { + status = "coder-error"; break; } const review = `.codex/reviews/${slug}-r${pass}.md`; - const claudeLog = `.codex/logs/${slug}-r${pass}-claude.log`; - const claudeId = `claude-${pass}`; + const reviewerLog = `.codex/logs/${slug}-r${pass}-reviewer.log`; + const reviewerId = `reviewer-${pass}`; await sink.event({ type: "step", - id: claudeId, - title: `pass ${pass}/${options.max} claude review`, + id: reviewerId, + title: `pass ${pass}/${options.max} ${agentLabel(options.reviewer)} review`, }); - const ok = await runClaude( + const ok = await runAgent( + options.reviewer, runner, repo, - path.join(repo, claudeSession), - path.join(repo, claudeLog), + path.join(repo, reviewerSession), + path.join(repo, reviewerLog), reviewPrompt({ + coder: options.coder, spec: runSpec, track, base, @@ -424,15 +451,16 @@ export async function runDevloop( criteria, strict: options.strict, }), + reviewerId, ); await sink.event({ type: "done", - id: claudeId, + id: reviewerId, ok, detail: ok ? "completed" : "failed", }); if (!ok) { - status = "claude-error"; + status = "reviewer-error"; break; } @@ -512,10 +540,11 @@ export async function runDevloop( } } - const codexSessionId = await readLine(path.join(repo, codexSession)); - const claudeSessionId = await readLine(path.join(repo, claudeSession)); + const coderSessionId = await readLine(path.join(repo, coderSession)); + const reviewerSessionId = await readLine(path.join(repo, reviewerSession)); await synthesizeReport(runner, repo, { slug, + reviewer: options.reviewer, spec: runSpec, specText, sourceSpec: spec, @@ -531,8 +560,10 @@ export async function runDevloop( branch: finalBranch, commit, commitMessage, - codexSessionId, - claudeSessionId, + coder: options.coder, + reviewerSessionFile: path.join(repo, reviewerSession), + coderSessionId, + reviewerSessionId, format: options.reportFormat, reviews: listReviews(slug, pass, options.max), }); @@ -547,8 +578,10 @@ export async function runDevloop( commitMessage, worktree: repo, sourceRepo, - codexSessionId, - claudeSessionId, + coder: options.coder, + reviewer: options.reviewer, + coderSessionId, + reviewerSessionId, }; await sink.event({ type: "result", result }); return result; @@ -797,6 +830,8 @@ async function initTrack( max: number; reportFormat: ReportFormat; strict: boolean; + coder: Agent; + reviewer: Agent; type: WorkType; breaking: boolean; }, @@ -804,7 +839,7 @@ async function initTrack( if (await stat(file).catch(() => false)) return; await writeFile( file, - `# Track: ${path.basename(file, ".md")}\n\n- spec: ${data.spec}\n- source-spec: ${data.sourceSpec}\n- cwd: ${data.cwd}\n- source-repo: ${data.sourceRepo}\n- worktree: ${data.worktree}\n- base: ${data.base}\n- branch: ${data.branch}\n- worktree-branch: ${data.worktreeBranch}\n- type: ${data.type}\n- breaking: ${data.breaking}\n- max: ${data.max}\n- report-format: ${data.reportFormat}\n- strict: ${data.strict}\n- started: ${new Date().toISOString()}\n\n`, + `# Track: ${path.basename(file, ".md")}\n\n- spec: ${data.spec}\n- source-spec: ${data.sourceSpec}\n- cwd: ${data.cwd}\n- source-repo: ${data.sourceRepo}\n- worktree: ${data.worktree}\n- base: ${data.base}\n- branch: ${data.branch}\n- worktree-branch: ${data.worktreeBranch}\n- coder: ${data.coder}\n- reviewer: ${data.reviewer}\n- type: ${data.type}\n- breaking: ${data.breaking}\n- max: ${data.max}\n- report-format: ${data.reportFormat}\n- strict: ${data.strict}\n- started: ${new Date().toISOString()}\n\n`, ); } @@ -818,12 +853,52 @@ async function writeLine(file: string, value: string) { await writeFile(file, `${value}\n`); } +async function runAgent( + agent: Agent, + runner: Runner, + repo: string, + sessionFile: string, + log: string, + prompt: string, + id: string, +) { + return agent === "codex" + ? runCodex(runner, repo, sessionFile, log, prompt, id) + : runClaude(runner, repo, sessionFile, log, prompt, id); +} + +async function runAgentOnce( + agent: Agent, + runner: Runner, + repo: string, + log: string, + prompt: string, + id: string, +) { + return agent === "codex" + ? runner( + "codex", + ["exec", "-s", "read-only", "-C", repo, "-"], + prompt, + log, + id, + ) + : runner( + "claude", + ["-p", "--dangerously-skip-permissions", "--add-dir", repo], + prompt, + log, + id, + ); +} + async function runCodex( runner: Runner, repo: string, sessionFile: string, log: string, prompt: string, + id: string, ) { const session = await readLine(sessionFile); const args = session @@ -835,7 +910,7 @@ async function runCodex( "-", ] : ["exec", "--dangerously-bypass-approvals-and-sandbox", "-C", repo, "-"]; - const result = await runner("codex", args, prompt, log, logId(log, "codex")); + const result = await runner("codex", args, prompt, log, id); if (result.code !== 0) return false; if (!session) { const next = extractSessionId(result.output); @@ -851,6 +926,7 @@ async function runClaude( sessionFile: string, log: string, prompt: string, + id: string, ) { const session = await readLine(sessionFile); const next = session || randomUUID(); @@ -876,18 +952,13 @@ async function runClaude( args, prompt, log, - logId(log, "claude"), + id, ); if (result.code !== 0) return false; if (!session) await writeLine(sessionFile, next); return true; } -function logId(log: string, kind: "codex" | "claude") { - const pass = log.match(new RegExp(`r(\\d+)-${kind}`))?.[1]; - return pass ? `${kind}-${pass}` : kind === "codex" ? "codex" : "report"; -} - function extractSessionId(output: string) { return output .split(/\r?\n/) @@ -915,7 +986,7 @@ function criteriaBlock(criteria: string[]) { ); } -function codexPrompt(input: { +function coderPrompt(input: { spec: string; track: string; pass: number; @@ -932,6 +1003,7 @@ function codexPrompt(input: { } function reviewPrompt(input: { + coder: Agent; spec: string; track: string; base: string; @@ -941,7 +1013,7 @@ function reviewPrompt(input: { criteria: string[]; strict: boolean; }) { - return `You are reviewing a Codex implementation. Be a senior reviewer, not a linter.\n\nSpec: ${input.spec}\nTrack: ${input.track}\nBase: ${input.base}\nPass: ${input.pass}\nPrior reviews:\n${input.priors}\nAcceptance criteria:\n${criteriaBlock(input.criteria)}\nOutput path: ${input.output}\n\nSteps:\n1. Read the spec and track.\n2. Run: git diff ${input.base}...HEAD\n3. Read prior reviews so you do not repeat resolved findings.\n4. Write the review to ${input.output} using this exact format:\n\n# Claude review ${input.pass}\n\nVerdict: \n\n## Acceptance matrix\n\n- AC1: - \n\n## Findings\n\n1. [severity] - . Root cause: . Principle: .\n\n## Missing tests\n\n- \n\n## Fix instructions\n\n1. \n\n## Notes\n\n- \n\nRules:\n- The verdict line must appear verbatim.\n- ACCEPT requires every acceptance criterion PASS with concrete evidence.${input.strict ? "\n- ACCEPT also requires regression-test evidence, red/green evidence when behavior changed, passing full tests, and 100% coverage when coverage tooling exists." : ""}\n- For ACCEPT: Findings and Fix instructions bodies are "None".\n- Findings must explain WHY, not just WHAT.\n`; + return `You are reviewing a ${agentLabel(input.coder)} implementation. Be a senior reviewer, not a linter.\n\nSpec: ${input.spec}\nTrack: ${input.track}\nBase: ${input.base}\nPass: ${input.pass}\nPrior reviews:\n${input.priors}\nAcceptance criteria:\n${criteriaBlock(input.criteria)}\nOutput path: ${input.output}\n\nSteps:\n1. Read the spec and track.\n2. Run: git diff ${input.base}...HEAD\n3. Read prior reviews so you do not repeat resolved findings.\n4. Write the review to ${input.output} using this exact format:\n\n# Review ${input.pass}\n\nVerdict: \n\n## Acceptance matrix\n\n- AC1: - \n\n## Findings\n\n1. [severity] - . Root cause: . Principle: .\n\n## Missing tests\n\n- \n\n## Fix instructions\n\n1. \n\n## Notes\n\n- \n\nRules:\n- The verdict line must appear verbatim.\n- ACCEPT requires every acceptance criterion PASS with concrete evidence.${input.strict ? "\n- ACCEPT also requires regression-test evidence, red/green evidence when behavior changed, passing full tests, and 100% coverage when coverage tooling exists." : ""}\n- For ACCEPT: Findings and Fix instructions bodies are "None".\n- Findings must explain WHY, not just WHAT.\n`; } async function synthesizeReport( @@ -949,6 +1021,7 @@ async function synthesizeReport( repo: string, input: { slug: string; + reviewer: Agent; spec: string; specText: string; sourceSpec: string; @@ -964,8 +1037,10 @@ async function synthesizeReport( branch: string; commit: string; commitMessage: string; - codexSessionId: string; - claudeSessionId: string; + coder: Agent; + reviewerSessionFile: string; + coderSessionId: string; + reviewerSessionId: string; format: ReportFormat; reviews: string; }, @@ -983,8 +1058,10 @@ Starting branch: ${input.initialBranch} Final branch: ${input.branch} Local commit: ${input.commit || "none"} Commit message: ${input.commitMessage || "none"} -Codex session: ${input.codexSessionId || "unknown"} -Claude session: ${input.claudeSessionId || "unknown"} +Coder: ${agentLabel(input.coder)} +Reviewer: ${agentLabel(input.reviewer)} +Coder session: ${input.coderSessionId || "unknown"} +Reviewer session: ${input.reviewerSessionId || "unknown"} Track: ${input.track} Reviews: ${input.reviews}`; @@ -992,36 +1069,15 @@ ${input.reviews}`; input.format === "html" ? `Write the report to ${input.report} as valid standalone HTML. Use a readable document layout with embedded CSS, set the HTML to the report title, render the report title and subtitle before Metadata, render a topical three-line haiku immediately after the subtitle, use a compact metadata table, and add substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.` : `Write the report to ${input.report} in markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`; - const sessionFile = path.join( + await runAgent( + input.reviewer, + runner, repo, - `.codex/sessions/${input.slug}-claude.id`, - ); - const session = await readLine(sessionFile); - const next = session || randomUUID(); - await runner( - "claude", - session - ? [ - "-p", - "--resume", - session, - "--dangerously-skip-permissions", - "--add-dir", - repo, - ] - : [ - "-p", - "--session-id", - next, - "--dangerously-skip-permissions", - "--add-dir", - repo, - ], - `You are writing a learning-oriented post-mortem for a developer who just ran a Codex/Claude devloop.\n\nReport framing to render visibly near the top, before Metadata:\nTitle: ${framing.title}\nSubtitle: ${framing.subtitle}\nHaiku: Compose a three-line haiku, 5/7/5 syllables if possible, about this specific work.\nHaiku topic: ${framing.title} - ${framing.subtitle}\n\nUse that exact title and subtitle. The subtitle must be specific to this work, not a generic or hard-coded tagline. The haiku must be topical, concrete, and rendered immediately after the subtitle before Metadata.\n\nMetadata to render exactly and visibly:\n${metadata}\n\nInputs:\n- spec: ${input.spec}\n- track: ${input.track}\nReview files:\n${input.reviews}\n- final status: ${input.status}\n- passes used: ${input.pass} / ${input.max}\n- base: ${input.base}, starting branch: ${input.initialBranch}, final branch: ${input.branch}, local commit: ${input.commit || "none"}\n\n${body}\n\nStyle:\n- Human readable, not ornamental.\n- Preserve useful substance over brevity.\n- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.\n- No emoji.\n`, + input.reviewerSessionFile, path.join(repo, `.codex/logs/${input.slug}-report.log`), + `You are writing a learning-oriented post-mortem for a developer who just ran a devloop.\n\nReport framing to render visibly near the top, before Metadata:\nTitle: ${framing.title}\nSubtitle: ${framing.subtitle}\nHaiku: Compose a three-line haiku, 5/7/5 syllables if possible, about this specific work.\nHaiku topic: ${framing.title} - ${framing.subtitle}\n\nUse that exact title and subtitle. The subtitle must be specific to this work, not a generic or hard-coded tagline. The haiku must be topical, concrete, and rendered immediately after the subtitle before Metadata.\n\nMetadata to render exactly and visibly:\n${metadata}\n\nInputs:\n- spec: ${input.spec}\n- track: ${input.track}\nReview files:\n${input.reviews}\n- final status: ${input.status}\n- passes used: ${input.pass} / ${input.max}\n- base: ${input.base}, starting branch: ${input.initialBranch}, final branch: ${input.branch}, local commit: ${input.commit || "none"}\n\n${body}\n\nStyle:\n- Human readable, not ornamental.\n- Preserve useful substance over brevity.\n- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.\n- No emoji.\n`, "report", ); - if (!session) await writeLine(sessionFile, next); } function reportTitle(specText: string) { @@ -1069,7 +1125,19 @@ function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } +function parseAgent(value: string | undefined): Agent | undefined { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "codex") return "codex"; + if (normalized === "claude") return "claude"; + return undefined; +} + +function agentLabel(agent: Agent) { + return agent === "codex" ? "Codex" : "Claude Code"; +} + async function resolveWorkItem(input: { + agent: Agent; runner: Runner; repo: string; spec: string; @@ -1084,20 +1152,23 @@ async function resolveWorkItem(input: { } async function deriveWorkItem(input: { + agent: Agent; runner: Runner; repo: string; spec: string; specText: string; log: string; }) { - const result = await input.runner( - "codex", - ["exec", "-s", "read-only", "-C", input.repo, "-"], - namingPrompt(input.spec, input.specText), + const result = await runAgentOnce( + input.agent, + input.runner, + input.repo, input.log, + namingPrompt(input.spec, input.specText), "naming", ); - if (result.code !== 0) throw new Error(result.output || "codex failed"); + if (result.code !== 0) + throw new Error(result.output || `${input.agent} failed`); return parseWorkItem(result.stdout || result.output); } diff --git a/src/tui-view.ts b/src/tui-view.ts index c2fbedf..76e60b6 100644 --- a/src/tui-view.ts +++ b/src/tui-view.ts @@ -18,18 +18,24 @@ export function view(rows: Row[], selected: number, result?: Result, spinnerFram const tail = result ? [ "", - `result: ${result.status}`, - `passes: ${result.passes} / ${result.max}`, - `branch: ${result.branch}`, - `commit: ${result.commit || "none"}`, - ...(isIsolatedWorktree(result) ? [`worktree: ${result.worktree}`] : []), - `report: ${resultPath(result, result.report)}`, - `track: ${resultPath(result, result.track)}`, + resultLine("result", result.status), + resultLine("passes", `${result.passes} / ${result.max}`), + resultLine("coder", result.coder), + resultLine("reviewer", result.reviewer), + resultLine("branch", result.branch), + resultLine("commit", result.commit || "none"), + ...(isIsolatedWorktree(result) ? [resultLine("worktree", result.worktree)] : []), + resultLine("report", resultPath(result, result.report)), + resultLine("track", resultPath(result, result.track)), ] : ["", "enter toggles logs, ↑/↓ moves"]; return [LOGO, "", ...body, ...tail].join("\n"); } +function resultLine(label: string, value: string) { + return `${`${label}:`.padEnd(10)}${value}`; +} + function icon(status: Row["status"], spinnerFrame: number) { if (status === "ok") return "ok"; if (status === "fail") return "!!"; diff --git a/tests/devloop.test.ts b/tests/devloop.test.ts index 14a4c66..a6652bb 100644 --- a/tests/devloop.test.ts +++ b/tests/devloop.test.ts @@ -6,6 +6,7 @@ import { parseArgs, parseCriteria, parseVerdict, reportFraming, runDevloop, welc const root = await mkdtemp(path.join(tmpdir(), "devloop-test.")); let oldPath = process.env.PATH ?? ""; +const defaultAgents = { coder: "codex", reviewer: "claude" } as const; afterAll(async () => rm(root, { recursive: true, force: true })); beforeEach(() => { @@ -30,9 +31,14 @@ describe("parsing", () => { reportFormat: "markdown", strict: false, worktree: true, + coder: "codex", + reviewer: "claude", cwd: "/x", } satisfies Options); expect(parseArgs(["--in-place", "spec.md"], "/x")).toMatchObject({ worktree: false }); + expect(parseArgs(["--coder", "claude", "--reviewer", "codex", "spec.md"], "/x")).toMatchObject({ coder: "claude", reviewer: "codex" }); + expect(parseArgs(["--coder", "gpt", "spec.md"], "/x")).toContain("coder must be codex or claude"); + expect(parseArgs(["--reviewer", "gpt", "spec.md"], "/x")).toContain("reviewer must be codex or claude"); expect(parseArgs(["spec.md", "0"], "/x")).toMatchObject({ max: 1 }); expect(parseArgs(["spec.md", "99"], "/x")).toMatchObject({ max: 10 }); expect(parseArgs(["--wat"], "/x")).toContain("unknown option"); @@ -98,8 +104,12 @@ describe("loop", () => { await exists(path.join(worktree, ".codex/reviews/change-r1.md")); await exists(path.join(worktree, ".codex/reports/change.html")); await exists(path.join(worktree, ".codex/logs/change-naming.log")); - expect(await readFile(path.join(worktree, ".codex/sessions/change-codex.id"), "utf8")).toContain("00000000-0000-4000-8000-000000000001"); + expect(result.coder).toBe("codex"); + expect(result.reviewer).toBe("claude"); + expect(await readFile(path.join(worktree, ".codex/sessions/change-coder-codex.id"), "utf8")).toContain("00000000-0000-4000-8000-000000000001"); expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- strict: true"); + expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- coder: codex"); + expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain("- reviewer: claude"); expect(await readFile(path.join(worktree, ".codex/tracks/change.md"), "utf8")).toContain(`- source-repo: ${repo}`); expect(await readFile(path.join(worktree, ".codex/reviews/change-r1.md"), "utf8")).toContain("- AC1: PASS"); const codexArgs = await readFile(path.join(state, "codex-args.log"), "utf8"); @@ -111,7 +121,9 @@ describe("loop", () => { expect(await Bun.$`git -C ${worktree} show --name-only --format= HEAD`.text()).toContain("feature.txt"); expect(await Bun.$`git -C ${worktree} show --name-only --format= HEAD`.text()).not.toContain(".codex/"); const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8"); - expect(reportPrompt).toContain("Codex session: 00000000-0000-4000-8000-000000000001"); + expect(reportPrompt).toContain("Coder: Codex"); + expect(reportPrompt).toContain("Reviewer: Claude Code"); + expect(reportPrompt).toContain("Coder session: 00000000-0000-4000-8000-000000000001"); expect(reportPrompt).toContain("Final branch: feat/change"); expect(reportPrompt).toContain(`Worktree: ${worktree}`); expect(reportPrompt).toContain(`Local commit: ${result.commit}`); @@ -125,7 +137,7 @@ describe("loop", () => { expect(events).toContainEqual({ type: "done", id: "naming", ok: true, detail: "feat/change" }); expect(events).toContainEqual({ type: "done", id: "worktree", ok: true, detail: worktree }); expect(events.some((event) => event.type === "gate" && event.name === "acceptance criteria" && event.ok)).toBe(true); - expect(events).toContainEqual({ type: "log", id: "codex-1", line: "codex-tail" }); + expect(events).toContainEqual({ type: "log", id: "coder-1", line: "codex-tail" }); }); test("rejects then accepts with resumed sessions", async () => { @@ -140,6 +152,27 @@ describe("loop", () => { expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain("exec resume --dangerously-bypass-approvals-and-sandbox 00000000-0000-4000-8000-000000000001 -"); }); + test("supports swapping coder and reviewer agents", async () => { + const { repo, state } = await fixture("swapped-agents"); + process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; + const { result, events } = await run(repo, { coder: "claude", reviewer: "codex" }); + + expect(result.status).toBe("accepted"); + expect(result.coder).toBe("claude"); + expect(result.reviewer).toBe("codex"); + expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- coder: claude"); + expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- reviewer: codex"); + const claudePrompts = await readFile(path.join(state, "claude-prompts.log"), "utf8"); + const codexPrompts = await readFile(path.join(state, "codex-prompts.log"), "utf8"); + expect(claudePrompts).toContain("Work item naming task."); + expect(claudePrompts).toContain("You are implementing against an approved spec."); + expect(codexPrompts).toContain("You are reviewing a Claude Code implementation."); + expect(codexPrompts).toContain("Coder: Claude Code"); + expect(codexPrompts).toContain("Reviewer: Codex"); + expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain("exec resume --dangerously-bypass-approvals-and-sandbox 00000000-0000-4000-8000-000000000001 -"); + expect(events).toContainEqual({ type: "log", id: "coder-1", line: "claude-tail" }); + }); + test("stalls on repeated reject findings", async () => { const { repo } = await fixture("stall"); process.env.DEVLOOP_TEST_VERDICTS = "REJECT,REJECT"; @@ -241,13 +274,13 @@ describe("loop", () => { process.env.DEVLOOP_TEST_VERDICTS = "REJECT,ACCEPT"; process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"change-with-spaces","breaking":false}'; - const spaced = await runDevloop({ spec: work.specPath, max: 2, reportFormat: "html", strict: true, worktree: true, cwd: work.repo }); + const spaced = await runDevloop({ spec: work.specPath, max: 2, reportFormat: "html", strict: true, worktree: true, cwd: work.repo, ...defaultAgents }); expect(spaced.status).toBe("accepted"); await exists(path.join(spaced.worktree, ".codex/reviews/change-with-spaces-r2.md")); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"external-spec","breaking":false}'; - const external = await runDevloop({ spec: specOnly.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: work.repo }); + const external = await runDevloop({ spec: specOnly.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: work.repo, ...defaultAgents }); expect(external.status).toBe("accepted"); await exists(path.join(external.worktree, ".codex/tracks/external-spec.md")); expect(await exists(path.join(work.repo, ".codex"), false)).toBe(false); @@ -270,7 +303,7 @@ describe("loop", () => { const { repo, specPath } = await fixture("dated-spec", spec, "2026-05-26-minimal-ai-sdk-chat-orchestration.md"); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"feat","slug":"minimal-ai-sdk-chat-orchestration","breaking":false}'; - const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo }); + const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents }); expect(result.status).toBe("accepted"); expect(result.branch).toBe("fix!/minimal-ai-sdk-chat-orchestration"); @@ -298,7 +331,7 @@ describe("loop", () => { const { repo, state, specPath } = await fixture("frontmatter-name", spec, "ignored-filename.md"); process.env.DEVLOOP_TEST_FAIL_NAMING = "1"; process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; - const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo }); + const result = await runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents }); expect(result.status).toBe("accepted"); expect(result.branch).toBe("chore/readme-refresh"); @@ -310,7 +343,7 @@ describe("loop", () => { process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"fix","slug":"noisy-json","breaking":false}'; process.env.DEVLOOP_TEST_NOISY_NAMING = "1"; process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; - const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo }); + const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents }); expect(result.status).toBe("accepted"); expect(result.branch).toBe("fix/noisy-json"); @@ -321,12 +354,12 @@ describe("loop", () => { const fix = await fixture("fix-prefix", undefined, "fix-null-check.md"); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"fix","slug":"null-check","breaking":false}'; - expect((await runDevloop({ spec: fix.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: fix.repo })).branch).toBe("fix/null-check"); + expect((await runDevloop({ spec: fix.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: fix.repo, ...defaultAgents })).branch).toBe("fix/null-check"); const chore = await fixture("chore-prefix", undefined, "docs-readme-refresh.md"); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; process.env.DEVLOOP_TEST_WORK_ITEM = '{"type":"chore","slug":"readme-refresh","breaking":false}'; - expect((await runDevloop({ spec: chore.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: chore.repo })).branch).toBe("chore/readme-refresh"); + expect((await runDevloop({ spec: chore.specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: chore.repo, ...defaultAgents })).branch).toBe("chore/readme-refresh"); }); test("rejects invalid codex-derived work names", async () => { @@ -350,13 +383,13 @@ describe("loop", () => { ].join("\n"); const { repo, specPath } = await fixture("bad-breaking", spec); - await expect(runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo })).rejects.toThrow("frontmatter breaking must be true or false"); + await expect(runDevloop({ spec: specPath, max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents })).rejects.toThrow("frontmatter breaking must be true or false"); }); test("requires acceptance criteria in strict mode", async () => { const { repo } = await fixture("no-criteria", "# Spec\n"); await expect(run(repo)).rejects.toThrow("strict mode requires ## Acceptance criteria"); - await expect(runDevloop({ spec: path.join(repo, ".specs/missing.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo })).rejects.toThrow("usage:"); + await expect(runDevloop({ spec: path.join(repo, ".specs/missing.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents })).rejects.toThrow("usage:"); }); test("allows missing criteria only when strict is off", async () => { @@ -381,12 +414,12 @@ describe("loop", () => { test("handles agent and review failures", async () => { const codex = await fixture("codex-fail"); process.env.DEVLOOP_TEST_FAIL_CODEX = "1"; - expect((await run(codex.repo)).result.status).toBe("codex-error"); + expect((await run(codex.repo)).result.status).toBe("coder-error"); delete process.env.DEVLOOP_TEST_FAIL_CODEX; const claude = await fixture("claude-fail"); process.env.DEVLOOP_TEST_FAIL_CLAUDE = "1"; - expect((await run(claude.repo)).result.status).toBe("claude-error"); + expect((await run(claude.repo)).result.status).toBe("reviewer-error"); delete process.env.DEVLOOP_TEST_FAIL_CLAUDE; const missing = await fixture("missing-review"); @@ -409,14 +442,14 @@ describe("loop", () => { await rm(path.join(missingClaude.repo, "../bin/claude"), { force: true }); process.env.PATH = `${path.join(missingClaude.repo, "../bin")}:/usr/bin:/bin`; process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; - expect((await run(missingClaude.repo)).result.status).toBe("claude-error"); + expect((await run(missingClaude.repo)).result.status).toBe("reviewer-error"); }); test("falls back to main when no base branch exists", async () => { const { repo } = await fixture("no-base"); await Bun.$`git -C ${repo} branch -m topic`.quiet(); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; - const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo }); + const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents }); expect(result.status).toBe("accepted"); expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- base: main"); }); @@ -426,7 +459,7 @@ describe("loop", () => { await Bun.$`git -C ${repo} update-ref refs/remotes/origin/trunk HEAD`.quiet(); await Bun.$`git -C ${repo} symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/trunk`.quiet(); process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT"; - const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo }); + const result = await runDevloop({ spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents }); expect(result.status).toBe("accepted"); expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- base: trunk"); @@ -475,6 +508,37 @@ if [[ "$prompt" == Work\\ item\\ naming\\ task.* ]]; then exit 0 fi [[ -z "\${DEVLOOP_TEST_FAIL_CODEX:-}" ]] || exit 42 +if [[ "$prompt" == *"Output path:"* ]]; then + [[ -z "\${DEVLOOP_TEST_NO_REVIEW:-}" ]] || exit 0 + review_file=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Output path: /{print $2; exit}') + count=$(( $(cat "$DEVLOOP_TEST_STATE/codex-review-count" 2>/dev/null || echo 0) + 1 )) + printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/codex-review-count" + IFS=',' read -r -a verdicts <<< "\${DEVLOOP_TEST_VERDICTS:-ACCEPT}" + verdict="\${verdicts[$(( count <= \${#verdicts[@]} ? count - 1 : \${#verdicts[@]} - 1 ))]}" + mkdir -p "$(dirname "$review_file")" + { + printf '# Review %s\\n\\n' "$count" + [[ -n "\${DEVLOOP_TEST_NO_VERDICT:-}" ]] || printf 'Verdict: %s\\n\\n' "$verdict" + if [[ -z "\${DEVLOOP_TEST_NO_MATRIX:-}" ]]; then + printf '## Acceptance matrix\\n\\n' + printf -- '- AC1: PASS - mock evidence\\n\\n' + fi + printf '## Findings\\n\\n' + if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. [must-fix] devloop.ts:1 - repeated fixture finding. Root cause: mock review. Principle: deterministic retry behavior.\\n\\n'; fi + printf '## Missing tests\\n\\n- None\\n\\n## Fix instructions\\n\\n' + if [[ "$verdict" == "ACCEPT" ]]; then printf 'None\\n\\n'; else printf '1. Fix the repeated fixture finding.\\n\\n'; fi + printf '## Notes\\n\\n- None\\n' + } > "$review_file" + printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n' + exit 0 +fi +report_file=$(printf '%s\\n' "$prompt" | sed -n 's/^Write the report to \\([^ ]*\\).*/\\1/p' | head -n 1) +if [[ -n "$report_file" ]]; then + mkdir -p "$(dirname "$report_file")" + printf '# mock devloop report\\n' > "$report_file" + printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n' + exit 0 +fi count=$(( $(cat "$DEVLOOP_TEST_STATE/codex-count" 2>/dev/null || echo 0) + 1 )) printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/codex-count" track=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Track: /{print $2; exit}') @@ -495,6 +559,17 @@ prompt=$(cat) mkdir -p "$DEVLOOP_TEST_STATE" printf '%s\\n' "$*" >> "$DEVLOOP_TEST_STATE/claude-args.log" printf '%s\\n---\\n' "$prompt" >> "$DEVLOOP_TEST_STATE/claude-prompts.log" +if [[ "$prompt" == Work\\ item\\ naming\\ task.* ]]; then + [[ -z "\${DEVLOOP_TEST_FAIL_NAMING:-}" ]] || exit 42 + [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'trace {"ignore":true}\\n' + if [[ -n "\${DEVLOOP_TEST_WORK_ITEM:-}" ]]; then + printf '%s\\n' "$DEVLOOP_TEST_WORK_ITEM" + else + printf '%s\\n' '{"type":"feat","slug":"change","breaking":false}' + fi + [[ -z "\${DEVLOOP_TEST_NOISY_NAMING:-}" ]] || printf 'tail {not json}\\n' + exit 0 +fi if [[ "$prompt" == *"Output path:"* ]]; then [[ -z "\${DEVLOOP_TEST_NO_REVIEW:-}" ]] || exit 0 review_file=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Output path: /{print $2; exit}') @@ -504,7 +579,7 @@ if [[ "$prompt" == *"Output path:"* ]]; then verdict="\${verdicts[$(( count <= \${#verdicts[@]} ? count - 1 : \${#verdicts[@]} - 1 ))]}" mkdir -p "$(dirname "$review_file")" { - printf '# Claude review %s\\n\\n' "$count" + printf '# Review %s\\n\\n' "$count" [[ -n "\${DEVLOOP_TEST_NO_VERDICT:-}" ]] || printf 'Verdict: %s\\n\\n' "$verdict" if [[ -z "\${DEVLOOP_TEST_NO_MATRIX:-}" ]]; then printf '## Acceptance matrix\\n\\n' @@ -518,7 +593,18 @@ if [[ "$prompt" == *"Output path:"* ]]; then } > "$review_file" else report_file=$(printf '%s\\n' "$prompt" | sed -n 's/^Write the report to \\([^ ]*\\).*/\\1/p' | head -n 1) - [[ -z "$report_file" ]] || { mkdir -p "$(dirname "$report_file")"; printf '# mock devloop report\\n' > "$report_file"; } + if [[ -n "$report_file" ]]; then + mkdir -p "$(dirname "$report_file")" + printf '# mock devloop report\\n' > "$report_file" + else + count=$(( $(cat "$DEVLOOP_TEST_STATE/claude-count" 2>/dev/null || echo 0) + 1 )) + printf '%s\\n' "$count" > "$DEVLOOP_TEST_STATE/claude-count" + track=$(printf '%s\\n' "$prompt" | awk -F': ' '/^Track: /{print $2; exit}') + [[ -z "$track" ]] || printf '\\n## Pass %s - mock claude\\n- verification: fixture\\n' "$count" >> "$track" + printf 'feature pass %s\\n' "$count" >> feature.txt + printf 'claude pass %s\\n' "$count" + printf 'claude-tail' >&2 + fi fi `, { mode: 0o755 }, @@ -528,7 +614,7 @@ fi async function run(repo: string, overrides: Partial<Options> = {}) { const events: Event[] = []; const result = await runDevloop( - { spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...overrides }, + { spec: path.join(repo, ".specs/change.md"), max: 1, reportFormat: "html", strict: true, worktree: true, cwd: repo, ...defaultAgents, ...overrides }, { event: (event) => void events.push(event) }, ); return { result, events }; diff --git a/tests/tui-view.test.ts b/tests/tui-view.test.ts index 9d0d497..9b3ffe5 100644 --- a/tests/tui-view.test.ts +++ b/tests/tui-view.test.ts @@ -47,16 +47,19 @@ describe("tui view", () => { commitMessage: "", worktree: "/tmp/repo-change", sourceRepo: "/tmp/repo", - codexSessionId: "codex-session", - claudeSessionId: "claude-session", + coder: "codex", + reviewer: "claude", + coderSessionId: "codex-session", + reviewerSessionId: "claude-session", }); expect(output).toContain("> !! run tests - failed"); - expect(output).toContain("result: commit-error"); - expect(output).toContain("commit: none"); + expect(output).toContain("result: commit-error"); + expect(output).toContain("reviewer: claude"); + expect(output).toContain("commit: none"); expect(output).toContain("worktree: /tmp/repo-change"); - expect(output).toContain("report: /tmp/repo-change/.codex/reports/change.html"); - expect(output).toContain("track: /tmp/repo-change/.codex/tracks/change.md"); + expect(output).toContain("report: /tmp/repo-change/.codex/reports/change.html"); + expect(output).toContain("track: /tmp/repo-change/.codex/tracks/change.md"); }); test("suppresses worktree details for in-place results", () => { @@ -71,12 +74,14 @@ describe("tui view", () => { commitMessage: "feat: change", worktree: "/tmp/repo", sourceRepo: "/tmp/repo", - codexSessionId: "codex-session", - claudeSessionId: "claude-session", + coder: "codex", + reviewer: "claude", + coderSessionId: "codex-session", + reviewerSessionId: "claude-session", }); expect(output).not.toContain("worktree:"); - expect(output).toContain("report: .codex/reports/change.html"); - expect(output).toContain("track: .codex/tracks/change.md"); + expect(output).toContain("report: .codex/reports/change.html"); + expect(output).toContain("track: .codex/tracks/change.md"); }); });