• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / ralphex / 21743054702

06 Feb 2026 07:54AM UTC coverage: 80.111% (-0.8%) from 80.861%
21743054702

push

github

umputun
refactor: address code review findings and improve package structure

- Remove dead code: IsPlanDraft, IsTerminalSignal, NewErrorEvent, NewWarnEvent, Phase aliases
- Fix stale comments, extract magic numbers, remove redundant conditions
- Improve session manager: add error logging, convert standalone functions to methods
- Deduplicate shared code: maxScannerBuffer constant, progress file parsing, review pipeline
- Extract shared types to pkg/status (signals, Phase, Section) from processor/signals
- Merge pkg/render into pkg/input
- Move Logger interface to consumer-side in pkg/web, decoupling web from processor

195 of 205 new or added lines in 13 files covered. (95.12%)

1 existing line in 1 file now uncovered.

4463 of 5571 relevant lines covered (80.11%)

157.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.73
/pkg/processor/runner.go
1
// Package processor provides the main orchestration loop for ralphex execution.
2
package processor
3

4
import (
5
        "context"
6
        "errors"
7
        "fmt"
8
        "os"
9
        "os/exec"
10
        "strings"
11
        "time"
12

13
        "github.com/umputun/ralphex/pkg/config"
14
        "github.com/umputun/ralphex/pkg/executor"
15
        "github.com/umputun/ralphex/pkg/status"
16
)
17

18
// DefaultIterationDelay is the pause between iterations to allow system to settle.
19
const DefaultIterationDelay = 2 * time.Second
20

21
const (
22
        minReviewIterations    = 3    // minimum claude review iterations
23
        reviewIterationDivisor = 10   // review iterations = max_iterations / divisor
24
        minCodexIterations     = 3    // minimum codex review iterations
25
        codexIterationDivisor  = 5    // codex iterations = max_iterations / divisor
26
        minPlanIterations      = 5    // minimum plan creation iterations
27
        planIterationDivisor   = 5    // plan iterations = max_iterations / divisor
28
        maxCodexSummaryLen     = 5000 // max chars for codex output summary
29
)
30

31
// Mode represents the execution mode.
32
type Mode string
33

34
const (
35
        ModeFull      Mode = "full"       // full execution: tasks + reviews + codex
36
        ModeReview    Mode = "review"     // skip tasks, run full review pipeline
37
        ModeCodexOnly Mode = "codex-only" // skip tasks and first review, run only codex loop
38
        ModeTasksOnly Mode = "tasks-only" // run only task phase, skip all reviews
39
        ModePlan      Mode = "plan"       // interactive plan creation mode
40
)
41

42
// Config holds runner configuration.
43
type Config struct {
44
        PlanFile         string         // path to plan file (required for full mode)
45
        PlanDescription  string         // plan description for interactive plan creation mode
46
        ProgressPath     string         // path to progress file
47
        Mode             Mode           // execution mode
48
        MaxIterations    int            // maximum iterations for task phase
49
        Debug            bool           // enable debug output
50
        NoColor          bool           // disable color output
51
        IterationDelayMs int            // delay between iterations in milliseconds
52
        TaskRetryCount   int            // number of times to retry failed tasks
53
        CodexEnabled     bool           // whether codex review is enabled
54
        FinalizeEnabled  bool           // whether finalize step is enabled
55
        DefaultBranch    string         // default branch name (detected from repo)
56
        AppConfig        *config.Config // full application config (for executors and prompts)
57
}
58

59
//go:generate moq -out mocks/executor.go -pkg mocks -skip-ensure -fmt goimports . Executor
60
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
61
//go:generate moq -out mocks/input_collector.go -pkg mocks -skip-ensure -fmt goimports . InputCollector
62

63
// Executor runs CLI commands and returns results.
64
type Executor interface {
65
        Run(ctx context.Context, prompt string) executor.Result
66
}
67

68
// Logger provides logging functionality.
69
type Logger interface {
70
        SetPhase(phase status.Phase)
71
        Print(format string, args ...any)
72
        PrintRaw(format string, args ...any)
73
        PrintSection(section status.Section)
74
        PrintAligned(text string)
75
        LogQuestion(question string, options []string)
76
        LogAnswer(answer string)
77
        LogDraftReview(action string, feedback string)
78
        Path() string
79
}
80

81
// InputCollector provides interactive input collection for plan creation.
82
type InputCollector interface {
83
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
84
        AskDraftReview(ctx context.Context, question string, planContent string) (action string, feedback string, err error)
85
}
86

87
// Runner orchestrates the execution loop.
88
type Runner struct {
89
        cfg            Config
90
        log            Logger
91
        claude         Executor
92
        codex          Executor
93
        custom         *executor.CustomExecutor
94
        inputCollector InputCollector
95
        iterationDelay time.Duration
96
        taskRetryCount int
97
}
98

99
// New creates a new Runner with the given configuration.
100
// If codex is enabled but the binary is not found in PATH, it is automatically disabled with a warning.
101
func New(cfg Config, log Logger) *Runner {
3✔
102
        // build claude executor with config values
3✔
103
        claudeExec := &executor.ClaudeExecutor{
3✔
104
                OutputHandler: func(text string) {
3✔
105
                        log.PrintAligned(text)
×
106
                },
×
107
                Debug: cfg.Debug,
108
        }
109
        if cfg.AppConfig != nil {
6✔
110
                claudeExec.Command = cfg.AppConfig.ClaudeCommand
3✔
111
                claudeExec.Args = cfg.AppConfig.ClaudeArgs
3✔
112
                claudeExec.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns
3✔
113
        }
3✔
114

115
        // build codex executor with config values
116
        codexExec := &executor.CodexExecutor{
3✔
117
                OutputHandler: func(text string) {
3✔
118
                        log.PrintAligned(text)
×
119
                },
×
120
                Debug: cfg.Debug,
121
        }
122
        if cfg.AppConfig != nil {
6✔
123
                codexExec.Command = cfg.AppConfig.CodexCommand
3✔
124
                codexExec.Model = cfg.AppConfig.CodexModel
3✔
125
                codexExec.ReasoningEffort = cfg.AppConfig.CodexReasoningEffort
3✔
126
                codexExec.TimeoutMs = cfg.AppConfig.CodexTimeoutMs
3✔
127
                codexExec.Sandbox = cfg.AppConfig.CodexSandbox
3✔
128
                codexExec.ErrorPatterns = cfg.AppConfig.CodexErrorPatterns
3✔
129
        }
3✔
130

131
        // build custom executor if custom review script is configured
132
        var customExec *executor.CustomExecutor
3✔
133
        if cfg.AppConfig != nil && cfg.AppConfig.CustomReviewScript != "" {
4✔
134
                customExec = &executor.CustomExecutor{
1✔
135
                        Script: cfg.AppConfig.CustomReviewScript,
1✔
136
                        OutputHandler: func(text string) {
1✔
137
                                log.PrintAligned(text)
×
138
                        },
×
139
                        ErrorPatterns: cfg.AppConfig.CodexErrorPatterns, // reuse codex error patterns
140
                }
141
        }
142

143
        // auto-disable codex if the binary is not installed AND we need codex
144
        // (skip this check if using custom external review tool or external review is disabled)
145
        if cfg.CodexEnabled && needsCodexBinary(cfg.AppConfig) {
4✔
146
                codexCmd := codexExec.Command
1✔
147
                if codexCmd == "" {
1✔
148
                        codexCmd = "codex"
×
149
                }
×
150
                if _, err := exec.LookPath(codexCmd); err != nil {
2✔
151
                        log.Print("warning: codex not found (%s: %v), disabling codex review phase", codexCmd, err)
1✔
152
                        cfg.CodexEnabled = false
1✔
153
                }
1✔
154
        }
155

156
        return NewWithExecutors(cfg, log, claudeExec, codexExec, customExec)
3✔
157
}
158

159
// NewWithExecutors creates a new Runner with custom executors (for testing).
160
func NewWithExecutors(cfg Config, log Logger, claude, codex Executor, custom *executor.CustomExecutor) *Runner {
64✔
161
        // determine iteration delay from config or default
64✔
162
        iterDelay := DefaultIterationDelay
64✔
163
        if cfg.IterationDelayMs > 0 {
80✔
164
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
16✔
165
        }
16✔
166

167
        // determine task retry count from config
168
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
169
        retryCount := 1
64✔
170
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
116✔
171
                retryCount = cfg.TaskRetryCount
52✔
172
        } else if cfg.TaskRetryCount > 0 {
65✔
173
                retryCount = cfg.TaskRetryCount
1✔
174
        }
1✔
175

176
        return &Runner{
64✔
177
                cfg:            cfg,
64✔
178
                log:            log,
64✔
179
                claude:         claude,
64✔
180
                codex:          codex,
64✔
181
                custom:         custom,
64✔
182
                iterationDelay: iterDelay,
64✔
183
                taskRetryCount: retryCount,
64✔
184
        }
64✔
185
}
186

187
// SetInputCollector sets the input collector for plan creation mode.
188
func (r *Runner) SetInputCollector(c InputCollector) {
15✔
189
        r.inputCollector = c
15✔
190
}
15✔
191

192
// Run executes the main loop based on configured mode.
193
func (r *Runner) Run(ctx context.Context) error {
52✔
194
        switch r.cfg.Mode {
52✔
195
        case ModeFull:
13✔
196
                return r.runFull(ctx)
13✔
197
        case ModeReview:
8✔
198
                return r.runReviewOnly(ctx)
8✔
199
        case ModeCodexOnly:
10✔
200
                return r.runCodexOnly(ctx)
10✔
201
        case ModeTasksOnly:
4✔
202
                return r.runTasksOnly(ctx)
4✔
203
        case ModePlan:
16✔
204
                return r.runPlanCreation(ctx)
16✔
205
        default:
1✔
206
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
207
        }
208
}
209

210
// runFull executes the complete pipeline: tasks → review → codex → review.
211
func (r *Runner) runFull(ctx context.Context) error {
13✔
212
        if r.cfg.PlanFile == "" {
14✔
213
                return errors.New("plan file required for full mode")
1✔
214
        }
1✔
215

216
        // phase 1: task execution
217
        r.log.SetPhase(status.PhaseTask)
12✔
218
        r.log.PrintRaw("starting task execution phase\n")
12✔
219

12✔
220
        if err := r.runTaskPhase(ctx); err != nil {
18✔
221
                return fmt.Errorf("task phase: %w", err)
6✔
222
        }
6✔
223

224
        // phase 2: first review pass - address ALL findings
225
        r.log.SetPhase(status.PhaseReview)
6✔
226
        r.log.PrintSection(status.NewGenericSection("claude review 0: all findings"))
6✔
227

6✔
228
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
6✔
229
                return fmt.Errorf("first review: %w", err)
×
230
        }
×
231

232
        // phase 2.1: claude review loop (critical/major) before codex
233
        if err := r.runClaudeReviewLoop(ctx); err != nil {
6✔
234
                return fmt.Errorf("pre-codex review loop: %w", err)
×
235
        }
×
236

237
        // phase 2.5+3: codex → post-codex review → finalize
238
        if err := r.runCodexAndPostReview(ctx); err != nil {
6✔
239
                return err
×
240
        }
×
241

242
        r.log.Print("all phases completed successfully")
6✔
243
        return nil
6✔
244
}
245

246
// runReviewOnly executes only the review pipeline: review → codex → review.
247
func (r *Runner) runReviewOnly(ctx context.Context) error {
8✔
248
        // phase 1: first review
8✔
249
        r.log.SetPhase(status.PhaseReview)
8✔
250
        r.log.PrintSection(status.NewGenericSection("claude review 0: all findings"))
8✔
251

8✔
252
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
9✔
253
                return fmt.Errorf("first review: %w", err)
1✔
254
        }
1✔
255

256
        // phase 1.1: claude review loop (critical/major) before codex
257
        if err := r.runClaudeReviewLoop(ctx); err != nil {
8✔
258
                return fmt.Errorf("pre-codex review loop: %w", err)
1✔
259
        }
1✔
260

261
        // phase 2+3: codex → post-codex review → finalize
262
        if err := r.runCodexAndPostReview(ctx); err != nil {
9✔
263
                return err
3✔
264
        }
3✔
265

266
        r.log.Print("review phases completed successfully")
3✔
267
        return nil
3✔
268
}
269

270
// runCodexOnly executes only the codex pipeline: codex → review → finalize.
271
func (r *Runner) runCodexOnly(ctx context.Context) error {
10✔
272
        if err := r.runCodexAndPostReview(ctx); err != nil {
11✔
273
                return err
1✔
274
        }
1✔
275

276
        r.log.Print("codex phases completed successfully")
9✔
277
        return nil
9✔
278
}
279

280
// runCodexAndPostReview runs the shared codex → post-codex claude review → finalize pipeline.
281
// used by runFull, runReviewOnly, and runCodexOnly to avoid duplicating this sequence.
282
func (r *Runner) runCodexAndPostReview(ctx context.Context) error {
22✔
283
        // codex external review loop
22✔
284
        r.log.SetPhase(status.PhaseCodex)
22✔
285
        r.log.PrintSection(status.NewGenericSection("codex external review"))
22✔
286

22✔
287
        if err := r.runCodexLoop(ctx); err != nil {
25✔
288
                return fmt.Errorf("codex loop: %w", err)
3✔
289
        }
3✔
290

291
        // claude review loop (critical/major) after codex
292
        r.log.SetPhase(status.PhaseReview)
19✔
293

19✔
294
        if err := r.runClaudeReviewLoop(ctx); err != nil {
19✔
295
                return fmt.Errorf("post-codex review loop: %w", err)
×
296
        }
×
297

298
        // optional finalize step (best-effort, but propagates context cancellation)
299
        return r.runFinalize(ctx)
19✔
300
}
301

302
// runTasksOnly executes only task phase, skipping all reviews.
303
func (r *Runner) runTasksOnly(ctx context.Context) error {
4✔
304
        if r.cfg.PlanFile == "" {
5✔
305
                return errors.New("plan file required for tasks-only mode")
1✔
306
        }
1✔
307

308
        r.log.SetPhase(status.PhaseTask)
3✔
309
        r.log.PrintRaw("starting task execution phase\n")
3✔
310

3✔
311
        if err := r.runTaskPhase(ctx); err != nil {
4✔
312
                return fmt.Errorf("task phase: %w", err)
1✔
313
        }
1✔
314

315
        r.log.Print("task execution completed successfully")
2✔
316
        return nil
2✔
317
}
318

319
// runTaskPhase executes tasks until completion or max iterations.
320
// executes ONE Task section per iteration.
321
func (r *Runner) runTaskPhase(ctx context.Context) error {
15✔
322
        prompt := r.replacePromptVariables(r.cfg.AppConfig.TaskPrompt)
15✔
323
        retryCount := 0
15✔
324

15✔
325
        for i := 1; i <= r.cfg.MaxIterations; i++ {
34✔
326
                select {
19✔
327
                case <-ctx.Done():
1✔
328
                        return fmt.Errorf("task phase: %w", ctx.Err())
1✔
329
                default:
18✔
330
                }
331

332
                r.log.PrintSection(status.NewTaskIterationSection(i))
18✔
333

18✔
334
                result := r.claude.Run(ctx, prompt)
18✔
335
                if result.Error != nil {
20✔
336
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
337
                                return err
1✔
338
                        }
1✔
339
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
340
                }
341

342
                if result.Signal == SignalCompleted {
24✔
343
                        // verify plan actually has no uncompleted checkboxes
8✔
344
                        if r.hasUncompletedTasks() {
8✔
345
                                r.log.Print("warning: completion signal received but plan still has [ ] items, continuing...")
×
346
                                continue
×
347
                        }
348
                        r.log.PrintRaw("\nall tasks completed, starting code review...\n")
8✔
349
                        return nil
8✔
350
                }
351

352
                if result.Signal == SignalFailed {
13✔
353
                        if retryCount < r.taskRetryCount {
7✔
354
                                r.log.Print("task failed, retrying...")
2✔
355
                                retryCount++
2✔
356
                                time.Sleep(r.iterationDelay)
2✔
357
                                continue
2✔
358
                        }
359
                        return errors.New("task execution failed after retry (FAILED signal received)")
3✔
360
                }
361

362
                retryCount = 0
3✔
363
                // continue with same prompt - it reads from plan file each time
3✔
364
                time.Sleep(r.iterationDelay)
3✔
365
        }
366

367
        return fmt.Errorf("max iterations (%d) reached without completion", r.cfg.MaxIterations)
1✔
368
}
369

370
// runClaudeReview runs Claude review with the given prompt until REVIEW_DONE.
371
func (r *Runner) runClaudeReview(ctx context.Context, prompt string) error {
14✔
372
        result := r.claude.Run(ctx, prompt)
14✔
373
        if result.Error != nil {
14✔
374
                if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
×
375
                        return err
×
376
                }
×
377
                return fmt.Errorf("claude execution: %w", result.Error)
×
378
        }
379

380
        if result.Signal == SignalFailed {
15✔
381
                return errors.New("review failed (FAILED signal received)")
1✔
382
        }
1✔
383

384
        if !IsReviewDone(result.Signal) {
13✔
385
                r.log.Print("warning: first review pass did not complete cleanly, continuing...")
×
386
        }
×
387

388
        return nil
13✔
389
}
390

391
// runClaudeReviewLoop runs claude review iterations using second review prompt.
392
func (r *Runner) runClaudeReviewLoop(ctx context.Context) error {
32✔
393
        // review iterations = 10% of max_iterations
32✔
394
        maxReviewIterations := max(minReviewIterations, r.cfg.MaxIterations/reviewIterationDivisor)
32✔
395

32✔
396
        for i := 1; i <= maxReviewIterations; i++ {
64✔
397
                select {
32✔
398
                case <-ctx.Done():
×
399
                        return fmt.Errorf("review: %w", ctx.Err())
×
400
                default:
32✔
401
                }
402

403
                r.log.PrintSection(status.NewClaudeReviewSection(i, ": critical/major"))
32✔
404

32✔
405
                result := r.claude.Run(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewSecondPrompt))
32✔
406
                if result.Error != nil {
33✔
407
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
2✔
408
                                return err
1✔
409
                        }
1✔
410
                        return fmt.Errorf("claude execution: %w", result.Error)
×
411
                }
412

413
                if result.Signal == SignalFailed {
31✔
414
                        return errors.New("review failed (FAILED signal received)")
×
415
                }
×
416

417
                if IsReviewDone(result.Signal) {
62✔
418
                        r.log.Print("claude review complete - no more findings")
31✔
419
                        return nil
31✔
420
                }
31✔
421

422
                r.log.Print("issues fixed, running another review iteration...")
×
423
                time.Sleep(r.iterationDelay)
×
424
        }
425

426
        r.log.Print("max claude review iterations reached, continuing...")
×
427
        return nil
×
428
}
429

430
// externalReviewTool returns the effective external review tool to use.
431
// handles backward compatibility: codex_enabled = false → "none"
432
// the CodexEnabled flag takes precedence for backward compatibility.
433
func (r *Runner) externalReviewTool() string {
22✔
434
        // backward compatibility: codex_enabled = false means no external review
22✔
435
        // this takes precedence over external_review_tool setting
22✔
436
        if !r.cfg.CodexEnabled {
31✔
437
                return "none"
9✔
438
        }
9✔
439

440
        // check explicit external_review_tool setting
441
        if r.cfg.AppConfig != nil && r.cfg.AppConfig.ExternalReviewTool != "" {
26✔
442
                return r.cfg.AppConfig.ExternalReviewTool
13✔
443
        }
13✔
444

445
        // default to codex
446
        return "codex"
×
447
}
448

449
// runCodexLoop runs the external review loop (codex or custom) until no findings.
450
func (r *Runner) runCodexLoop(ctx context.Context) error {
22✔
451
        tool := r.externalReviewTool()
22✔
452

22✔
453
        // skip external review phase if disabled
22✔
454
        if tool == "none" {
32✔
455
                r.log.Print("external review disabled, skipping...")
10✔
456
                return nil
10✔
457
        }
10✔
458

459
        // custom review tool
460
        if tool == "custom" {
14✔
461
                if r.custom == nil {
3✔
462
                        return errors.New("custom review script not configured")
1✔
463
                }
1✔
464
                return r.runExternalReviewLoop(ctx, externalReviewConfig{
1✔
465
                        name:            "custom",
1✔
466
                        runReview:       func(ctx context.Context, prompt string) executor.Result { return r.custom.Run(ctx, prompt) },
2✔
467
                        buildPrompt:     r.buildCustomReviewPrompt,
468
                        buildEvalPrompt: r.buildCustomEvaluationPrompt,
469
                        showSummary:     r.showCustomSummary,
470
                        makeSection:     status.NewCustomIterationSection,
471
                })
472
        }
473

474
        // default: codex review
475
        return r.runExternalReviewLoop(ctx, externalReviewConfig{
10✔
476
                name:            "codex",
10✔
477
                runReview:       r.codex.Run,
10✔
478
                buildPrompt:     r.buildCodexPrompt,
10✔
479
                buildEvalPrompt: r.buildCodexEvaluationPrompt,
10✔
480
                showSummary:     r.showCodexSummary,
10✔
481
                makeSection:     status.NewCodexIterationSection,
10✔
482
        })
10✔
483
}
484

485
// externalReviewConfig holds callbacks for running an external review tool.
486
type externalReviewConfig struct {
487
        name            string                                                   // tool name for error messages
488
        runReview       func(ctx context.Context, prompt string) executor.Result // run the external review tool
489
        buildPrompt     func(isFirst bool, claudeResponse string) string         // build prompt for review tool
490
        buildEvalPrompt func(output string) string                               // build evaluation prompt for claude
491
        showSummary     func(output string)                                      // display review findings summary
492
        makeSection     func(iteration int) status.Section                       // create section header
493
}
494

495
// runExternalReviewLoop runs a generic external review tool-claude loop until no findings.
496
func (r *Runner) runExternalReviewLoop(ctx context.Context, cfg externalReviewConfig) error {
11✔
497
        // iterations = 20% of max_iterations (min 3)
11✔
498
        maxIterations := max(3, r.cfg.MaxIterations/5)
11✔
499

11✔
500
        var claudeResponse string // first iteration has no prior response
11✔
501

11✔
502
        for i := 1; i <= maxIterations; i++ {
22✔
503
                select {
11✔
504
                case <-ctx.Done():
×
505
                        return fmt.Errorf("%s loop: %w", cfg.name, ctx.Err())
×
506
                default:
11✔
507
                }
508

509
                r.log.PrintSection(cfg.makeSection(i))
11✔
510

11✔
511
                // run external review tool
11✔
512
                reviewResult := cfg.runReview(ctx, cfg.buildPrompt(i == 1, claudeResponse))
11✔
513
                if reviewResult.Error != nil {
13✔
514
                        if err := r.handlePatternMatchError(reviewResult.Error, cfg.name); err != nil {
3✔
515
                                return err
1✔
516
                        }
1✔
517
                        return fmt.Errorf("%s execution: %w", cfg.name, reviewResult.Error)
1✔
518
                }
519

520
                if reviewResult.Output == "" {
11✔
521
                        r.log.Print("%s review returned no output, skipping...", cfg.name)
2✔
522
                        break
2✔
523
                }
524

525
                // show findings summary before Claude evaluation
526
                cfg.showSummary(reviewResult.Output)
7✔
527

7✔
528
                // pass output to claude for evaluation and fixing
7✔
529
                r.log.SetPhase(status.PhaseClaudeEval)
7✔
530
                r.log.PrintSection(status.NewClaudeEvalSection())
7✔
531
                claudeResult := r.claude.Run(ctx, cfg.buildEvalPrompt(reviewResult.Output))
7✔
532

7✔
533
                // restore codex phase for next iteration
7✔
534
                r.log.SetPhase(status.PhaseCodex)
7✔
535
                if claudeResult.Error != nil {
7✔
536
                        if err := r.handlePatternMatchError(claudeResult.Error, "claude"); err != nil {
×
537
                                return err
×
538
                        }
×
539
                        return fmt.Errorf("claude execution: %w", claudeResult.Error)
×
540
                }
541

542
                claudeResponse = claudeResult.Output
7✔
543

7✔
544
                // exit only when claude sees "no findings"
7✔
545
                if IsCodexDone(claudeResult.Signal) {
14✔
546
                        r.log.Print("%s review complete - no more findings", cfg.name)
7✔
547
                        return nil
7✔
548
                }
7✔
549

550
                time.Sleep(r.iterationDelay)
×
551
        }
552

553
        r.log.Print("max %s iterations reached, continuing to next phase...", cfg.name)
2✔
554
        return nil
2✔
555
}
556

557
// buildCodexPrompt creates the prompt for codex review.
558
func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string {
11✔
559
        // build plan context if available
11✔
560
        planContext := ""
11✔
561
        if r.cfg.PlanFile != "" {
14✔
562
                planContext = fmt.Sprintf(`
3✔
563
## Plan Context
3✔
564
The code implements the plan at: %s
3✔
565

3✔
566
---
3✔
567
`, r.resolvePlanFilePath())
3✔
568
        }
3✔
569

570
        // different diff command based on iteration
571
        var diffInstruction, diffDescription string
11✔
572
        if isFirst {
22✔
573
                defaultBranch := r.getDefaultBranch()
11✔
574
                diffInstruction = fmt.Sprintf("Run: git diff %s...HEAD", defaultBranch)
11✔
575
                diffDescription = fmt.Sprintf("code changes between %s and HEAD branch", defaultBranch)
11✔
576
        } else {
11✔
577
                diffInstruction = "Run: git diff"
×
578
                diffDescription = "uncommitted changes (Claude's fixes from previous iteration)"
×
579
        }
×
580

581
        basePrompt := fmt.Sprintf(`%sReview the %s.
11✔
582

11✔
583
%s
11✔
584

11✔
585
Analyze for:
11✔
586
- Bugs and logic errors
11✔
587
- Security vulnerabilities
11✔
588
- Race conditions
11✔
589
- Error handling gaps
11✔
590
- Code quality issues
11✔
591

11✔
592
Report findings with file:line references. If no issues found, say "NO ISSUES FOUND".`, planContext, diffDescription, diffInstruction)
11✔
593

11✔
594
        if claudeResponse != "" {
11✔
595
                return fmt.Sprintf(`%s
×
596

×
597
---
×
598
PREVIOUS REVIEW CONTEXT:
×
599
Claude (previous reviewer) responded to your findings:
×
600

×
601
%s
×
602

×
603
Re-evaluate considering Claude's arguments. If Claude's fixes are correct, acknowledge them.
×
604
If Claude's arguments are invalid, explain why the issues still exist.`, basePrompt, claudeResponse)
×
605
        }
×
606

607
        return basePrompt
11✔
608
}
609

610
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
611
func (r *Runner) hasUncompletedTasks() bool {
13✔
612
        content, err := os.ReadFile(r.resolvePlanFilePath())
13✔
613
        if err != nil {
13✔
614
                return true // assume incomplete if can't read
×
615
        }
×
616

617
        // look for uncompleted checkbox pattern: [ ] (not [x])
618
        for line := range strings.SplitSeq(string(content), "\n") {
43✔
619
                trimmed := strings.TrimSpace(line)
30✔
620
                if strings.HasPrefix(trimmed, "- [ ]") {
33✔
621
                        return true
3✔
622
                }
3✔
623
        }
624
        return false
10✔
625
}
626

627
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
628
// extracts text until first code block or maxCodexSummaryLen chars, whichever is shorter.
629
func (r *Runner) showCodexSummary(output string) {
6✔
630
        r.showExternalReviewSummary("codex", output)
6✔
631
}
6✔
632

633
// showCustomSummary displays a condensed summary of custom review output before Claude evaluation.
634
func (r *Runner) showCustomSummary(output string) {
1✔
635
        r.showExternalReviewSummary("custom", output)
1✔
636
}
1✔
637

638
// showExternalReviewSummary displays a condensed summary of external review output.
639
// extracts text until first code block or 5000 chars, whichever is shorter.
640
func (r *Runner) showExternalReviewSummary(toolName, output string) {
7✔
641
        summary := output
7✔
642

7✔
643
        // trim to first code block if present
7✔
644
        if idx := strings.Index(summary, "```"); idx > 0 {
7✔
645
                summary = summary[:idx]
×
646
        }
×
647

648
        // limit to maxCodexSummaryLen chars
649
        if len(summary) > maxCodexSummaryLen {
7✔
NEW
650
                summary = summary[:maxCodexSummaryLen] + "..."
×
UNCOV
651
        }
×
652

653
        summary = strings.TrimSpace(summary)
7✔
654
        if summary == "" {
7✔
655
                return
×
656
        }
×
657

658
        r.log.Print("%s findings:", toolName)
7✔
659
        for line := range strings.SplitSeq(summary, "\n") {
14✔
660
                if strings.TrimSpace(line) == "" {
7✔
661
                        continue
×
662
                }
663
                r.log.PrintAligned("  " + line)
7✔
664
        }
665
}
666

667
// ErrUserRejectedPlan is returned when user rejects the plan draft.
668
var ErrUserRejectedPlan = errors.New("user rejected plan")
669

670
// draftReviewResult holds the result of draft review handling.
671
type draftReviewResult struct {
672
        handled  bool   // true if draft was found and handled
673
        feedback string // revision feedback (non-empty only for "revise" action)
674
        err      error  // error if review failed or user rejected
675
}
676

677
// handlePlanDraft processes PLAN_DRAFT signal if present in output.
678
// returns result indicating whether draft was handled and any feedback/errors.
679
func (r *Runner) handlePlanDraft(ctx context.Context, output string) draftReviewResult {
15✔
680
        planContent, draftErr := ParsePlanDraftPayload(output)
15✔
681
        if draftErr != nil {
24✔
682
                // log malformed signals (but not "no signal" which is expected)
9✔
683
                if !errors.Is(draftErr, ErrNoPlanDraftSignal) {
10✔
684
                        r.log.Print("warning: %v", draftErr)
1✔
685
                }
1✔
686
                return draftReviewResult{handled: false}
9✔
687
        }
688

689
        r.log.Print("plan draft ready for review")
6✔
690

6✔
691
        action, feedback, askErr := r.inputCollector.AskDraftReview(ctx, "Review the plan draft", planContent)
6✔
692
        if askErr != nil {
7✔
693
                return draftReviewResult{handled: true, err: fmt.Errorf("collect draft review: %w", askErr)}
1✔
694
        }
1✔
695

696
        // log the draft review action and feedback to progress file
697
        r.log.LogDraftReview(action, feedback)
5✔
698

5✔
699
        switch action {
5✔
700
        case "accept":
3✔
701
                r.log.Print("draft accepted, continuing to write plan file...")
3✔
702
                return draftReviewResult{handled: true}
3✔
703
        case "revise":
1✔
704
                r.log.Print("revision requested, re-running with feedback...")
1✔
705
                return draftReviewResult{handled: true, feedback: feedback}
1✔
706
        case "reject":
1✔
707
                r.log.Print("plan rejected by user")
1✔
708
                return draftReviewResult{handled: true, err: ErrUserRejectedPlan}
1✔
709
        }
710

711
        return draftReviewResult{handled: true}
×
712
}
713

714
// handlePlanQuestion processes QUESTION signal if present in output.
715
// returns true if question was found and handled, false otherwise.
716
// returns error if question handling failed.
717
func (r *Runner) handlePlanQuestion(ctx context.Context, output string) (bool, error) {
9✔
718
        question, err := ParseQuestionPayload(output)
9✔
719
        if err != nil {
15✔
720
                // log malformed signals (but not "no signal" which is expected)
6✔
721
                if !errors.Is(err, ErrNoQuestionSignal) {
6✔
722
                        r.log.Print("warning: %v", err)
×
723
                }
×
724
                return false, nil
6✔
725
        }
726

727
        r.log.LogQuestion(question.Question, question.Options)
3✔
728

3✔
729
        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
3✔
730
        if askErr != nil {
4✔
731
                return true, fmt.Errorf("collect answer: %w", askErr)
1✔
732
        }
1✔
733

734
        r.log.LogAnswer(answer)
2✔
735
        return true, nil
2✔
736
}
737

738
// runPlanCreation executes the interactive plan creation loop.
739
// the loop continues until PLAN_READY signal or max iterations reached.
740
// handles QUESTION signals for Q&A and PLAN_DRAFT signals for draft review.
741
func (r *Runner) runPlanCreation(ctx context.Context) error {
16✔
742
        if r.cfg.PlanDescription == "" {
17✔
743
                return errors.New("plan description required for plan mode")
1✔
744
        }
1✔
745
        if r.inputCollector == nil {
16✔
746
                return errors.New("input collector required for plan mode")
1✔
747
        }
1✔
748

749
        r.log.SetPhase(status.PhasePlan)
14✔
750
        r.log.PrintRaw("starting interactive plan creation\n")
14✔
751
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
14✔
752

14✔
753
        // plan iterations use 20% of max_iterations
14✔
754
        maxPlanIterations := max(minPlanIterations, r.cfg.MaxIterations/planIterationDivisor)
14✔
755

14✔
756
        // track revision feedback for context in next iteration
14✔
757
        var lastRevisionFeedback string
14✔
758

14✔
759
        for i := 1; i <= maxPlanIterations; i++ {
39✔
760
                select {
25✔
761
                case <-ctx.Done():
1✔
762
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
763
                default:
24✔
764
                }
765

766
                r.log.PrintSection(status.NewPlanIterationSection(i))
24✔
767

24✔
768
                prompt := r.buildPlanPrompt()
24✔
769
                // append revision feedback context if present
24✔
770
                if lastRevisionFeedback != "" {
25✔
771
                        prompt = fmt.Sprintf("%s\n\n---\nPREVIOUS DRAFT FEEDBACK:\nUser requested revisions with this feedback:\n%s\n\nPlease revise the plan accordingly and present a new PLAN_DRAFT.", prompt, lastRevisionFeedback)
1✔
772
                        lastRevisionFeedback = "" // clear after use
1✔
773
                }
1✔
774

775
                result := r.claude.Run(ctx, prompt)
24✔
776
                if result.Error != nil {
26✔
777
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
778
                                return err
1✔
779
                        }
1✔
780
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
781
                }
782

783
                if result.Signal == SignalFailed {
23✔
784
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
785
                }
1✔
786

787
                // check for PLAN_READY signal
788
                if IsPlanReady(result.Signal) {
27✔
789
                        r.log.Print("plan creation completed")
6✔
790
                        return nil
6✔
791
                }
6✔
792

793
                // check for PLAN_DRAFT signal - present draft for user review
794
                draftResult := r.handlePlanDraft(ctx, result.Output)
15✔
795
                if draftResult.err != nil {
17✔
796
                        return draftResult.err
2✔
797
                }
2✔
798
                if draftResult.handled {
17✔
799
                        lastRevisionFeedback = draftResult.feedback
4✔
800
                        time.Sleep(r.iterationDelay)
4✔
801
                        continue
4✔
802
                }
803

804
                // check for QUESTION signal
805
                handled, err := r.handlePlanQuestion(ctx, result.Output)
9✔
806
                if err != nil {
10✔
807
                        return err
1✔
808
                }
1✔
809
                if handled {
10✔
810
                        time.Sleep(r.iterationDelay)
2✔
811
                        continue
2✔
812
                }
813

814
                // no question, no draft, and no completion - continue
815
                time.Sleep(r.iterationDelay)
6✔
816
        }
817

818
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
819
}
820

821
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
822
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
823
func (r *Runner) handlePatternMatchError(err error, tool string) error {
8✔
824
        var patternErr *executor.PatternMatchError
8✔
825
        if errors.As(err, &patternErr) {
12✔
826
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
4✔
827
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
4✔
828
                return err
4✔
829
        }
4✔
830
        return nil
4✔
831
}
832

833
// runFinalize executes the optional finalize step after successful reviews.
834
// runs once, best-effort: failures are logged but don't block success.
835
// exception: context cancellation is propagated (user wants to abort).
836
func (r *Runner) runFinalize(ctx context.Context) error {
19✔
837
        if !r.cfg.FinalizeEnabled {
30✔
838
                return nil
11✔
839
        }
11✔
840

841
        r.log.SetPhase(status.PhaseFinalize)
8✔
842
        r.log.PrintSection(status.NewGenericSection("finalize step"))
8✔
843

8✔
844
        prompt := r.replacePromptVariables(r.cfg.AppConfig.FinalizePrompt)
8✔
845
        result := r.claude.Run(ctx, prompt)
8✔
846

8✔
847
        if result.Error != nil {
10✔
848
                // propagate context cancellation - user wants to abort
2✔
849
                if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, context.DeadlineExceeded) {
3✔
850
                        return fmt.Errorf("finalize step: %w", result.Error)
1✔
851
                }
1✔
852
                // pattern match (rate limit) - log via shared helper, but don't fail (best-effort)
853
                if r.handlePatternMatchError(result.Error, "claude") != nil {
1✔
854
                        return nil //nolint:nilerr // intentional: best-effort semantics, log but don't propagate
×
855
                }
×
856
                // best-effort: log error but don't fail
857
                r.log.Print("finalize step failed: %v", result.Error)
1✔
858
                return nil
1✔
859
        }
860

861
        if result.Signal == SignalFailed {
7✔
862
                r.log.Print("finalize step reported failure (non-blocking)")
1✔
863
                return nil
1✔
864
        }
1✔
865

866
        r.log.Print("finalize step completed")
5✔
867
        return nil
5✔
868
}
869

870
// needsCodexBinary returns true if the current configuration requires the codex binary.
871
// returns false when external_review_tool is "custom" or "none", since codex isn't used.
872
func needsCodexBinary(appConfig *config.Config) bool {
3✔
873
        if appConfig == nil {
3✔
874
                return true // default behavior assumes codex
×
875
        }
×
876
        switch appConfig.ExternalReviewTool {
3✔
877
        case "custom", "none":
2✔
878
                return false
2✔
879
        default:
1✔
880
                return true // "codex" or empty (default) requires codex binary
1✔
881
        }
882
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc