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
14 changes: 11 additions & 3 deletions src/loops/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ func RunReview(ctx context.Context, db *lib.DB, available []runners.Runner, git
return nil, fmt.Errorf("no diff found — nothing to review")
}

// Truncate diff for prompt
const maxDiff = 30000
// Truncate diff at file boundaries to preserve reviewable context.
// 500K chars ≈ 125K tokens — well within Claude (200K) and Gemini (1M) limits.
const maxDiff = 500000
if len(diff) > maxDiff {
diff = diff[:maxDiff] + "\n... (diff truncated)"
// Cut at last file boundary to avoid mid-hunk truncation
cut := diff[:maxDiff]
if idx := strings.LastIndex(cut, "\ndiff --git "); idx > 0 {
cut = cut[:idx]
diff = cut + "\n\n... (diff truncated at file boundary — review remaining files separately)"
} else {
diff = cut + "\n\n... (diff truncated mid-hunk — no clean file boundary found in first 500K chars)"
}
}

prompt := cfg.Prompt
Expand Down
25 changes: 17 additions & 8 deletions src/runners/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)

type ClaudeRunner struct{}
Expand All @@ -25,11 +27,21 @@ type claudeResponse struct {
func (r *ClaudeRunner) Name() string { return "claude" }

func (r *ClaudeRunner) Available() bool {
_, err := exec.LookPath("claude")
return err == nil
if _, err := exec.LookPath("claude"); err != nil {
return false
}
// OAuth tokens (sk-ant-oat*) don't work for subprocess claude -p calls.
// Only real API keys (sk-ant-api*) or no key (keychain auth) work.
key := os.Getenv("ANTHROPIC_API_KEY")
if strings.HasPrefix(key, "sk-ant-oat") {
fmt.Fprintf(os.Stderr, "claude: skipping — ANTHROPIC_API_KEY is an OAuth token (sk-ant-oat*), which doesn't work for subprocess calls\n")
return false
}
return true
}

func (r *ClaudeRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) {
// claude -p is "print mode" — the prompt is a positional argument.
args := []string{
"-p", prompt,
"--output-format", "json",
Expand Down Expand Up @@ -63,17 +75,14 @@ func (r *ClaudeRunner) Run(ctx context.Context, prompt string, opts RunOpts) (Ru
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return RunResult{ExitCode: 1}, fmt.Errorf("claude failed to start: %w — is claude CLI installed?", err)
return RunResult{ExitCode: 1}, fmt.Errorf("claude failed to run: %w", err)
}
}

var resp claudeResponse
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
// If JSON parsing fails, return raw output
return RunResult{
Output: stdout.String(),
ExitCode: exitCode,
}, nil
return RunResult{Output: stdout.String(), ExitCode: exitCode},
fmt.Errorf("claude returned non-JSON output: %s", TruncStr(stdout.String(), 200))
}

return RunResult{
Expand Down
16 changes: 13 additions & 3 deletions src/runners/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ func (r *CodexRunner) Available() bool {
}

func (r *CodexRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) {
// Codex reads stdin and appends to the prompt argument.
// Pipe the full prompt via stdin to handle large diffs safely.
args := []string{"exec", "--full-auto", prompt}

cmd := exec.CommandContext(ctx, "codex", args...)
Expand All @@ -34,12 +36,20 @@ func (r *CodexRunner) Run(ctx context.Context, prompt string, opts RunOpts) (Run
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return RunResult{ExitCode: 1}, fmt.Errorf("codex failed to start: %w", err)
return RunResult{ExitCode: 1}, fmt.Errorf("codex failed to run: %w", err)
}
}

return RunResult{
result := RunResult{
Output: stdout.String(),
ExitCode: exitCode,
}, nil
}
if exitCode != 0 {
errMsg := stderr.String()
if errMsg == "" {
errMsg = stdout.String()
}
return result, fmt.Errorf("codex exited %d: %s", exitCode, TruncStr(errMsg, 200))
}
return result, nil
}
8 changes: 6 additions & 2 deletions src/runners/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os/exec"
"strings"
)

type GeminiRunner struct{}
Expand All @@ -17,8 +18,9 @@ func (r *GeminiRunner) Available() bool {
}

func (r *GeminiRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) {
// Gemini reads from stdin and -p appends to it. To avoid duplicate
// instructions, pipe the full prompt via stdin only — no -p flag.
args := []string{
"-p", prompt,
"-y",
"--output-format", "text",
}
Expand All @@ -28,6 +30,8 @@ func (r *GeminiRunner) Run(ctx context.Context, prompt string, opts RunOpts) (Ru
cmd.Dir = opts.WorkDir
}

cmd.Stdin = strings.NewReader(prompt)

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
Expand All @@ -38,7 +42,7 @@ func (r *GeminiRunner) Run(ctx context.Context, prompt string, opts RunOpts) (Ru
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else {
return RunResult{ExitCode: 1}, fmt.Errorf("gemini failed to start: %w", err)
return RunResult{ExitCode: 1}, fmt.Errorf("gemini failed to run: %w", err)
}
}

Expand Down
Loading