diff --git a/AGENTS.md b/AGENTS.md index 459a027..f9f1ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -Please keep the code dead simple and keep the `src/loop/main.ts` file under 100 lines of code. +Please keep the code dead-simple and keep the `src/loop/main.ts` file under 150 lines of code. # Quick Commands diff --git a/README.md b/README.md index 3bd1ff2..f85132d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # loop -Dead simple Bun CLI that runs `codex` and `claude` in a loop. The [main loop](https://github.com/axeldelafosse/loop/blob/main/src/loop/main.ts#L14) is ~50 lines of easy-to-read code. +Dead-simple Bun CLI that runs `codex` and `claude` in a loop. The [main loop](https://github.com/axeldelafosse/loop/blob/main/src/loop/main.ts#L14) is ~50 lines of easy-to-read code. Install: ```bash diff --git a/src/cli.ts b/src/cli.ts index a9ec5e9..b8c9dc9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,10 @@ export const runCli = async (argv: string[]): Promise => { if (opts.tmux && cliDeps.runInTmux(argv)) { return; } + const gitWarning = cliDeps.checkGitState(); + if (gitWarning) { + console.log(gitWarning); + } await cliDeps.maybeEnterWorktree(opts); const task = await cliDeps.resolveTask(opts); await cliDeps.runLoop(task, opts); diff --git a/src/loop/deps.ts b/src/loop/deps.ts index 517600f..fe85bb1 100644 --- a/src/loop/deps.ts +++ b/src/loop/deps.ts @@ -1,4 +1,5 @@ import { parseArgs } from "./args"; +import { checkGitState } from "./git"; import { runLoop } from "./main"; import { runPanel } from "./panel"; import { resolveTask } from "./task"; @@ -6,6 +7,7 @@ import { runInTmux } from "./tmux"; import { maybeEnterWorktree } from "./worktree"; export const cliDeps = { + checkGitState, maybeEnterWorktree, parseArgs, resolveTask, diff --git a/src/loop/git.ts b/src/loop/git.ts index d916bbf..a75fe2c 100644 --- a/src/loop/git.ts +++ b/src/loop/git.ts @@ -7,6 +7,7 @@ export interface GitResult { } const SAFE_NAME_RE = /[^a-z0-9-]+/g; +const MAIN_BRANCHES = new Set(["main", "master"]); export const decode = (value: Uint8Array | null | undefined): string => value ? new TextDecoder().decode(value).trim() : ""; @@ -38,3 +39,31 @@ export const runGit = ( stdout: decode(result.stdout), }; }; + +export const checkGitState = ( + deps: { runGit?: (args: string[]) => GitResult } = {} +): string | undefined => { + const git = deps.runGit ?? ((args: string[]) => runGit(process.cwd(), args)); + + const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]); + if (branch.exitCode !== 0) { + return undefined; + } + + const current = branch.stdout; + if (!MAIN_BRANCHES.has(current)) { + return `[loop] heads up: on branch "${current}", not main`; + } + + const behind = git(["rev-list", "--count", "HEAD..@{upstream}"]); + if (behind.exitCode !== 0) { + return undefined; + } + + const count = Number.parseInt(behind.stdout, 10); + if (count > 0) { + const commits = count === 1 ? "1 commit" : `${count} commits`; + return `[loop] heads up: local ${current} is ${commits} behind remote`; + } + return undefined; +}; diff --git a/src/loop/main.ts b/src/loop/main.ts index 9aaf689..c2c2f16 100644 --- a/src/loop/main.ts +++ b/src/loop/main.ts @@ -23,16 +23,21 @@ const runIterations = async ( const shouldReview = reviewers.length > 0; const { doneSignal, maxIterations } = opts; console.log(`\n[loop] PLAN.md:\n\n${task}`); + for (let i = 1; i <= maxIterations; i++) { await iterationCooldown(i); logIterationHeader(i, maxIterations, opts.agent); + const prompt = buildWorkPrompt(task, doneSignal, opts.proof, reviewNotes); reviewNotes = ""; + const result = await tryRunAgent(opts.agent, prompt, opts, sessionId); sessionId = undefined; + if (!result) { continue; } + if (result.exitCode !== 0) { console.error( `\n[loop] ${opts.agent} exited with code ${result.exitCode}` @@ -40,14 +45,17 @@ const runIterations = async ( logSessionHint(opts.agent); continue; } + const output = `${result.parsed}\n${result.combined}`; if (!hasSignal(output, doneSignal)) { continue; } + if (!shouldReview) { console.log(`\n[loop] ${doneText(doneSignal)} detected, stopping.`); return true; } + const review = await runReview(reviewers, task, opts); if (review.approved) { await runDraftPrStep(task, opts); @@ -56,10 +64,12 @@ const runIterations = async ( ); return true; } + const followUp = formatFollowUp(review); reviewNotes = followUp.notes; console.log(followUp.log); } + return false; }; @@ -69,6 +79,7 @@ export const runLoop = async (task: string, opts: Options): Promise => { ? createInterface({ input: process.stdin, output: process.stdout }) : undefined; let loopTask = task; + try { while (true) { const done = await runIterations(loopTask, opts, reviewers); diff --git a/src/loop/prompts.ts b/src/loop/prompts.ts index d3a5d7b..ce9eeda 100644 --- a/src/loop/prompts.ts +++ b/src/loop/prompts.ts @@ -1,6 +1,5 @@ -import { REVIEW_FAIL, REVIEW_PASS } from "./constants"; +import { NEWLINE_RE, REVIEW_FAIL, REVIEW_PASS } from "./constants"; -const NEWLINE_RE = /\r?\n/; const SPAWN_TEAM_WITH_WORKTREE_ISOLATION = "Spawn a team of agents with worktree isolation."; @@ -78,7 +77,7 @@ export const buildReviewPrompt = ( ): string => { const parts = [ `Review this completed work for the task below and verify it in the current repo.\n\nTask:\n${task.trim()}`, - "Run checks/tests/commands as needed and inspect changed files.", + "Focus your review on unstaged changes (the diff produced by `git diff`). Run checks/tests/commands as needed.", ]; appendProofRequirements(parts, proof); diff --git a/src/loop/utils.ts b/src/loop/utils.ts index 059d21c..cf4762c 100644 --- a/src/loop/utils.ts +++ b/src/loop/utils.ts @@ -5,16 +5,13 @@ import { NEWLINE_RE } from "./constants"; export const isFile = (path: string): boolean => existsSync(path) && statSync(path).isFile(); -export const hasSignal = (text: string, signal: string): boolean => - text - .split(NEWLINE_RE) - .map((line) => line.trim()) - .some( - (line) => - line === signal || - line === `"${signal}"` || - line.includes(`"${signal}"`) - ); +export const hasSignal = (text: string, signal: string): boolean => { + const quoted = `"${signal}"`; + return text.split(NEWLINE_RE).some((raw) => { + const line = raw.trim(); + return line === signal || line === quoted || line.includes(quoted); + }); +}; export const readPrompt = async (input: string): Promise => { if (!isFile(input)) { diff --git a/tests/loop.test.ts b/tests/loop.test.ts index 8266067..075edad 100644 --- a/tests/loop.test.ts +++ b/tests/loop.test.ts @@ -18,6 +18,7 @@ afterEach(() => { }); interface CliModuleDeps { + checkGitState?: () => string | undefined; maybeEnterWorktree?: (opts: Options) => void | Promise; parseArgs?: (argv: string[]) => Options; resolveTask?: (opts: Options) => Promise; @@ -36,6 +37,7 @@ const loadRunCli = async ( deps: CliModuleDeps = {}, updateOverrides: UpdateModuleDeps = {} ) => { + const checkGitStateMock = mock(deps.checkGitState ?? (() => undefined)); const maybeEnterWorktreeMock = mock( deps.maybeEnterWorktree ?? (() => undefined) ); @@ -57,6 +59,7 @@ const loadRunCli = async ( mock.module("../src/loop/deps", () => ({ cliDeps: { + checkGitState: checkGitStateMock, maybeEnterWorktree: maybeEnterWorktreeMock, parseArgs: parseArgsMock, resolveTask: resolveTaskMock, @@ -77,6 +80,7 @@ const loadRunCli = async ( const { runCli } = await import(`../src/cli?test=${Date.now()}`); return { applyStagedMock, + checkGitStateMock, handleManualMock, maybeEnterWorktreeMock, parseArgsMock, diff --git a/tests/loop/git.test.ts b/tests/loop/git.test.ts new file mode 100644 index 0000000..96a874c --- /dev/null +++ b/tests/loop/git.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test"; +import { checkGitState, type GitResult } from "../../src/loop/git"; + +const ok = (stdout: string): GitResult => ({ exitCode: 0, stderr: "", stdout }); +const fail = (): GitResult => ({ exitCode: 1, stderr: "", stdout: "" }); + +test("returns warning when on a non-main branch", () => { + const result = checkGitState({ + runGit: (args) => { + if (args[0] === "rev-parse") { + return ok("feature-xyz"); + } + throw new Error(`unexpected: ${args.join(" ")}`); + }, + }); + expect(result).toBe('[loop] heads up: on branch "feature-xyz", not main'); +}); + +test("returns warning when local main is behind remote", () => { + const result = checkGitState({ + runGit: (args) => { + if (args[0] === "rev-parse") { + return ok("main"); + } + if (args[0] === "rev-list") { + return ok("3"); + } + throw new Error(`unexpected: ${args.join(" ")}`); + }, + }); + expect(result).toBe("[loop] heads up: local main is 3 commits behind remote"); +}); + +test("returns warning when local master is behind remote", () => { + const result = checkGitState({ + runGit: (args) => { + if (args[0] === "rev-parse") { + return ok("master"); + } + if (args[0] === "rev-list") { + return ok("1"); + } + throw new Error(`unexpected: ${args.join(" ")}`); + }, + }); + expect(result).toBe( + "[loop] heads up: local master is 1 commit behind remote" + ); +}); + +test("returns undefined when on main and up to date", () => { + const result = checkGitState({ + runGit: (args) => { + if (args[0] === "rev-parse") { + return ok("main"); + } + if (args[0] === "rev-list") { + return ok("0"); + } + throw new Error(`unexpected: ${args.join(" ")}`); + }, + }); + expect(result).toBeUndefined(); +}); + +test("returns undefined when not in a git repo", () => { + const result = checkGitState({ runGit: () => fail() }); + expect(result).toBeUndefined(); +}); + +test("returns undefined when no upstream is configured", () => { + const result = checkGitState({ + runGit: (args) => { + if (args[0] === "rev-parse") { + return ok("main"); + } + if (args[0] === "rev-list") { + return fail(); + } + throw new Error(`unexpected: ${args.join(" ")}`); + }, + }); + expect(result).toBeUndefined(); +});