From 0adfd0be0f024c3a170ebdc29ea4a92d71744a80 Mon Sep 17 00:00:00 2001 From: Ermin Muratovic Date: Mon, 6 Apr 2026 22:03:07 +0100 Subject: [PATCH] feat: add --max-budget CLI flag and handle budget exhaustion gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --max-budget flag to implement --auto command - ClaudeCodeRunner: on non-zero exit, try parsing stdout JSON before throwing — budget exhaustion now returns RunResult instead of crashing - AutoImplement: detect max_budget_reached stop reason and report a clear error message with guidance to increase via --max-budget or CFORGE_MAX_BUDGET env var - Update USAGE_IMPLEMENT, main USAGE, and validation tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/application/use-cases/AutoImplement.ts | 13 +++++- src/cli/commands/implement.ts | 22 ++++++++- src/cli/index.ts | 19 ++++---- src/cli/validation.ts | 3 +- src/domain/models/AutoImplementResult.ts | 1 + src/infrastructure/claude/ClaudeCodeRunner.ts | 24 +++++----- tests/application/AutoImplement.test.ts | 23 ++++++++++ tests/cli/validation.test.ts | 4 +- tests/infrastructure/ClaudeCodeRunner.test.ts | 45 ++++++++++++++++++- 9 files changed, 126 insertions(+), 28 deletions(-) diff --git a/src/application/use-cases/AutoImplement.ts b/src/application/use-cases/AutoImplement.ts index bef0f84..fec23e9 100644 --- a/src/application/use-cases/AutoImplement.ts +++ b/src/application/use-cases/AutoImplement.ts @@ -74,7 +74,7 @@ After implementing: }); if (!runResult.success) { - return this.buildResult(issue, branch, runResult, false, false, false); + return this.buildResult(issue, branch, runResult, false, false, false, runResult.stopReason); } // Step 5: Run tests @@ -128,9 +128,17 @@ Fix the failing tests and ensure all tests pass before committing.`; success: boolean, testsPassed: boolean, retried: boolean, + stopReason?: string, ): AutoImplementResult { const manualStepsRequired: string[] = []; + let error: string | undefined; + if (!success) { + if (stopReason === "max_budget_reached") { + error = `Budget limit reached ($${runResult.cost.toFixed(2)} spent, ${runResult.turns} turns) — increase with --max-budget or CFORGE_MAX_BUDGET`; + } else { + error = "Tests failed after implementation"; + } manualStepsRequired.push( `cd to branch: git checkout ${branch}`, "Review test output: npm test", @@ -147,8 +155,9 @@ Fix the failing tests and ensure all tests pass before committing.`; cost: runResult.cost, turns: runResult.turns, claudeOutput: runResult.output, + stopReason, retried, - error: success ? undefined : "Tests failed after implementation", + error, manualStepsRequired, }; } diff --git a/src/cli/commands/implement.ts b/src/cli/commands/implement.ts index 32e5b45..7f3ef20 100644 --- a/src/cli/commands/implement.ts +++ b/src/cli/commands/implement.ts @@ -8,6 +8,11 @@ import { loadContext } from "../utils/loadContext"; import { validatePresence, validateNumericIssueNumber, USAGE_IMPLEMENT } from "../validation"; export async function implementCommand(issueNumberStr: string, flags: string[] = []): Promise { + if (issueNumberStr === "--help" || flags.includes("--help")) { + console.log(USAGE_IMPLEMENT); + process.exit(0); + } + const presenceErr = validatePresence(issueNumberStr, USAGE_IMPLEMENT); if (presenceErr) { console.error(presenceErr); @@ -23,9 +28,10 @@ export async function implementCommand(issueNumberStr: string, flags: string[] = const issueNumber = parseInt(issueNumberStr, 10); const isAuto = flags.includes("--auto"); + const maxBudget = parseFlagValue(flags, "--max-budget"); if (isAuto) { - await runAutoImplement(issueNumber); + await runAutoImplement(issueNumber, maxBudget); } else { await runManualImplement(issueNumber); } @@ -52,7 +58,18 @@ async function runManualImplement(issueNumber: number): Promise { }); } -async function runAutoImplement(issueNumber: number): Promise { +function parseFlagValue(flags: string[], flag: string): number | undefined { + const idx = flags.indexOf(flag); + if (idx === -1 || idx + 1 >= flags.length) return undefined; + const val = Number(flags[idx + 1]); + if (Number.isNaN(val) || val <= 0) { + console.error(`Invalid value for ${flag}: ${flags[idx + 1]}`); + process.exit(1); + } + return val; +} + +async function runAutoImplement(issueNumber: number, maxBudgetUsd?: number): Promise { const context = loadContext(); const gh = new OctokitGitHubClient(context.repoOwner, context.repoName); const promptGen = new CForgePromptGenerator(); @@ -78,6 +95,7 @@ async function runAutoImplement(issueNumber: number): Promise { const result = await auto.execute({ issueNumber, context, + maxBudgetUsd, }); if (result.success) { diff --git a/src/cli/index.ts b/src/cli/index.ts index 999aa30..09e4e36 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,22 +9,23 @@ import { chatCommand } from "./commands/chat"; import { auditCommand } from "./commands/audit"; import { getVersion } from "./utils/getVersion"; -const USAGE = `cforge-dev — AI-native SDLC orchestrator +export const USAGE = `cforge-dev — AI-native SDLC orchestrator Usage: cforge-dev plan Plan a sprint from a PRD file cforge-dev implement Generate Claude Code prompt for an issue - cforge-dev implement --auto Autonomous: Claude Code implements + opens PR + cforge-dev implement --auto Autonomous: Claude Code implements + opens PR + cforge-dev implement --auto --max-budget 10 Set max USD budget per session cforge-dev verify Verify issue readiness for merge cforge-dev release Create a release from a milestone cforge-dev chat Interactive planning session cforge-dev audit Run governance audit against contracts `; -async function main(): Promise { +export async function main(): Promise { const [command, ...args] = process.argv.slice(2); - if (!command) { + if (!command || command === "--help") { console.log(USAGE); process.exit(0); } @@ -60,7 +61,9 @@ async function main(): Promise { } } -main().catch((err) => { - console.error(`Error: ${err.message}`); - process.exit(1); -}); +if (require.main === module) { + main().catch((err) => { + console.error(`Error: ${err.message}`); + process.exit(1); + }); +} diff --git a/src/cli/validation.ts b/src/cli/validation.ts index 5d9eeea..111cec1 100644 --- a/src/cli/validation.ts +++ b/src/cli/validation.ts @@ -1,7 +1,8 @@ -export const USAGE_IMPLEMENT = "Usage: cforge-dev implement [issue-number]"; +export const USAGE_IMPLEMENT = "Usage: cforge-dev implement [issue-number] [--auto] [--max-budget ]"; export const USAGE_VERIFY = "Usage: cforge-dev verify [pr-number]"; export const USAGE_RELEASE = "Usage: cforge-dev release [version]"; export const USAGE_PLAN = "Usage: cforge-dev plan [milestone]"; +export const USAGE_AUDIT = "Usage: cforge-dev audit"; export function validatePresence(arg: string | undefined, usageMessage: string): string | null { if (!arg) return usageMessage; diff --git a/src/domain/models/AutoImplementResult.ts b/src/domain/models/AutoImplementResult.ts index e6912f9..eafcc53 100644 --- a/src/domain/models/AutoImplementResult.ts +++ b/src/domain/models/AutoImplementResult.ts @@ -10,6 +10,7 @@ export interface AutoImplementResult { cost: number; turns: number; claudeOutput: string; + stopReason?: string; retried: boolean; error?: string; manualStepsRequired: string[]; diff --git a/src/infrastructure/claude/ClaudeCodeRunner.ts b/src/infrastructure/claude/ClaudeCodeRunner.ts index 3cf2010..f516003 100644 --- a/src/infrastructure/claude/ClaudeCodeRunner.ts +++ b/src/infrastructure/claude/ClaudeCodeRunner.ts @@ -38,32 +38,28 @@ export class ClaudeCodeRunner implements CodeRunner { "--output-format", "json", ]; - const output = await new Promise((resolve, reject) => { + const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number | null }>((resolve, reject) => { const child = spawn("claude", args, { cwd: workingDir, stdio: ["pipe", "pipe", "pipe"], }); - let stdout = ""; - let stderr = ""; + let stdoutBuf = ""; + let stderrBuf = ""; child.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); + stdoutBuf += data.toString(); }); child.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); + stderrBuf += data.toString(); }); child.stdin.write(prompt); child.stdin.end(); child.on("close", (code: number | null) => { - if (code !== 0) { - reject(new Error(`claude exited with code ${code}: ${stderr}`)); - } else { - resolve(stdout); - } + resolve({ stdout: stdoutBuf, stderr: stderrBuf, exitCode: code }); }); child.on("error", (err: Error) => { @@ -71,11 +67,15 @@ export class ClaudeCodeRunner implements CodeRunner { }); }); + // Try to parse JSON from stdout — even on non-zero exit (e.g. budget exhaustion) let parsed: ClaudeJsonResponse; try { - parsed = JSON.parse(output.trim()); + parsed = JSON.parse(stdout.trim()); } catch { - throw new Error(`Failed to parse claude output: ${output.slice(0, 200)}`); + if (exitCode !== 0) { + throw new Error(`claude exited with code ${exitCode}: ${stderr}`); + } + throw new Error(`Failed to parse claude output: ${stdout.slice(0, 200)}`); } return { diff --git a/tests/application/AutoImplement.test.ts b/tests/application/AutoImplement.test.ts index 97f8295..5813198 100644 --- a/tests/application/AutoImplement.test.ts +++ b/tests/application/AutoImplement.test.ts @@ -126,6 +126,29 @@ describe("AutoImplement", () => { expect(gh.createPullRequest).not.toHaveBeenCalled(); }); + it("budget exhaustion → error message mentions --max-budget", async () => { + const issue = makeIssue(); + const gh = mockGitHubClient(issue); + const prompt = mockPromptGenerator(); + const runner = mockCodeRunner(makeRunResult({ + success: false, + output: "Hit max budget", + cost: 5.0, + turns: 12, + stopReason: "max_budget_reached", + })); + const testRunner = mockTestRunner(false); + const auto = new AutoImplement(gh, prompt, runner, testRunner); + + const result = await auto.execute({ issueNumber: 42, context: mockContext }); + + expect(result.success).toBe(false); + expect(result.stopReason).toBe("max_budget_reached"); + expect(result.error).toContain("Budget limit reached"); + expect(result.error).toContain("--max-budget"); + expect(result.error).toContain("$5.00"); + }); + it("tests fail first attempt → retries with error context", async () => { const issue = makeIssue(); const gh = mockGitHubClient(issue); diff --git a/tests/cli/validation.test.ts b/tests/cli/validation.test.ts index 57eef4b..b74235e 100644 --- a/tests/cli/validation.test.ts +++ b/tests/cli/validation.test.ts @@ -48,7 +48,7 @@ describe("validateNumericIssueNumber", () => { describe("USAGE constants", () => { it("USAGE_IMPLEMENT contains correct format", () => { - expect(USAGE_IMPLEMENT).toBe("Usage: cforge-dev implement [issue-number]"); + expect(USAGE_IMPLEMENT).toBe("Usage: cforge-dev implement [issue-number] [--auto] [--max-budget ]"); }); it("USAGE_VERIFY contains correct format", () => { @@ -68,7 +68,7 @@ describe("implement command validation", () => { it("fails with usage when no issue number provided", () => { const presenceErr = validatePresence(undefined, USAGE_IMPLEMENT); expect(presenceErr).not.toBeNull(); - expect(presenceErr).toBe("Usage: cforge-dev implement [issue-number]"); + expect(presenceErr).toBe("Usage: cforge-dev implement [issue-number] [--auto] [--max-budget ]"); }); it("fails with error when non-numeric issue number provided", () => { diff --git a/tests/infrastructure/ClaudeCodeRunner.test.ts b/tests/infrastructure/ClaudeCodeRunner.test.ts index b9e6483..d944a66 100644 --- a/tests/infrastructure/ClaudeCodeRunner.test.ts +++ b/tests/infrastructure/ClaudeCodeRunner.test.ts @@ -55,6 +55,25 @@ function mockSpawnFailure(code: number, stderr: string) { (execSync as jest.Mock).mockReturnValue("/usr/bin/claude"); } +function mockSpawnExitWithJson(code: number, jsonResponse: string, stderr = "") { + (spawn as jest.Mock).mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new PassThrough(), + }); + process.nextTick(() => { + child.stdout.push(jsonResponse); + child.stdout.push(null); + if (stderr) child.stderr.push(stderr); + child.stderr.push(null); + child.emit("close", code); + }); + return child; + }); + (execSync as jest.Mock).mockReturnValue("/usr/bin/claude"); +} + const successJson = JSON.stringify({ type: "result", subtype: "success", @@ -200,7 +219,31 @@ describe("ClaudeCodeRunner", () => { ); }); - it("should throw on non-zero exit code", async () => { + // ── Budget exhaustion handling ───────────────────────────────── + + it("should return RunResult on non-zero exit when stdout has valid JSON (budget exhaustion)", async () => { + const budgetJson = JSON.stringify({ + type: "result", + subtype: "error_max_budget", + is_error: true, + result: "Hit max budget of $5.00", + total_cost_usd: 5.0, + num_turns: 12, + stop_reason: "max_budget_reached", + }); + mockSpawnExitWithJson(1, budgetJson); + + const runner = new ClaudeCodeRunner(); + const result = await runner.run("prompt", "/tmp/test", {}); + + expect(result.success).toBe(false); + expect(result.stopReason).toBe("max_budget_reached"); + expect(result.cost).toBe(5.0); + expect(result.turns).toBe(12); + expect(result.output).toContain("Hit max budget"); + }); + + it("should still throw on non-zero exit when stdout is not valid JSON", async () => { mockSpawnFailure(1, "something went wrong"); const runner = new ClaudeCodeRunner();