diff --git a/src/loops/review.go b/src/loops/review.go index c19ef62..14a2c57 100644 --- a/src/loops/review.go +++ b/src/loops/review.go @@ -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 diff --git a/src/runners/claude.go b/src/runners/claude.go index cec6491..401bffb 100644 --- a/src/runners/claude.go +++ b/src/runners/claude.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "strconv" + "strings" ) type ClaudeRunner struct{} @@ -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", @@ -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{ diff --git a/src/runners/codex.go b/src/runners/codex.go index 0ac2f4e..194c581 100644 --- a/src/runners/codex.go +++ b/src/runners/codex.go @@ -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...) @@ -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 } diff --git a/src/runners/gemini.go b/src/runners/gemini.go index 8a26ccc..9761315 100644 --- a/src/runners/gemini.go +++ b/src/runners/gemini.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "os/exec" + "strings" ) type GeminiRunner struct{} @@ -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", } @@ -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 @@ -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) } }