diff --git a/README.md b/README.md
index 80e15bf..1360622 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ devloop .specs/change.md
Full form:
```sh
-devloop [--plain|--tui] [--in-place] [--no-strict] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] spec.md [max=5]
+devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] spec.md [max=5]
```
Common examples:
@@ -49,6 +49,7 @@ 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
+devloop --create-pr .specs/change.md
```
## Generate A Spec
@@ -100,10 +101,10 @@ By default, `devloop`:
- requires the reviewer to pass every acceptance criterion with implementation and test evidence
- asks the reviewer to flag silent decisions, scope drift, and missing tests
- creates an isolated sibling git worktree and runs agents there
-- creates a local branch and commit when the run is accepted
+- creates one conventional commit after each coder pass, before review
- never pushes or opens a PR
-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.
+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 `--create-pr` or `--pr` to push an accepted branch to `origin` and open a GitHub pull request with `gh pr create --fill --base --head `. If PR creation fails after the push succeeds, the report keeps the pushed branch name so you can open the PR manually. Use `--no-strict` only when you want weaker acceptance gates.
## Output
@@ -120,7 +121,7 @@ Each run writes files under `.codex/`:
.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.
+With the default isolated worktree, these files are written inside the generated sibling worktree named `-`. Agents and git commands run against that worktree explicitly. 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 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:
@@ -132,7 +133,7 @@ breaking: true
When `type`, `slug`, and `breaking` are all set, `devloop` skips the naming call.
-On acceptance, `devloop` creates or reuses a branch like:
+Before the first review, `devloop` creates or reuses a branch like:
```text
feat/
@@ -142,11 +143,12 @@ chore/
Breaking changes use `!`, for example `feat!/`.
-It commits only files that were clean when the run started. It excludes `.codex/`. Commit messages use:
+After each coder pass, `devloop` commits the current eligible diff before handing the work to the reviewer. It commits only files that were clean when the run started and excludes `.codex/`. Commit messages use:
```text
feat:
feat!:
+fix:
```
devloop intentionally keeps generated worktrees and branches for inspection after both successful and failed runs. To remove one when you are done:
diff --git a/src/cli.ts b/src/cli.ts
index 8c0aaa7..129fc39 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -64,6 +64,7 @@ function printResult(result: {
track: string;
branch?: string;
commit?: string;
+ pullRequest?: string;
worktree?: string;
sourceRepo?: string;
coder?: string;
@@ -77,6 +78,7 @@ function printResult(result: {
if (result.branch) console.log(resultLine("branch", result.branch));
if (result.commit !== undefined)
console.log(resultLine("commit", result.commit || "none"));
+ if (result.pullRequest) console.log(resultLine("pr", result.pullRequest));
if (hasWorktreeInfo(result) && isIsolatedWorktree(result))
console.log(resultLine("worktree", result.worktree));
console.log(resultLine("report", displayPath(result, result.report)));
diff --git a/src/devloop.ts b/src/devloop.ts
index 83a71ad..e089929 100644
--- a/src/devloop.ts
+++ b/src/devloop.ts
@@ -24,7 +24,8 @@ export type Status =
| "coder-error"
| "reviewer-error"
| "review-missing"
- | "commit-error";
+ | "commit-error"
+ | "pr-error";
type WorkType = "feat" | "fix" | "chore";
type WorkItem = {
@@ -33,6 +34,13 @@ type WorkItem = {
breaking: boolean;
};
+export type CommitRecord = {
+ pass: number;
+ commit: string;
+ message: string;
+ paths: string[];
+};
+
export type Options = {
spec: string;
max: number;
@@ -42,6 +50,7 @@ export type Options = {
coder: Agent;
reviewer: Agent;
cwd: string;
+ createPr?: boolean;
};
export type Result = {
@@ -53,12 +62,15 @@ export type Result = {
branch: string;
commit: string;
commitMessage: string;
+ commits: CommitRecord[];
worktree: string;
sourceRepo: string;
coder: Agent;
reviewer: Agent;
coderSessionId: string;
reviewerSessionId: string;
+ pullRequest?: string;
+ pullRequestError?: string;
};
export type Event =
@@ -111,6 +123,7 @@ Common commands:
devloop --plain .specs/change.md
devloop --report-format markdown .specs/change.md 3
devloop --coder claude --reviewer codex .specs/change.md
+ devloop --create-pr .specs/change.md
bun scripts/install.ts
Options:
@@ -121,6 +134,7 @@ Options:
--report-format html|markdown choose report format
--no-strict weaken acceptance gates
--in-place run in the current worktree
+ --create-pr, --pr push accepted branch and open a PR
-h, --help show this screen`;
}
@@ -133,6 +147,7 @@ export function parseArgs(
let worktree = true;
let coder: Agent = "codex";
let reviewer: Agent = "claude";
+ let createPr = false;
let spec = "";
let maxRaw = "5";
let maxSet = false;
@@ -157,6 +172,7 @@ export function parseArgs(
else if (arg === "--no-strict") strict = false;
else if (arg === "--strict") strict = true;
else if (arg === "--in-place") worktree = false;
+ else if (arg === "--create-pr" || arg === "--pr") createPr = true;
else if (arg === "--plain" || arg === "--tui") continue;
else if (arg === "-h" || arg === "--help") return usage();
else if (arg.startsWith("--")) return `unknown option: ${arg}\n${usage()}`;
@@ -179,11 +195,12 @@ export function parseArgs(
coder,
reviewer,
cwd,
+ createPr,
};
}
export function usage() {
- return "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [max=5]";
+ return "usage: devloop [--plain|--tui] [--in-place] [--no-strict] [--create-pr|--pr] [--coder codex|claude] [--reviewer codex|claude] [--report-format html|markdown] [max=5]";
}
export function parseCriteria(markdown: string): string[] {
@@ -383,6 +400,7 @@ export async function runDevloop(
reviewer: options.reviewer,
type: work.type,
breaking: work.breaking,
+ createPr: Boolean(options.createPr),
});
let status: Status = "max-turns";
@@ -391,6 +409,9 @@ export async function runDevloop(
let commit = "";
let commitMessage = "";
let finalBranch = runBranch;
+ const commits: CommitRecord[] = [];
+ let pullRequest = "";
+ let pullRequestError = "";
for (pass = 1; pass <= options.max; pass++) {
const coderLog = `.codex/logs/${slug}-r${pass}-coder.log`;
@@ -427,6 +448,49 @@ export async function runDevloop(
break;
}
+ const commitId = `commit-${pass}`;
+ await sink.event({
+ type: "step",
+ id: commitId,
+ title: `pass ${pass}/${options.max} commit`,
+ });
+ let commitError = "";
+ const committed = await commitPass({
+ repo,
+ work,
+ pass,
+ initialDirty,
+ }).catch((error) => {
+ commitError = error instanceof Error ? error.message : String(error);
+ return undefined;
+ });
+ if (!committed) {
+ status = "commit-error";
+ await sink.event({
+ type: "done",
+ id: commitId,
+ ok: false,
+ detail: commitError || "failed",
+ });
+ break;
+ }
+ if (committed.branch) finalBranch = committed.branch;
+ const passCommits = committed.commits;
+ commits.push(...passCommits);
+ const latest = passCommits.at(-1);
+ if (latest) {
+ commit = latest.commit;
+ commitMessage = latest.message;
+ }
+ await sink.event({
+ type: "done",
+ id: commitId,
+ ok: true,
+ detail: passCommits.length
+ ? `${passCommits.length} commit${passCommits.length === 1 ? "" : "s"}`
+ : "no changes",
+ });
+
const review = `.codex/reviews/${slug}-r${pass}.md`;
const reviewerLog = `.codex/logs/${slug}-r${pass}-reviewer.log`;
const reviewerId = `reviewer-${pass}`;
@@ -504,39 +568,34 @@ export async function runDevloop(
}
if (pass > options.max) pass = options.max;
- if (status === "accepted") {
- const commitId = "commit";
+ if (options.createPr && status === "accepted") {
+ const prId = "pull-request";
await sink.event({
type: "step",
- id: commitId,
- title: "local branch and commit",
+ id: prId,
+ title: "push branch and create PR",
});
- let commitError = "";
- const committed = await commitAccepted(repo, work, initialDirty).catch(
+ const published = await createPullRequest(repo, finalBranch, base).catch(
(error) => {
- commitError = error instanceof Error ? error.message : String(error);
+ pullRequestError = error instanceof Error ? error.message : String(error);
return undefined;
},
);
- if (committed) {
- finalBranch = committed.branch;
- commit = committed.commit;
- commitMessage = committed.message;
+ if (published) {
+ pullRequest = published.url;
await sink.event({
type: "done",
- id: commitId,
+ id: prId,
ok: true,
- detail: commit
- ? `${finalBranch} ${commit}`
- : `${finalBranch} no changes`,
+ detail: pullRequest || `${published.remote}/${published.branch}`,
});
} else {
- status = "commit-error";
+ status = "pr-error";
await sink.event({
type: "done",
- id: commitId,
+ id: prId,
ok: false,
- detail: commitError || "failed",
+ detail: pullRequestError || "failed",
});
}
}
@@ -561,6 +620,9 @@ export async function runDevloop(
branch: finalBranch,
commit,
commitMessage,
+ commits,
+ pullRequest,
+ pullRequestError,
coder: options.coder,
reviewerSessionFile: path.join(repo, reviewerSession),
coderSessionId,
@@ -577,6 +639,9 @@ export async function runDevloop(
branch: finalBranch,
commit,
commitMessage,
+ commits,
+ pullRequest,
+ pullRequestError,
worktree: repo,
sourceRepo,
coder: options.coder,
@@ -594,8 +659,13 @@ async function absoluteFile(file: string, cwd: string) {
return realpath(full);
}
-async function command(cmd: string, args: string[]) {
- const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" });
+async function command(cmd: string, args: string[], cwd?: string) {
+ const proc = Bun.spawn([cmd, ...args], {
+ cwd,
+ stdout: "pipe",
+ stderr: "pipe",
+ env: Bun.env,
+ });
const [out, err, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
@@ -610,6 +680,24 @@ async function command(cmd: string, args: string[]) {
return out;
}
+async function createPullRequest(repo: string, branch: string, base: string) {
+ const remote = "origin";
+ await command("git", ["-C", repo, "push", "-u", remote, branch]);
+ const output = await command(
+ "gh",
+ ["pr", "create", "--fill", "--base", base, "--head", branch],
+ repo,
+ ).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(`pushed ${remote}/${branch}, but PR creation failed: ${message}`);
+ });
+ return {
+ url: output.split(/\s+/).find((part) => /^https?:\/\//.test(part)) ?? output.trim(),
+ remote,
+ branch,
+ };
+}
+
async function createWorktree(repo: string, work: WorkItem) {
const branch = await nextBranch(repo, work, "");
const worktree = await nextWorktreePath(repo, branchLeaf(branch));
@@ -691,26 +779,32 @@ async function statusPaths(repo: string) {
return paths;
}
-async function commitAccepted(
- repo: string,
- work: WorkItem,
- initialDirty: Set,
-) {
+async function switchToWorkBranch(repo: string, work: WorkItem) {
const current = (
await command("git", ["-C", repo, "branch", "--show-current"])
).trim();
const branch = await nextBranch(repo, work, current);
- const message = `${work.type}${work.breaking ? "!" : ""}: ${work.slug}`;
if (branch !== current)
await command("git", ["-C", repo, "switch", "-c", branch]);
- const changed = [...(await statusPaths(repo))].filter(
- (file) => !initialDirty.has(file) && !file.startsWith(".codex/"),
- );
- if (changed.length === 0) return { branch, commit: "", message };
- await command("git", ["-C", repo, "add", "--", ...changed]);
+ return branch;
+}
+
+async function commitPass(input: {
+ repo: string;
+ work: WorkItem;
+ pass: number;
+ initialDirty: Set;
+}): Promise<
+ { branch: string; commits: CommitRecord[] } | { branch: ""; commits: [] }
+> {
+ const changed = await committablePaths(input.repo, input.initialDirty);
+ if (changed.length === 0) return { branch: "", commits: [] };
+ const branch = await switchToWorkBranch(input.repo, input.work);
+ const message = passCommitMessage(input.work, input.pass);
+ await command("git", ["-C", input.repo, "add", "--", ...changed]);
await command("git", [
"-C",
- repo,
+ input.repo,
"commit",
"--only",
"-m",
@@ -720,13 +814,31 @@ async function commitAccepted(
]);
return {
branch,
- commit: (
- await command("git", ["-C", repo, "rev-parse", "--short", "HEAD"])
- ).trim(),
- message,
+ commits: [
+ {
+ pass: input.pass,
+ commit: (
+ await command("git", ["-C", input.repo, "rev-parse", "--short", "HEAD"])
+ ).trim(),
+ message,
+ paths: changed,
+ },
+ ],
};
}
+async function committablePaths(repo: string, initialDirty: Set) {
+ return [...(await statusPaths(repo))]
+ .filter((file) => !initialDirty.has(file) && !file.startsWith(".codex/"))
+ .sort();
+}
+
+function passCommitMessage(work: WorkItem, pass: number) {
+ if (pass === 1) return `${work.type}${work.breaking ? "!" : ""}: ${work.slug}`;
+ const type = work.type === "chore" ? "chore" : "fix";
+ return `${type}: ${work.slug}`;
+}
+
async function nextBranch(repo: string, work: WorkItem, current: string) {
const base = branchBase(work);
if (
@@ -835,12 +947,13 @@ async function initTrack(
reviewer: Agent;
type: WorkType;
breaking: boolean;
+ createPr: boolean;
},
) {
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- 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`,
+ `# 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- create-pr: ${data.createPr}\n- started: ${new Date().toISOString()}\n\n`,
);
}
@@ -1017,6 +1130,17 @@ function reviewPrompt(input: {
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| Criterion | Status | Implementation evidence | Test evidence |\n| --- | --- | --- | --- |\n| AC1 | | | |\n\n## Review flags\n\n- Silent decision: - \n- Scope drift: - \n- Missing test: - \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 implementation evidence and concrete test 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- Flag a silent decision when the diff makes a tradeoff, default choice, compatibility choice, migration choice, or risk acceptance that is not recorded in the spec or track.\n- Flag scope drift when the diff changes behavior, public API, dependencies, files, or architecture outside the acceptance criteria, or includes a broad refactor not needed for the spec.\n- Flag a missing test when behavior changed without targeted test evidence, even if the full suite passed.\n- Use UNCLEAR only when spec ambiguity prevents a defensible ACCEPT or REJECT, and put the exact question in Notes.\n- For ACCEPT: Findings and Fix instructions bodies are "None".\n- Findings must explain WHY, not just WHAT.\n`;
}
+function commitLines(commits: CommitRecord[]) {
+ return commits.length
+ ? commits
+ .map(
+ (item) =>
+ `- pass ${item.pass} ${item.commit} ${item.message} (${item.paths.join(", ")})`,
+ )
+ .join("\n")
+ : "- none";
+}
+
async function synthesizeReport(
runner: Runner,
repo: string,
@@ -1038,6 +1162,9 @@ async function synthesizeReport(
branch: string;
commit: string;
commitMessage: string;
+ commits: CommitRecord[];
+ pullRequest: string;
+ pullRequestError: string;
coder: Agent;
reviewerSessionFile: string;
coderSessionId: string;
@@ -1059,6 +1186,10 @@ Starting branch: ${input.initialBranch}
Final branch: ${input.branch}
Local commit: ${input.commit || "none"}
Commit message: ${input.commitMessage || "none"}
+Commits:
+${commitLines(input.commits)}
+Pull request: ${input.pullRequest || "none"}
+Pull request error: ${input.pullRequestError || "none"}
Coder: ${agentLabel(input.coder)}
Reviewer: ${agentLabel(input.reviewer)}
Coder session: ${input.coderSessionId || "unknown"}
@@ -1076,7 +1207,7 @@ ${input.reviews}`;
repo,
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`,
+ `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- pull request: ${input.pullRequest || "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",
);
}
diff --git a/src/tui-view.ts b/src/tui-view.ts
index 76e60b6..0e6a025 100644
--- a/src/tui-view.ts
+++ b/src/tui-view.ts
@@ -24,6 +24,7 @@ export function view(rows: Row[], selected: number, result?: Result, spinnerFram
resultLine("reviewer", result.reviewer),
resultLine("branch", result.branch),
resultLine("commit", result.commit || "none"),
+ ...(result.pullRequest ? [resultLine("pr", result.pullRequest)] : []),
...(isIsolatedWorktree(result) ? [resultLine("worktree", result.worktree)] : []),
resultLine("report", resultPath(result, result.report)),
resultLine("track", resultPath(result, result.track)),
diff --git a/tests/devloop.test.ts b/tests/devloop.test.ts
index d89f2d9..d50efe3 100644
--- a/tests/devloop.test.ts
+++ b/tests/devloop.test.ts
@@ -21,6 +21,9 @@ beforeEach(() => {
delete process.env.DEVLOOP_TEST_FAIL_CLAUDE;
delete process.env.DEVLOOP_TEST_NOISY_NAMING;
delete process.env.DEVLOOP_TEST_WORK_ITEM;
+ delete process.env.DEVLOOP_TEST_MULTI_COMMIT;
+ delete process.env.DEVLOOP_TEST_NO_CODE_CHANGE;
+ delete process.env.DEVLOOP_TEST_FAIL_GH;
});
describe("parsing", () => {
@@ -34,8 +37,11 @@ describe("parsing", () => {
coder: "codex",
reviewer: "claude",
cwd: "/x",
+ createPr: false,
} satisfies Options);
expect(parseArgs(["--in-place", "spec.md"], "/x")).toMatchObject({ worktree: false });
+ expect(parseArgs(["--create-pr", "spec.md"], "/x")).toMatchObject({ createPr: true });
+ expect(parseArgs(["--pr", "spec.md"], "/x")).toMatchObject({ createPr: true });
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");
@@ -84,6 +90,7 @@ describe("parsing", () => {
expect(welcome()).toContain("____/ /__");
expect(welcome()).toContain("Common commands:");
expect(welcome()).toContain("devloop .specs/change.md");
+ expect(welcome()).toContain("--create-pr");
expect(welcome()).toContain("bun scripts/install.ts");
});
});
@@ -100,6 +107,7 @@ describe("loop", () => {
expect(result.branch).toBe("feat/change");
expect(result.commit).toMatch(/^[0-9a-f]+$/);
expect(result.commitMessage).toBe("feat: change");
+ expect(result.commits).toEqual([{ pass: 1, commit: result.commit, message: "feat: change", paths: ["feature.txt"] }]);
expect(result.sourceRepo).toBe(repo);
expect(worktree).not.toBe(repo);
await exists(path.join(worktree, ".codex/specs/change.md"));
@@ -111,6 +119,7 @@ describe("loop", () => {
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("- create-pr: false");
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}`);
@@ -131,6 +140,9 @@ describe("loop", () => {
expect(reportPrompt).toContain(`Worktree: ${worktree}`);
expect(reportPrompt).toContain(`Local commit: ${result.commit}`);
expect(reportPrompt).toContain("Commit message: feat: change");
+ expect(reportPrompt).toContain(`- pass 1 ${result.commit} feat: change (feature.txt)`);
+ expect(reportPrompt).toContain("Pull request: none");
+ expect(reportPrompt).toContain("Pull request error: none");
expect(reportPrompt).toContain("Title: Fixture spec");
expect(reportPrompt).toContain("Subtitle: The loop runs deterministically under test.");
expect(reportPrompt).toContain("Haiku: Compose a three-line haiku");
@@ -146,6 +158,7 @@ describe("loop", () => {
expect(reportPrompt).toContain("Use UNCLEAR only when spec ambiguity prevents a defensible ACCEPT or REJECT");
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).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "1 commit" });
expect(events.some((event) => event.type === "gate" && event.name === "acceptance criteria" && event.ok)).toBe(true);
expect(events).toContainEqual({ type: "log", id: "coder-1", line: "codex-tail" });
});
@@ -157,6 +170,7 @@ describe("loop", () => {
expect(result.status).toBe("accepted");
expect(result.passes).toBe(2);
+ expect(result.commits.map((item) => item.message)).toEqual(["feat: change", "fix: change"]);
expect(await readFile(path.join(result.worktree, ".codex/reviews/change-r1.md"), "utf8")).toContain("Verdict: REJECT");
expect(await readFile(path.join(result.worktree, ".codex/reviews/change-r2.md"), "utf8")).toContain("Verdict: ACCEPT");
expect(await readFile(path.join(state, "codex-args.log"), "utf8")).toContain("exec resume --dangerously-bypass-approvals-and-sandbox 00000000-0000-4000-8000-000000000001 -");
@@ -245,6 +259,20 @@ describe("loop", () => {
expect(events.some((event) => event.type === "step" && event.id === "worktree")).toBe(false);
});
+ test("does not create an in-place branch when a coder pass has no eligible changes", async () => {
+ const { repo } = await fixture("in-place-no-changes");
+ process.env.DEVLOOP_TEST_NO_CODE_CHANGE = "1";
+ process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
+ const { result, events } = await run(repo, { worktree: false });
+
+ expect(result.status).toBe("accepted");
+ expect(result.branch).toBe("main");
+ expect(result.commit).toBe("");
+ expect(result.commits).toEqual([]);
+ expect((await Bun.$`git -C ${repo} branch --show-current`.text()).trim()).toBe("main");
+ expect(events).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "no changes" });
+ });
+
test("reports commit errors", async () => {
const { repo } = await fixture("commit-error");
await writeFile(path.join(repo, ".git/hooks/pre-commit"), "#!/usr/bin/env bash\necho 'pre-commit blocked commit' >&2\nexit 1\n", { mode: 0o755 });
@@ -252,7 +280,74 @@ describe("loop", () => {
const { result, events } = await run(repo);
expect(result.status).toBe("commit-error");
- expect(events).toContainEqual({ type: "done", id: "commit", ok: false, detail: "pre-commit blocked commit" });
+ expect(events).toContainEqual({ type: "done", id: "commit-1", ok: false, detail: "pre-commit blocked commit" });
+ });
+
+ test("creates one bundled commit per coder pass", async () => {
+ const { repo } = await fixture("multi-file-pass");
+ process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
+ process.env.DEVLOOP_TEST_MULTI_COMMIT = "1";
+ const { result, events } = await run(repo);
+
+ expect(result.status).toBe("accepted");
+ expect(result.commits).toEqual([{ pass: 1, commit: result.commit, message: "feat: change", paths: ["api.txt", "ui.txt"] }]);
+ expect(result.commitMessage).toBe("feat: change");
+ expect((await Bun.$`git -C ${result.worktree} log --format=%s --reverse main..HEAD`.text()).trim()).toBe("feat: change");
+ expect(events).toContainEqual({ type: "done", id: "commit-1", ok: true, detail: "1 commit" });
+ });
+
+ test("optionally pushes and opens a pull request", async () => {
+ const { repo, state } = await fixture("create-pr");
+ const remote = path.join(path.dirname(repo), "remote.git");
+ await Bun.$`git init -q --bare ${remote}`.quiet();
+ await Bun.$`git -C ${repo} remote add origin ${remote}`.quiet();
+ process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
+ const { result, events } = await run(repo, { createPr: true });
+
+ expect(result.status).toBe("accepted");
+ expect(result.pullRequest).toBe("https://github.com/example/repo/pull/42");
+ expect(result.pullRequestError).toBe("");
+ expect(await Bun.$`git -C ${repo} ls-remote --heads origin feat/change`.text()).toContain("refs/heads/feat/change");
+ expect(await readFile(path.join(state, "gh-args.log"), "utf8")).toBe("pr create --fill --base main --head feat/change\n");
+ expect(await readFile(path.join(state, "gh-pwd.log"), "utf8")).toBe(`${result.worktree}\n`);
+ expect(await readFile(path.join(result.worktree, ".codex/tracks/change.md"), "utf8")).toContain("- create-pr: true");
+ const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
+ expect(reportPrompt).toContain("Pull request: https://github.com/example/repo/pull/42");
+ expect(reportPrompt).toContain("- pull request: https://github.com/example/repo/pull/42");
+ expect(events).toContainEqual({ type: "done", id: "pull-request", ok: true, detail: "https://github.com/example/repo/pull/42" });
+ });
+
+ test("reports pull request publishing failures", async () => {
+ const { repo, state } = await fixture("create-pr-fail");
+ process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
+ const { result, events } = await run(repo, { createPr: true });
+
+ expect(result.status).toBe("pr-error");
+ expect(result.pullRequest).toBe("");
+ expect(result.pullRequestError).toContain("origin");
+ const error = result.pullRequestError ?? "";
+ expect(events).toContainEqual({ type: "done", id: "pull-request", ok: false, detail: error });
+ const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
+ expect(reportPrompt).toContain("Result: pr-error");
+ expect(reportPrompt).toContain("Pull request: none");
+ expect(reportPrompt).toContain("Pull request error:");
+ });
+
+ test("reports a pushed branch when pull request creation fails after push", async () => {
+ const { repo, state } = await fixture("create-pr-after-push-fail");
+ const remote = path.join(path.dirname(repo), "remote.git");
+ await Bun.$`git init -q --bare ${remote}`.quiet();
+ await Bun.$`git -C ${repo} remote add origin ${remote}`.quiet();
+ process.env.DEVLOOP_TEST_FAIL_GH = "1";
+ process.env.DEVLOOP_TEST_VERDICTS = "ACCEPT";
+ const { result } = await run(repo, { createPr: true });
+
+ expect(result.status).toBe("pr-error");
+ expect(result.pullRequestError).toContain("pushed origin/feat/change");
+ expect(result.pullRequestError).toContain("gh blocked");
+ expect(await Bun.$`git -C ${repo} ls-remote --heads origin feat/change`.text()).toContain("refs/heads/feat/change");
+ const reportPrompt = await readFile(path.join(state, "claude-prompts.log"), "utf8");
+ expect(reportPrompt).toContain("pushed origin/feat/change");
});
test("uses a suffixed branch when the default branch exists", async () => {
@@ -345,7 +440,7 @@ describe("loop", () => {
expect(result.status).toBe("accepted");
expect(result.branch).toBe("chore/readme-refresh");
- expect(await readFile(path.join(state, "codex-args.log"), "utf8")).not.toContain("-s read-only");
+ expect(await readFile(path.join(state, "codex-prompts.log"), "utf8")).not.toContain("Work item naming task.");
});
test("parses noisy codex naming output", async () => {
@@ -556,7 +651,14 @@ 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}')
[[ -z "$track" ]] || printf '\\n## Pass %s - mock codex\\n- verification: fixture\\n' "$count" >> "$track"
-printf 'feature pass %s\\n' "$count" >> feature.txt
+if [[ -z "\${DEVLOOP_TEST_NO_CODE_CHANGE:-}" ]]; then
+ if [[ -n "\${DEVLOOP_TEST_MULTI_COMMIT:-}" ]]; then
+ printf 'api pass %s\\n' "$count" >> api.txt
+ printf 'ui pass %s\\n' "$count" >> ui.txt
+ else
+ printf 'feature pass %s\\n' "$count" >> feature.txt
+ fi
+fi
printf 'codex pass %s\\n' "$count"
printf 'To continue this session, run codex exec resume 00000000-0000-4000-8000-000000000001\\n'
printf 'codex-tail' >&2
@@ -617,11 +719,30 @@ else
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
+ if [[ -z "\${DEVLOOP_TEST_NO_CODE_CHANGE:-}" ]]; then
+ if [[ -n "\${DEVLOOP_TEST_MULTI_COMMIT:-}" ]]; then
+ printf 'api pass %s\\n' "$count" >> api.txt
+ printf 'ui pass %s\\n' "$count" >> ui.txt
+ else
+ printf 'feature pass %s\\n' "$count" >> feature.txt
+ fi
+ fi
printf 'claude pass %s\\n' "$count"
printf 'claude-tail' >&2
fi
fi
+`,
+ { mode: 0o755 },
+ );
+ await writeFile(
+ path.join(bin, "gh"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+mkdir -p "$DEVLOOP_TEST_STATE"
+printf '%s\\n' "$*" >> "$DEVLOOP_TEST_STATE/gh-args.log"
+printf '%s\\n' "$PWD" >> "$DEVLOOP_TEST_STATE/gh-pwd.log"
+[[ -z "\${DEVLOOP_TEST_FAIL_GH:-}" ]] || { echo 'gh blocked' >&2; exit 42; }
+printf 'https://github.com/example/repo/pull/42\\n'
`,
{ mode: 0o755 },
);
diff --git a/tests/tui-view.test.ts b/tests/tui-view.test.ts
index 9b3ffe5..85c7876 100644
--- a/tests/tui-view.test.ts
+++ b/tests/tui-view.test.ts
@@ -45,6 +45,8 @@ describe("tui view", () => {
branch: "feat/change",
commit: "",
commitMessage: "",
+ commits: [],
+ pullRequest: "https://github.com/example/repo/pull/42",
worktree: "/tmp/repo-change",
sourceRepo: "/tmp/repo",
coder: "codex",
@@ -57,6 +59,7 @@ describe("tui view", () => {
expect(output).toContain("result: commit-error");
expect(output).toContain("reviewer: claude");
expect(output).toContain("commit: none");
+ expect(output).toContain("pr: https://github.com/example/repo/pull/42");
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");
@@ -72,6 +75,7 @@ describe("tui view", () => {
branch: "feat/change",
commit: "abc123",
commitMessage: "feat: change",
+ commits: [{ pass: 1, commit: "abc123", message: "feat: change", paths: ["feature.txt"] }],
worktree: "/tmp/repo",
sourceRepo: "/tmp/repo",
coder: "codex",