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

umputun / ralphex / 21742676820

06 Feb 2026 07:37AM UTC coverage: 80.038% (-0.8%) from 80.861%
21742676820

Pull #68

github

Claude
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
Pull Request #68: refactor: address code review findings and improve package structure

194 of 205 new or added lines in 12 files covered. (94.63%)

181 existing lines in 7 files now uncovered.

4226 of 5280 relevant lines covered (80.04%)

140.39 hits per line

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

85.92
/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
        inputCollector InputCollector
94
        iterationDelay time.Duration
95
        taskRetryCount int
96
}
97

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

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

130
        // auto-disable codex if the binary is not installed
131
        if cfg.CodexEnabled {
2✔
132
                codexCmd := codexExec.Command
1✔
133
                if codexCmd == "" {
1✔
UNCOV
134
                        codexCmd = "codex"
×
UNCOV
135
                }
×
136
                if _, err := exec.LookPath(codexCmd); err != nil {
2✔
137
                        log.Print("warning: codex not found (%s: %v), disabling codex review phase", codexCmd, err)
1✔
138
                        cfg.CodexEnabled = false
1✔
139
                }
1✔
140
        }
141

142
        return NewWithExecutors(cfg, log, claudeExec, codexExec)
1✔
143
}
144

145
// NewWithExecutors creates a new Runner with custom executors (for testing).
146
func NewWithExecutors(cfg Config, log Logger, claude, codex Executor) *Runner {
57✔
147
        // determine iteration delay from config or default
57✔
148
        iterDelay := DefaultIterationDelay
57✔
149
        if cfg.IterationDelayMs > 0 {
73✔
150
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
16✔
151
        }
16✔
152

153
        // determine task retry count from config
154
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
155
        retryCount := 1
57✔
156
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
102✔
157
                retryCount = cfg.TaskRetryCount
45✔
158
        } else if cfg.TaskRetryCount > 0 {
58✔
159
                retryCount = cfg.TaskRetryCount
1✔
160
        }
1✔
161

162
        return &Runner{
57✔
163
                cfg:            cfg,
57✔
164
                log:            log,
57✔
165
                claude:         claude,
57✔
166
                codex:          codex,
57✔
167
                iterationDelay: iterDelay,
57✔
168
                taskRetryCount: retryCount,
57✔
169
        }
57✔
170
}
171

172
// SetInputCollector sets the input collector for plan creation mode.
173
func (r *Runner) SetInputCollector(c InputCollector) {
15✔
174
        r.inputCollector = c
15✔
175
}
15✔
176

177
// Run executes the main loop based on configured mode.
178
func (r *Runner) Run(ctx context.Context) error {
47✔
179
        switch r.cfg.Mode {
47✔
180
        case ModeFull:
13✔
181
                return r.runFull(ctx)
13✔
182
        case ModeReview:
8✔
183
                return r.runReviewOnly(ctx)
8✔
184
        case ModeCodexOnly:
5✔
185
                return r.runCodexOnly(ctx)
5✔
186
        case ModeTasksOnly:
4✔
187
                return r.runTasksOnly(ctx)
4✔
188
        case ModePlan:
16✔
189
                return r.runPlanCreation(ctx)
16✔
190
        default:
1✔
191
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
192
        }
193
}
194

195
// runFull executes the complete pipeline: tasks → review → codex → review.
196
func (r *Runner) runFull(ctx context.Context) error {
13✔
197
        if r.cfg.PlanFile == "" {
14✔
198
                return errors.New("plan file required for full mode")
1✔
199
        }
1✔
200

201
        // phase 1: task execution
202
        r.log.SetPhase(status.PhaseTask)
12✔
203
        r.log.PrintRaw("starting task execution phase\n")
12✔
204

12✔
205
        if err := r.runTaskPhase(ctx); err != nil {
18✔
206
                return fmt.Errorf("task phase: %w", err)
6✔
207
        }
6✔
208

209
        // phase 2: first review pass - address ALL findings
210
        r.log.SetPhase(status.PhaseReview)
6✔
211
        r.log.PrintSection(status.NewGenericSection("claude review 0: all findings"))
6✔
212

6✔
213
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
6✔
UNCOV
214
                return fmt.Errorf("first review: %w", err)
×
UNCOV
215
        }
×
216

217
        // phase 2.1: claude review loop (critical/major) before codex
218
        if err := r.runClaudeReviewLoop(ctx); err != nil {
6✔
UNCOV
219
                return fmt.Errorf("pre-codex review loop: %w", err)
×
UNCOV
220
        }
×
221

222
        // phase 2.5+3: codex → post-codex review → finalize
223
        if err := r.runCodexAndPostReview(ctx); err != nil {
6✔
UNCOV
224
                return err
×
UNCOV
225
        }
×
226

227
        r.log.Print("all phases completed successfully")
6✔
228
        return nil
6✔
229
}
230

231
// runReviewOnly executes only the review pipeline: review → codex → review.
232
func (r *Runner) runReviewOnly(ctx context.Context) error {
8✔
233
        // phase 1: first review
8✔
234
        r.log.SetPhase(status.PhaseReview)
8✔
235
        r.log.PrintSection(status.NewGenericSection("claude review 0: all findings"))
8✔
236

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

241
        // phase 1.1: claude review loop (critical/major) before codex
242
        if err := r.runClaudeReviewLoop(ctx); err != nil {
8✔
243
                return fmt.Errorf("pre-codex review loop: %w", err)
1✔
244
        }
1✔
245

246
        // phase 2+3: codex → post-codex review → finalize
247
        if err := r.runCodexAndPostReview(ctx); err != nil {
9✔
248
                return err
3✔
249
        }
3✔
250

251
        r.log.Print("review phases completed successfully")
3✔
252
        return nil
3✔
253
}
254

255
// runCodexOnly executes only the codex pipeline: codex → review → finalize.
256
func (r *Runner) runCodexOnly(ctx context.Context) error {
5✔
257
        if err := r.runCodexAndPostReview(ctx); err != nil {
5✔
UNCOV
258
                return err
×
UNCOV
259
        }
×
260

261
        r.log.Print("codex phases completed successfully")
5✔
262
        return nil
5✔
263
}
264

265
// runCodexAndPostReview runs the shared codex → post-codex claude review → finalize pipeline.
266
// used by runFull, runReviewOnly, and runCodexOnly to avoid duplicating this sequence.
267
func (r *Runner) runCodexAndPostReview(ctx context.Context) error {
17✔
268
        // codex external review loop
17✔
269
        r.log.SetPhase(status.PhaseCodex)
17✔
270
        r.log.PrintSection(status.NewGenericSection("codex external review"))
17✔
271

17✔
272
        if err := r.runCodexLoop(ctx); err != nil {
19✔
273
                return fmt.Errorf("codex loop: %w", err)
2✔
274
        }
2✔
275

276
        // claude review loop (critical/major) after codex
277
        r.log.SetPhase(status.PhaseReview)
15✔
278

15✔
279
        if err := r.runClaudeReviewLoop(ctx); err != nil {
15✔
UNCOV
280
                return fmt.Errorf("post-codex review loop: %w", err)
×
UNCOV
281
        }
×
282

283
        // optional finalize step (best-effort, but propagates context cancellation)
284
        return r.runFinalize(ctx)
15✔
285
}
286

287
// runTasksOnly executes only task phase, skipping all reviews.
288
func (r *Runner) runTasksOnly(ctx context.Context) error {
4✔
289
        if r.cfg.PlanFile == "" {
5✔
290
                return errors.New("plan file required for tasks-only mode")
1✔
291
        }
1✔
292

293
        r.log.SetPhase(status.PhaseTask)
3✔
294
        r.log.PrintRaw("starting task execution phase\n")
3✔
295

3✔
296
        if err := r.runTaskPhase(ctx); err != nil {
4✔
297
                return fmt.Errorf("task phase: %w", err)
1✔
298
        }
1✔
299

300
        r.log.Print("task execution completed successfully")
2✔
301
        return nil
2✔
302
}
303

304
// runTaskPhase executes tasks until completion or max iterations.
305
// executes ONE Task section per iteration.
306
func (r *Runner) runTaskPhase(ctx context.Context) error {
15✔
307
        prompt := r.replacePromptVariables(r.cfg.AppConfig.TaskPrompt)
15✔
308
        retryCount := 0
15✔
309

15✔
310
        for i := 1; i <= r.cfg.MaxIterations; i++ {
34✔
311
                select {
19✔
312
                case <-ctx.Done():
1✔
313
                        return fmt.Errorf("task phase: %w", ctx.Err())
1✔
314
                default:
18✔
315
                }
316

317
                r.log.PrintSection(status.NewTaskIterationSection(i))
18✔
318

18✔
319
                result := r.claude.Run(ctx, prompt)
18✔
320
                if result.Error != nil {
20✔
321
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
322
                                return err
1✔
323
                        }
1✔
324
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
325
                }
326

327
                if result.Signal == SignalCompleted {
24✔
328
                        // verify plan actually has no uncompleted checkboxes
8✔
329
                        if r.hasUncompletedTasks() {
8✔
UNCOV
330
                                r.log.Print("warning: completion signal received but plan still has [ ] items, continuing...")
×
UNCOV
331
                                continue
×
332
                        }
333
                        r.log.PrintRaw("\nall tasks completed, starting code review...\n")
8✔
334
                        return nil
8✔
335
                }
336

337
                if result.Signal == SignalFailed {
13✔
338
                        if retryCount < r.taskRetryCount {
7✔
339
                                r.log.Print("task failed, retrying...")
2✔
340
                                retryCount++
2✔
341
                                time.Sleep(r.iterationDelay)
2✔
342
                                continue
2✔
343
                        }
344
                        return errors.New("task execution failed after retry (FAILED signal received)")
3✔
345
                }
346

347
                retryCount = 0
3✔
348
                // continue with same prompt - it reads from plan file each time
3✔
349
                time.Sleep(r.iterationDelay)
3✔
350
        }
351

352
        return fmt.Errorf("max iterations (%d) reached without completion", r.cfg.MaxIterations)
1✔
353
}
354

355
// runClaudeReview runs Claude review with the given prompt until REVIEW_DONE.
356
func (r *Runner) runClaudeReview(ctx context.Context, prompt string) error {
14✔
357
        result := r.claude.Run(ctx, prompt)
14✔
358
        if result.Error != nil {
14✔
UNCOV
359
                if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
×
UNCOV
360
                        return err
×
UNCOV
361
                }
×
UNCOV
362
                return fmt.Errorf("claude execution: %w", result.Error)
×
363
        }
364

365
        if result.Signal == SignalFailed {
15✔
366
                return errors.New("review failed (FAILED signal received)")
1✔
367
        }
1✔
368

369
        if !IsReviewDone(result.Signal) {
13✔
UNCOV
370
                r.log.Print("warning: first review pass did not complete cleanly, continuing...")
×
UNCOV
371
        }
×
372

373
        return nil
13✔
374
}
375

376
// runClaudeReviewLoop runs claude review iterations using second review prompt.
377
func (r *Runner) runClaudeReviewLoop(ctx context.Context) error {
28✔
378
        // review iterations = 10% of max_iterations
28✔
379
        maxReviewIterations := max(minReviewIterations, r.cfg.MaxIterations/reviewIterationDivisor)
28✔
380

28✔
381
        for i := 1; i <= maxReviewIterations; i++ {
56✔
382
                select {
28✔
UNCOV
383
                case <-ctx.Done():
×
UNCOV
384
                        return fmt.Errorf("review: %w", ctx.Err())
×
385
                default:
28✔
386
                }
387

388
                r.log.PrintSection(status.NewClaudeReviewSection(i, ": critical/major"))
28✔
389

28✔
390
                result := r.claude.Run(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewSecondPrompt))
28✔
391
                if result.Error != nil {
29✔
392
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
2✔
393
                                return err
1✔
394
                        }
1✔
UNCOV
395
                        return fmt.Errorf("claude execution: %w", result.Error)
×
396
                }
397

398
                if result.Signal == SignalFailed {
27✔
399
                        return errors.New("review failed (FAILED signal received)")
×
UNCOV
400
                }
×
401

402
                if IsReviewDone(result.Signal) {
54✔
403
                        r.log.Print("claude review complete - no more findings")
27✔
404
                        return nil
27✔
405
                }
27✔
406

UNCOV
407
                r.log.Print("issues fixed, running another review iteration...")
×
UNCOV
408
                time.Sleep(r.iterationDelay)
×
409
        }
410

UNCOV
411
        r.log.Print("max claude review iterations reached, continuing...")
×
UNCOV
412
        return nil
×
413
}
414

415
// runCodexLoop runs the codex-claude review loop until no findings.
416
func (r *Runner) runCodexLoop(ctx context.Context) error {
17✔
417
        // skip codex phase if disabled
17✔
418
        if !r.cfg.CodexEnabled {
25✔
419
                r.log.Print("codex review disabled, skipping...")
8✔
420
                return nil
8✔
421
        }
8✔
422

423
        // codex iterations = 20% of max_iterations
424
        maxCodexIterations := max(minCodexIterations, r.cfg.MaxIterations/codexIterationDivisor)
9✔
425

9✔
426
        var claudeResponse string // first iteration has no prior response
9✔
427

9✔
428
        for i := 1; i <= maxCodexIterations; i++ {
18✔
429
                select {
9✔
UNCOV
430
                case <-ctx.Done():
×
UNCOV
431
                        return fmt.Errorf("codex loop: %w", ctx.Err())
×
432
                default:
9✔
433
                }
434

435
                r.log.PrintSection(status.NewCodexIterationSection(i))
9✔
436

9✔
437
                // run codex analysis
9✔
438
                codexResult := r.codex.Run(ctx, r.buildCodexPrompt(i == 1, claudeResponse))
9✔
439
                if codexResult.Error != nil {
11✔
440
                        if err := r.handlePatternMatchError(codexResult.Error, "codex"); err != nil {
3✔
441
                                return err
1✔
442
                        }
1✔
443
                        return fmt.Errorf("codex execution: %w", codexResult.Error)
1✔
444
                }
445

446
                if codexResult.Output == "" {
9✔
447
                        r.log.Print("codex review returned no output, skipping...")
2✔
448
                        break
2✔
449
                }
450

451
                // show codex findings summary before Claude evaluation
452
                r.showCodexSummary(codexResult.Output)
5✔
453

5✔
454
                // pass codex output to claude for evaluation and fixing
5✔
455
                r.log.SetPhase(status.PhaseClaudeEval)
5✔
456
                r.log.PrintSection(status.NewClaudeEvalSection())
5✔
457
                claudeResult := r.claude.Run(ctx, r.buildCodexEvaluationPrompt(codexResult.Output))
5✔
458

5✔
459
                // restore codex phase for next iteration
5✔
460
                r.log.SetPhase(status.PhaseCodex)
5✔
461
                if claudeResult.Error != nil {
5✔
UNCOV
462
                        if err := r.handlePatternMatchError(claudeResult.Error, "claude"); err != nil {
×
UNCOV
463
                                return err
×
UNCOV
464
                        }
×
UNCOV
465
                        return fmt.Errorf("claude execution: %w", claudeResult.Error)
×
466
                }
467

468
                claudeResponse = claudeResult.Output
5✔
469

5✔
470
                // exit only when claude sees "no findings" from codex
5✔
471
                if IsCodexDone(claudeResult.Signal) {
10✔
472
                        r.log.Print("codex review complete - no more findings")
5✔
473
                        return nil
5✔
474
                }
5✔
475

UNCOV
476
                time.Sleep(r.iterationDelay)
×
477
        }
478

479
        r.log.Print("max codex iterations reached, continuing to next phase...")
2✔
480
        return nil
2✔
481
}
482

483
// buildCodexPrompt creates the prompt for codex review.
484
func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string {
10✔
485
        // build plan context if available
10✔
486
        planContext := ""
10✔
487
        if r.cfg.PlanFile != "" {
13✔
488
                planContext = fmt.Sprintf(`
3✔
489
## Plan Context
3✔
490
The code implements the plan at: %s
3✔
491

3✔
492
---
3✔
493
`, r.resolvePlanFilePath())
3✔
494
        }
3✔
495

496
        // different diff command based on iteration
497
        var diffInstruction, diffDescription string
10✔
498
        if isFirst {
20✔
499
                defaultBranch := r.getDefaultBranch()
10✔
500
                diffInstruction = fmt.Sprintf("Run: git diff %s...HEAD", defaultBranch)
10✔
501
                diffDescription = fmt.Sprintf("code changes between %s and HEAD branch", defaultBranch)
10✔
502
        } else {
10✔
UNCOV
503
                diffInstruction = "Run: git diff"
×
504
                diffDescription = "uncommitted changes (Claude's fixes from previous iteration)"
×
505
        }
×
506

507
        basePrompt := fmt.Sprintf(`%sReview the %s.
10✔
508

10✔
509
%s
10✔
510

10✔
511
Analyze for:
10✔
512
- Bugs and logic errors
10✔
513
- Security vulnerabilities
10✔
514
- Race conditions
10✔
515
- Error handling gaps
10✔
516
- Code quality issues
10✔
517

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

10✔
520
        if claudeResponse != "" {
10✔
UNCOV
521
                return fmt.Sprintf(`%s
×
UNCOV
522

×
UNCOV
523
---
×
UNCOV
524
PREVIOUS REVIEW CONTEXT:
×
UNCOV
525
Claude (previous reviewer) responded to your findings:
×
UNCOV
526

×
UNCOV
527
%s
×
UNCOV
528

×
UNCOV
529
Re-evaluate considering Claude's arguments. If Claude's fixes are correct, acknowledge them.
×
UNCOV
530
If Claude's arguments are invalid, explain why the issues still exist.`, basePrompt, claudeResponse)
×
UNCOV
531
        }
×
532

533
        return basePrompt
10✔
534
}
535

536
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
537
func (r *Runner) hasUncompletedTasks() bool {
13✔
538
        content, err := os.ReadFile(r.resolvePlanFilePath())
13✔
539
        if err != nil {
13✔
UNCOV
540
                return true // assume incomplete if can't read
×
UNCOV
541
        }
×
542

543
        // look for uncompleted checkbox pattern: [ ] (not [x])
544
        for line := range strings.SplitSeq(string(content), "\n") {
43✔
545
                trimmed := strings.TrimSpace(line)
30✔
546
                if strings.HasPrefix(trimmed, "- [ ]") {
33✔
547
                        return true
3✔
548
                }
3✔
549
        }
550
        return false
10✔
551
}
552

553
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
554
// extracts text until first code block or maxCodexSummaryLen chars, whichever is shorter.
555
func (r *Runner) showCodexSummary(output string) {
5✔
556
        summary := output
5✔
557

5✔
558
        // trim to first code block if present
5✔
559
        if idx := strings.Index(summary, "```"); idx > 0 {
5✔
UNCOV
560
                summary = summary[:idx]
×
UNCOV
561
        }
×
562

563
        // limit to maxCodexSummaryLen chars
564
        if len(summary) > maxCodexSummaryLen {
5✔
NEW
565
                summary = summary[:maxCodexSummaryLen] + "..."
×
UNCOV
566
        }
×
567

568
        summary = strings.TrimSpace(summary)
5✔
569
        if summary == "" {
5✔
UNCOV
570
                return
×
UNCOV
571
        }
×
572

573
        r.log.Print("codex findings:")
5✔
574
        for line := range strings.SplitSeq(summary, "\n") {
10✔
575
                if strings.TrimSpace(line) == "" {
5✔
UNCOV
576
                        continue
×
577
                }
578
                r.log.PrintAligned("  " + line)
5✔
579
        }
580
}
581

582
// ErrUserRejectedPlan is returned when user rejects the plan draft.
583
var ErrUserRejectedPlan = errors.New("user rejected plan")
584

585
// draftReviewResult holds the result of draft review handling.
586
type draftReviewResult struct {
587
        handled  bool   // true if draft was found and handled
588
        feedback string // revision feedback (non-empty only for "revise" action)
589
        err      error  // error if review failed or user rejected
590
}
591

592
// handlePlanDraft processes PLAN_DRAFT signal if present in output.
593
// returns result indicating whether draft was handled and any feedback/errors.
594
func (r *Runner) handlePlanDraft(ctx context.Context, output string) draftReviewResult {
15✔
595
        planContent, draftErr := ParsePlanDraftPayload(output)
15✔
596
        if draftErr != nil {
24✔
597
                // log malformed signals (but not "no signal" which is expected)
9✔
598
                if !errors.Is(draftErr, ErrNoPlanDraftSignal) {
10✔
599
                        r.log.Print("warning: %v", draftErr)
1✔
600
                }
1✔
601
                return draftReviewResult{handled: false}
9✔
602
        }
603

604
        r.log.Print("plan draft ready for review")
6✔
605

6✔
606
        action, feedback, askErr := r.inputCollector.AskDraftReview(ctx, "Review the plan draft", planContent)
6✔
607
        if askErr != nil {
7✔
608
                return draftReviewResult{handled: true, err: fmt.Errorf("collect draft review: %w", askErr)}
1✔
609
        }
1✔
610

611
        // log the draft review action and feedback to progress file
612
        r.log.LogDraftReview(action, feedback)
5✔
613

5✔
614
        switch action {
5✔
615
        case "accept":
3✔
616
                r.log.Print("draft accepted, continuing to write plan file...")
3✔
617
                return draftReviewResult{handled: true}
3✔
618
        case "revise":
1✔
619
                r.log.Print("revision requested, re-running with feedback...")
1✔
620
                return draftReviewResult{handled: true, feedback: feedback}
1✔
621
        case "reject":
1✔
622
                r.log.Print("plan rejected by user")
1✔
623
                return draftReviewResult{handled: true, err: ErrUserRejectedPlan}
1✔
624
        }
625

UNCOV
626
        return draftReviewResult{handled: true}
×
627
}
628

629
// handlePlanQuestion processes QUESTION signal if present in output.
630
// returns true if question was found and handled, false otherwise.
631
// returns error if question handling failed.
632
func (r *Runner) handlePlanQuestion(ctx context.Context, output string) (bool, error) {
9✔
633
        question, err := ParseQuestionPayload(output)
9✔
634
        if err != nil {
15✔
635
                // log malformed signals (but not "no signal" which is expected)
6✔
636
                if !errors.Is(err, ErrNoQuestionSignal) {
6✔
UNCOV
637
                        r.log.Print("warning: %v", err)
×
UNCOV
638
                }
×
639
                return false, nil
6✔
640
        }
641

642
        r.log.LogQuestion(question.Question, question.Options)
3✔
643

3✔
644
        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
3✔
645
        if askErr != nil {
4✔
646
                return true, fmt.Errorf("collect answer: %w", askErr)
1✔
647
        }
1✔
648

649
        r.log.LogAnswer(answer)
2✔
650
        return true, nil
2✔
651
}
652

653
// runPlanCreation executes the interactive plan creation loop.
654
// the loop continues until PLAN_READY signal or max iterations reached.
655
// handles QUESTION signals for Q&A and PLAN_DRAFT signals for draft review.
656
func (r *Runner) runPlanCreation(ctx context.Context) error {
16✔
657
        if r.cfg.PlanDescription == "" {
17✔
658
                return errors.New("plan description required for plan mode")
1✔
659
        }
1✔
660
        if r.inputCollector == nil {
16✔
661
                return errors.New("input collector required for plan mode")
1✔
662
        }
1✔
663

664
        r.log.SetPhase(status.PhasePlan)
14✔
665
        r.log.PrintRaw("starting interactive plan creation\n")
14✔
666
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
14✔
667

14✔
668
        // plan iterations use 20% of max_iterations
14✔
669
        maxPlanIterations := max(minPlanIterations, r.cfg.MaxIterations/planIterationDivisor)
14✔
670

14✔
671
        // track revision feedback for context in next iteration
14✔
672
        var lastRevisionFeedback string
14✔
673

14✔
674
        for i := 1; i <= maxPlanIterations; i++ {
39✔
675
                select {
25✔
676
                case <-ctx.Done():
1✔
677
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
678
                default:
24✔
679
                }
680

681
                r.log.PrintSection(status.NewPlanIterationSection(i))
24✔
682

24✔
683
                prompt := r.buildPlanPrompt()
24✔
684
                // append revision feedback context if present
24✔
685
                if lastRevisionFeedback != "" {
25✔
686
                        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✔
687
                        lastRevisionFeedback = "" // clear after use
1✔
688
                }
1✔
689

690
                result := r.claude.Run(ctx, prompt)
24✔
691
                if result.Error != nil {
26✔
692
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
693
                                return err
1✔
694
                        }
1✔
695
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
696
                }
697

698
                if result.Signal == SignalFailed {
23✔
699
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
700
                }
1✔
701

702
                // check for PLAN_READY signal
703
                if IsPlanReady(result.Signal) {
27✔
704
                        r.log.Print("plan creation completed")
6✔
705
                        return nil
6✔
706
                }
6✔
707

708
                // check for PLAN_DRAFT signal - present draft for user review
709
                draftResult := r.handlePlanDraft(ctx, result.Output)
15✔
710
                if draftResult.err != nil {
17✔
711
                        return draftResult.err
2✔
712
                }
2✔
713
                if draftResult.handled {
17✔
714
                        lastRevisionFeedback = draftResult.feedback
4✔
715
                        time.Sleep(r.iterationDelay)
4✔
716
                        continue
4✔
717
                }
718

719
                // check for QUESTION signal
720
                handled, err := r.handlePlanQuestion(ctx, result.Output)
9✔
721
                if err != nil {
10✔
722
                        return err
1✔
723
                }
1✔
724
                if handled {
10✔
725
                        time.Sleep(r.iterationDelay)
2✔
726
                        continue
2✔
727
                }
728

729
                // no question, no draft, and no completion - continue
730
                time.Sleep(r.iterationDelay)
6✔
731
        }
732

733
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
734
}
735

736
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
737
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
738
func (r *Runner) handlePatternMatchError(err error, tool string) error {
8✔
739
        var patternErr *executor.PatternMatchError
8✔
740
        if errors.As(err, &patternErr) {
12✔
741
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
4✔
742
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
4✔
743
                return err
4✔
744
        }
4✔
745
        return nil
4✔
746
}
747

748
// runFinalize executes the optional finalize step after successful reviews.
749
// runs once, best-effort: failures are logged but don't block success.
750
// exception: context cancellation is propagated (user wants to abort).
751
func (r *Runner) runFinalize(ctx context.Context) error {
15✔
752
        if !r.cfg.FinalizeEnabled {
22✔
753
                return nil
7✔
754
        }
7✔
755

756
        r.log.SetPhase(status.PhaseFinalize)
8✔
757
        r.log.PrintSection(status.NewGenericSection("finalize step"))
8✔
758

8✔
759
        prompt := r.replacePromptVariables(r.cfg.AppConfig.FinalizePrompt)
8✔
760
        result := r.claude.Run(ctx, prompt)
8✔
761

8✔
762
        if result.Error != nil {
10✔
763
                // propagate context cancellation - user wants to abort
2✔
764
                if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, context.DeadlineExceeded) {
3✔
765
                        return fmt.Errorf("finalize step: %w", result.Error)
1✔
766
                }
1✔
767
                // pattern match (rate limit) - log via shared helper, but don't fail (best-effort)
768
                if r.handlePatternMatchError(result.Error, "claude") != nil {
1✔
UNCOV
769
                        return nil //nolint:nilerr // intentional: best-effort semantics, log but don't propagate
×
UNCOV
770
                }
×
771
                // best-effort: log error but don't fail
772
                r.log.Print("finalize step failed: %v", result.Error)
1✔
773
                return nil
1✔
774
        }
775

776
        if result.Signal == SignalFailed {
7✔
777
                r.log.Print("finalize step reported failure (non-blocking)")
1✔
778
                return nil
1✔
779
        }
1✔
780

781
        r.log.Print("finalize step completed")
5✔
782
        return nil
5✔
783
}
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