Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/application/use-cases/AutoImplement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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,
};
}
Expand Down
22 changes: 20 additions & 2 deletions src/cli/commands/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (issueNumberStr === "--help" || flags.includes("--help")) {
console.log(USAGE_IMPLEMENT);
process.exit(0);
}

const presenceErr = validatePresence(issueNumberStr, USAGE_IMPLEMENT);
if (presenceErr) {
console.error(presenceErr);
Expand All @@ -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);
}
Expand All @@ -52,7 +58,18 @@ async function runManualImplement(issueNumber: number): Promise<void> {
});
}

async function runAutoImplement(issueNumber: number): Promise<void> {
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<void> {
const context = loadContext();
const gh = new OctokitGitHubClient(context.repoOwner, context.repoName);
const promptGen = new CForgePromptGenerator();
Expand All @@ -78,6 +95,7 @@ async function runAutoImplement(issueNumber: number): Promise<void> {
const result = await auto.execute({
issueNumber,
context,
maxBudgetUsd,
});

if (result.success) {
Expand Down
19 changes: 11 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prd-file> Plan a sprint from a PRD file
cforge-dev implement <issue-number> Generate Claude Code prompt for an issue
cforge-dev implement <n> --auto Autonomous: Claude Code implements + opens PR
cforge-dev implement <n> --auto Autonomous: Claude Code implements + opens PR
cforge-dev implement <n> --auto --max-budget 10 Set max USD budget per session
cforge-dev verify <issue-number> Verify issue readiness for merge
cforge-dev release <milestone-id> <ver> Create a release from a milestone
cforge-dev chat Interactive planning session
cforge-dev audit Run governance audit against contracts
`;

async function main(): Promise<void> {
export async function main(): Promise<void> {
const [command, ...args] = process.argv.slice(2);

if (!command) {
if (!command || command === "--help") {
console.log(USAGE);
process.exit(0);
}
Expand Down Expand Up @@ -60,7 +61,9 @@ async function main(): Promise<void> {
}
}

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);
});
}
3 changes: 2 additions & 1 deletion src/cli/validation.ts
Original file line number Diff line number Diff line change
@@ -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 <usd>]";
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;
Expand Down
1 change: 1 addition & 0 deletions src/domain/models/AutoImplementResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface AutoImplementResult {
cost: number;
turns: number;
claudeOutput: string;
stopReason?: string;
retried: boolean;
error?: string;
manualStepsRequired: string[];
Expand Down
24 changes: 12 additions & 12 deletions src/infrastructure/claude/ClaudeCodeRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,44 +38,44 @@ export class ClaudeCodeRunner implements CodeRunner {
"--output-format", "json",
];

const output = await new Promise<string>((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) => {
reject(err);
});
});

// 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 {
Expand Down
23 changes: 23 additions & 0 deletions tests/application/AutoImplement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions tests/cli/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <usd>]");
});

it("USAGE_VERIFY contains correct format", () => {
Expand All @@ -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 <usd>]");
});

it("fails with error when non-numeric issue number provided", () => {
Expand Down
45 changes: 44 additions & 1 deletion tests/infrastructure/ClaudeCodeRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();

Expand Down
Loading