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

umputun / ralphex / 21493195690

29 Jan 2026 08:13PM UTC coverage: 79.83% (+0.2%) from 79.632%
21493195690

push

github

umputun
docs: document error pattern detection feature

Update CLAUDE.md and README.md with error pattern detection configuration.
Move completed plan to docs/plans/completed/.

4049 of 5072 relevant lines covered (79.83%)

123.57 hits per line

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

82.15
/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
        "path/filepath"
11
        "strings"
12
        "time"
13

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

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

21
// Mode represents the execution mode.
22
type Mode string
23

24
const (
25
        ModeFull      Mode = "full"       // full execution: tasks + reviews + codex
26
        ModeReview    Mode = "review"     // skip tasks, run full review pipeline
27
        ModeCodexOnly Mode = "codex-only" // skip tasks and first review, run only codex loop
28
        ModePlan      Mode = "plan"       // interactive plan creation mode
29
)
30

31
// Config holds runner configuration.
32
type Config struct {
33
        PlanFile         string         // path to plan file (required for full mode)
34
        PlanDescription  string         // plan description for interactive plan creation mode
35
        ProgressPath     string         // path to progress file
36
        Mode             Mode           // execution mode
37
        MaxIterations    int            // maximum iterations for task phase
38
        Debug            bool           // enable debug output
39
        NoColor          bool           // disable color output
40
        IterationDelayMs int            // delay between iterations in milliseconds
41
        TaskRetryCount   int            // number of times to retry failed tasks
42
        CodexEnabled     bool           // whether codex review is enabled
43
        DefaultBranch    string         // default branch name (detected from repo)
44
        AppConfig        *config.Config // full application config (for executors and prompts)
45
}
46

47
//go:generate moq -out mocks/executor.go -pkg mocks -skip-ensure -fmt goimports . Executor
48
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
49
//go:generate moq -out mocks/input_collector.go -pkg mocks -skip-ensure -fmt goimports . InputCollector
50

51
// Executor runs CLI commands and returns results.
52
type Executor interface {
53
        Run(ctx context.Context, prompt string) executor.Result
54
}
55

56
// Logger provides logging functionality.
57
type Logger interface {
58
        SetPhase(phase Phase)
59
        Print(format string, args ...any)
60
        PrintRaw(format string, args ...any)
61
        PrintSection(section Section)
62
        PrintAligned(text string)
63
        LogQuestion(question string, options []string)
64
        LogAnswer(answer string)
65
        Path() string
66
}
67

68
// InputCollector provides interactive input collection for plan creation.
69
type InputCollector interface {
70
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
71
}
72

73
// Runner orchestrates the execution loop.
74
type Runner struct {
75
        cfg            Config
76
        log            Logger
77
        claude         Executor
78
        codex          Executor
79
        inputCollector InputCollector
80
        iterationDelay time.Duration
81
        taskRetryCount int
82
}
83

84
// New creates a new Runner with the given configuration.
85
// If codex is enabled but the binary is not found in PATH, it is automatically disabled with a warning.
86
func New(cfg Config, log Logger) *Runner {
1✔
87
        // build claude executor with config values
1✔
88
        claudeExec := &executor.ClaudeExecutor{
1✔
89
                OutputHandler: func(text string) {
1✔
90
                        log.PrintAligned(text)
×
91
                },
×
92
                Debug: cfg.Debug,
93
        }
94
        if cfg.AppConfig != nil {
2✔
95
                claudeExec.Command = cfg.AppConfig.ClaudeCommand
1✔
96
                claudeExec.Args = cfg.AppConfig.ClaudeArgs
1✔
97
                claudeExec.ErrorPatterns = cfg.AppConfig.ClaudeErrorPatterns
1✔
98
        }
1✔
99

100
        // build codex executor with config values
101
        codexExec := &executor.CodexExecutor{
1✔
102
                OutputHandler: func(text string) {
1✔
103
                        log.PrintAligned(text)
×
104
                },
×
105
                Debug: cfg.Debug,
106
        }
107
        if cfg.AppConfig != nil {
2✔
108
                codexExec.Command = cfg.AppConfig.CodexCommand
1✔
109
                codexExec.Model = cfg.AppConfig.CodexModel
1✔
110
                codexExec.ReasoningEffort = cfg.AppConfig.CodexReasoningEffort
1✔
111
                codexExec.TimeoutMs = cfg.AppConfig.CodexTimeoutMs
1✔
112
                codexExec.Sandbox = cfg.AppConfig.CodexSandbox
1✔
113
                codexExec.ErrorPatterns = cfg.AppConfig.CodexErrorPatterns
1✔
114
        }
1✔
115

116
        // auto-disable codex if the binary is not installed
117
        if cfg.CodexEnabled {
2✔
118
                codexCmd := codexExec.Command
1✔
119
                if codexCmd == "" {
1✔
120
                        codexCmd = "codex"
×
121
                }
×
122
                if _, err := exec.LookPath(codexCmd); err != nil {
2✔
123
                        log.Print("warning: codex not found (%s: %v), disabling codex review phase", codexCmd, err)
1✔
124
                        cfg.CodexEnabled = false
1✔
125
                }
1✔
126
        }
127

128
        return NewWithExecutors(cfg, log, claudeExec, codexExec)
1✔
129
}
130

131
// NewWithExecutors creates a new Runner with custom executors (for testing).
132
func NewWithExecutors(cfg Config, log Logger, claude, codex Executor) *Runner {
36✔
133
        // determine iteration delay from config or default
36✔
134
        iterDelay := DefaultIterationDelay
36✔
135
        if cfg.IterationDelayMs > 0 {
46✔
136
                iterDelay = time.Duration(cfg.IterationDelayMs) * time.Millisecond
10✔
137
        }
10✔
138

139
        // determine task retry count from config
140
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
141
        retryCount := 1
36✔
142
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
63✔
143
                retryCount = cfg.TaskRetryCount
27✔
144
        } else if cfg.TaskRetryCount > 0 {
37✔
145
                retryCount = cfg.TaskRetryCount
1✔
146
        }
1✔
147

148
        return &Runner{
36✔
149
                cfg:            cfg,
36✔
150
                log:            log,
36✔
151
                claude:         claude,
36✔
152
                codex:          codex,
36✔
153
                iterationDelay: iterDelay,
36✔
154
                taskRetryCount: retryCount,
36✔
155
        }
36✔
156
}
157

158
// SetInputCollector sets the input collector for plan creation mode.
159
func (r *Runner) SetInputCollector(c InputCollector) {
9✔
160
        r.inputCollector = c
9✔
161
}
9✔
162

163
// Run executes the main loop based on configured mode.
164
func (r *Runner) Run(ctx context.Context) error {
28✔
165
        switch r.cfg.Mode {
28✔
166
        case ModeFull:
9✔
167
                return r.runFull(ctx)
9✔
168
        case ModeReview:
5✔
169
                return r.runReviewOnly(ctx)
5✔
170
        case ModeCodexOnly:
3✔
171
                return r.runCodexOnly(ctx)
3✔
172
        case ModePlan:
10✔
173
                return r.runPlanCreation(ctx)
10✔
174
        default:
1✔
175
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
176
        }
177
}
178

179
// runFull executes the complete pipeline: tasks → review → codex → review.
180
func (r *Runner) runFull(ctx context.Context) error {
9✔
181
        if r.cfg.PlanFile == "" {
10✔
182
                return errors.New("plan file required for full mode")
1✔
183
        }
1✔
184

185
        // phase 1: task execution
186
        r.log.SetPhase(PhaseTask)
8✔
187
        r.log.PrintRaw("starting task execution phase\n")
8✔
188

8✔
189
        if err := r.runTaskPhase(ctx); err != nil {
14✔
190
                return fmt.Errorf("task phase: %w", err)
6✔
191
        }
6✔
192

193
        // phase 2: first review pass - address ALL findings
194
        r.log.SetPhase(PhaseReview)
2✔
195
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
2✔
196

2✔
197
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
2✔
198
                return fmt.Errorf("first review: %w", err)
×
199
        }
×
200

201
        // phase 2.1: claude review loop (critical/major) before codex
202
        if err := r.runClaudeReviewLoop(ctx); err != nil {
2✔
203
                return fmt.Errorf("pre-codex review loop: %w", err)
×
204
        }
×
205

206
        // phase 2.5: codex external review loop
207
        r.log.SetPhase(PhaseCodex)
2✔
208
        r.log.PrintSection(NewGenericSection("codex external review"))
2✔
209

2✔
210
        if err := r.runCodexLoop(ctx); err != nil {
2✔
211
                return fmt.Errorf("codex loop: %w", err)
×
212
        }
×
213

214
        // phase 3: claude review loop (critical/major) after codex
215
        r.log.SetPhase(PhaseReview)
2✔
216

2✔
217
        if err := r.runClaudeReviewLoop(ctx); err != nil {
2✔
218
                return fmt.Errorf("post-codex review loop: %w", err)
×
219
        }
×
220

221
        r.log.Print("all phases completed successfully")
2✔
222
        return nil
2✔
223
}
224

225
// runReviewOnly executes only the review pipeline: review → codex → review.
226
func (r *Runner) runReviewOnly(ctx context.Context) error {
5✔
227
        // phase 1: first review
5✔
228
        r.log.SetPhase(PhaseReview)
5✔
229
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
5✔
230

5✔
231
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
6✔
232
                return fmt.Errorf("first review: %w", err)
1✔
233
        }
1✔
234

235
        // phase 1.1: claude review loop (critical/major) before codex
236
        if err := r.runClaudeReviewLoop(ctx); err != nil {
5✔
237
                return fmt.Errorf("pre-codex review loop: %w", err)
1✔
238
        }
1✔
239

240
        // phase 2: codex external review loop
241
        r.log.SetPhase(PhaseCodex)
3✔
242
        r.log.PrintSection(NewGenericSection("codex external review"))
3✔
243

3✔
244
        if err := r.runCodexLoop(ctx); err != nil {
5✔
245
                return fmt.Errorf("codex loop: %w", err)
2✔
246
        }
2✔
247

248
        // phase 3: claude review loop (critical/major) after codex
249
        r.log.SetPhase(PhaseReview)
1✔
250

1✔
251
        if err := r.runClaudeReviewLoop(ctx); err != nil {
1✔
252
                return fmt.Errorf("post-codex review loop: %w", err)
×
253
        }
×
254

255
        r.log.Print("review phases completed successfully")
1✔
256
        return nil
1✔
257
}
258

259
// runCodexOnly executes only the codex pipeline: codex → review.
260
func (r *Runner) runCodexOnly(ctx context.Context) error {
3✔
261
        // phase 1: codex external review loop
3✔
262
        r.log.SetPhase(PhaseCodex)
3✔
263
        r.log.PrintSection(NewGenericSection("codex external review"))
3✔
264

3✔
265
        if err := r.runCodexLoop(ctx); err != nil {
3✔
266
                return fmt.Errorf("codex loop: %w", err)
×
267
        }
×
268

269
        // phase 2: claude review loop (critical/major) after codex
270
        r.log.SetPhase(PhaseReview)
3✔
271

3✔
272
        if err := r.runClaudeReviewLoop(ctx); err != nil {
3✔
273
                return fmt.Errorf("post-codex review loop: %w", err)
×
274
        }
×
275

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

280
// runTaskPhase executes tasks until completion or max iterations.
281
// executes ONE Task section per iteration.
282
func (r *Runner) runTaskPhase(ctx context.Context) error {
8✔
283
        prompt := r.replacePromptVariables(r.cfg.AppConfig.TaskPrompt)
8✔
284
        retryCount := 0
8✔
285

8✔
286
        for i := 1; i <= r.cfg.MaxIterations; i++ {
20✔
287
                select {
12✔
288
                case <-ctx.Done():
1✔
289
                        return fmt.Errorf("task phase: %w", ctx.Err())
1✔
290
                default:
11✔
291
                }
292

293
                r.log.PrintSection(NewTaskIterationSection(i))
11✔
294

11✔
295
                result := r.claude.Run(ctx, prompt)
11✔
296
                if result.Error != nil {
13✔
297
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
298
                                return err
1✔
299
                        }
1✔
300
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
301
                }
302

303
                if result.Signal == SignalCompleted {
11✔
304
                        // verify plan actually has no uncompleted checkboxes
2✔
305
                        if r.hasUncompletedTasks() {
2✔
306
                                r.log.Print("warning: completion signal received but plan still has [ ] items, continuing...")
×
307
                                continue
×
308
                        }
309
                        r.log.PrintRaw("\nall tasks completed, starting code review...\n")
2✔
310
                        return nil
2✔
311
                }
312

313
                if result.Signal == SignalFailed {
11✔
314
                        if retryCount < r.taskRetryCount {
6✔
315
                                r.log.Print("task failed, retrying...")
2✔
316
                                retryCount++
2✔
317
                                time.Sleep(r.iterationDelay)
2✔
318
                                continue
2✔
319
                        }
320
                        return errors.New("task execution failed after retry (FAILED signal received)")
2✔
321
                }
322

323
                retryCount = 0
3✔
324
                // continue with same prompt - it reads from plan file each time
3✔
325
                time.Sleep(r.iterationDelay)
3✔
326
        }
327

328
        return fmt.Errorf("max iterations (%d) reached without completion", r.cfg.MaxIterations)
1✔
329
}
330

331
// runClaudeReview runs Claude review with the given prompt until REVIEW_DONE.
332
func (r *Runner) runClaudeReview(ctx context.Context, prompt string) error {
7✔
333
        result := r.claude.Run(ctx, prompt)
7✔
334
        if result.Error != nil {
7✔
335
                if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
×
336
                        return err
×
337
                }
×
338
                return fmt.Errorf("claude execution: %w", result.Error)
×
339
        }
340

341
        if result.Signal == SignalFailed {
8✔
342
                return errors.New("review failed (FAILED signal received)")
1✔
343
        }
1✔
344

345
        if !IsReviewDone(result.Signal) {
6✔
346
                r.log.Print("warning: first review pass did not complete cleanly, continuing...")
×
347
        }
×
348

349
        return nil
6✔
350
}
351

352
// runClaudeReviewLoop runs claude review iterations using second review prompt.
353
func (r *Runner) runClaudeReviewLoop(ctx context.Context) error {
12✔
354
        // review iterations = 10% of max_iterations (min 3)
12✔
355
        maxReviewIterations := max(3, r.cfg.MaxIterations/10)
12✔
356

12✔
357
        for i := 1; i <= maxReviewIterations; i++ {
24✔
358
                select {
12✔
359
                case <-ctx.Done():
×
360
                        return fmt.Errorf("review: %w", ctx.Err())
×
361
                default:
12✔
362
                }
363

364
                r.log.PrintSection(NewClaudeReviewSection(i, ": critical/major"))
12✔
365

12✔
366
                result := r.claude.Run(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewSecondPrompt))
12✔
367
                if result.Error != nil {
13✔
368
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
2✔
369
                                return err
1✔
370
                        }
1✔
371
                        return fmt.Errorf("claude execution: %w", result.Error)
×
372
                }
373

374
                if result.Signal == SignalFailed {
11✔
375
                        return errors.New("review failed (FAILED signal received)")
×
376
                }
×
377

378
                if IsReviewDone(result.Signal) {
22✔
379
                        r.log.Print("claude review complete - no more findings")
11✔
380
                        return nil
11✔
381
                }
11✔
382

383
                r.log.Print("issues fixed, running another review iteration...")
×
384
                time.Sleep(r.iterationDelay)
×
385
        }
386

387
        r.log.Print("max claude review iterations reached, continuing...")
×
388
        return nil
×
389
}
390

391
// runCodexLoop runs the codex-claude review loop until no findings.
392
func (r *Runner) runCodexLoop(ctx context.Context) error {
8✔
393
        // skip codex phase if disabled
8✔
394
        if !r.cfg.CodexEnabled {
9✔
395
                r.log.Print("codex review disabled, skipping...")
1✔
396
                return nil
1✔
397
        }
1✔
398

399
        // codex iterations = 20% of max_iterations (min 3)
400
        maxCodexIterations := max(3, r.cfg.MaxIterations/5)
7✔
401

7✔
402
        var claudeResponse string // first iteration has no prior response
7✔
403

7✔
404
        for i := 1; i <= maxCodexIterations; i++ {
14✔
405
                select {
7✔
406
                case <-ctx.Done():
×
407
                        return fmt.Errorf("codex loop: %w", ctx.Err())
×
408
                default:
7✔
409
                }
410

411
                r.log.PrintSection(NewCodexIterationSection(i))
7✔
412

7✔
413
                // run codex analysis
7✔
414
                codexResult := r.codex.Run(ctx, r.buildCodexPrompt(i == 1, claudeResponse))
7✔
415
                if codexResult.Error != nil {
9✔
416
                        if err := r.handlePatternMatchError(codexResult.Error, "codex"); err != nil {
3✔
417
                                return err
1✔
418
                        }
1✔
419
                        return fmt.Errorf("codex execution: %w", codexResult.Error)
1✔
420
                }
421

422
                if codexResult.Output == "" {
7✔
423
                        r.log.Print("codex review returned no output, skipping...")
2✔
424
                        break
2✔
425
                }
426

427
                // show codex findings summary before Claude evaluation
428
                r.showCodexSummary(codexResult.Output)
3✔
429

3✔
430
                // pass codex output to claude for evaluation and fixing
3✔
431
                r.log.SetPhase(PhaseClaudeEval)
3✔
432
                r.log.PrintSection(NewClaudeEvalSection())
3✔
433
                claudeResult := r.claude.Run(ctx, r.buildCodexEvaluationPrompt(codexResult.Output))
3✔
434

3✔
435
                // restore codex phase for next iteration
3✔
436
                r.log.SetPhase(PhaseCodex)
3✔
437
                if claudeResult.Error != nil {
3✔
438
                        if err := r.handlePatternMatchError(claudeResult.Error, "claude"); err != nil {
×
439
                                return err
×
440
                        }
×
441
                        return fmt.Errorf("claude execution: %w", claudeResult.Error)
×
442
                }
443

444
                claudeResponse = claudeResult.Output
3✔
445

3✔
446
                // exit only when claude sees "no findings" from codex
3✔
447
                if IsCodexDone(claudeResult.Signal) {
6✔
448
                        r.log.Print("codex review complete - no more findings")
3✔
449
                        return nil
3✔
450
                }
3✔
451

452
                time.Sleep(r.iterationDelay)
×
453
        }
454

455
        r.log.Print("max codex iterations reached, continuing to next phase...")
2✔
456
        return nil
2✔
457
}
458

459
// buildCodexPrompt creates the prompt for codex review.
460
func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string {
7✔
461
        // build plan context if available
7✔
462
        planContext := ""
7✔
463
        if r.cfg.PlanFile != "" {
9✔
464
                planContext = fmt.Sprintf(`
2✔
465
## Plan Context
2✔
466
The code implements the plan at: %s
2✔
467

2✔
468
---
2✔
469
`, r.cfg.PlanFile)
2✔
470
        }
2✔
471

472
        // different diff command based on iteration
473
        var diffInstruction, diffDescription string
7✔
474
        if isFirst {
14✔
475
                defaultBranch := r.getDefaultBranch()
7✔
476
                diffInstruction = fmt.Sprintf("Run: git diff %s...HEAD", defaultBranch)
7✔
477
                diffDescription = fmt.Sprintf("code changes between %s and HEAD branch", defaultBranch)
7✔
478
        } else {
7✔
479
                diffInstruction = "Run: git diff"
×
480
                diffDescription = "uncommitted changes (Claude's fixes from previous iteration)"
×
481
        }
×
482

483
        basePrompt := fmt.Sprintf(`%sReview the %s.
7✔
484

7✔
485
%s
7✔
486

7✔
487
Analyze for:
7✔
488
- Bugs and logic errors
7✔
489
- Security vulnerabilities
7✔
490
- Race conditions
7✔
491
- Error handling gaps
7✔
492
- Code quality issues
7✔
493

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

7✔
496
        if claudeResponse != "" {
7✔
497
                return fmt.Sprintf(`%s
×
498

×
499
---
×
500
PREVIOUS REVIEW CONTEXT:
×
501
Claude (previous reviewer) responded to your findings:
×
502

×
503
%s
×
504

×
505
Re-evaluate considering Claude's arguments. If Claude's fixes are correct, acknowledge them.
×
506
If Claude's arguments are invalid, explain why the issues still exist.`, basePrompt, claudeResponse)
×
507
        }
×
508

509
        return basePrompt
7✔
510
}
511

512
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
513
// Checks both original path and completed/ subdirectory.
514
func (r *Runner) hasUncompletedTasks() bool {
6✔
515
        // try original path first
6✔
516
        content, err := os.ReadFile(r.cfg.PlanFile)
6✔
517
        if err != nil {
6✔
518
                // try completed/ subdirectory as fallback
×
519
                completedPath := filepath.Join(filepath.Dir(r.cfg.PlanFile), "completed", filepath.Base(r.cfg.PlanFile))
×
520
                content, err = os.ReadFile(completedPath) //nolint:gosec // planFile from CLI args
×
521
                if err != nil {
×
522
                        return true // assume incomplete if can't read from either location
×
523
                }
×
524
        }
525

526
        // look for uncompleted checkbox pattern: [ ] (not [x])
527
        for line := range strings.SplitSeq(string(content), "\n") {
21✔
528
                trimmed := strings.TrimSpace(line)
15✔
529
                if strings.HasPrefix(trimmed, "- [ ]") {
17✔
530
                        return true
2✔
531
                }
2✔
532
        }
533
        return false
4✔
534
}
535

536
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
537
// extracts text until first code block or 500 chars, whichever is shorter.
538
func (r *Runner) showCodexSummary(output string) {
3✔
539
        summary := output
3✔
540

3✔
541
        // trim to first code block if present
3✔
542
        if idx := strings.Index(summary, "```"); idx > 0 {
3✔
543
                summary = summary[:idx]
×
544
        }
×
545

546
        // limit to 5000 chars
547
        if len(summary) > 5000 {
3✔
548
                summary = summary[:5000] + "..."
×
549
        }
×
550

551
        summary = strings.TrimSpace(summary)
3✔
552
        if summary == "" {
3✔
553
                return
×
554
        }
×
555

556
        r.log.Print("codex findings:")
3✔
557
        for line := range strings.SplitSeq(summary, "\n") {
6✔
558
                if strings.TrimSpace(line) == "" {
3✔
559
                        continue
×
560
                }
561
                r.log.PrintAligned("  " + line)
3✔
562
        }
563
}
564

565
// runPlanCreation executes the interactive plan creation loop.
566
// the loop continues until PLAN_READY signal or max iterations reached.
567
func (r *Runner) runPlanCreation(ctx context.Context) error {
10✔
568
        if r.cfg.PlanDescription == "" {
11✔
569
                return errors.New("plan description required for plan mode")
1✔
570
        }
1✔
571
        if r.inputCollector == nil {
10✔
572
                return errors.New("input collector required for plan mode")
1✔
573
        }
1✔
574

575
        r.log.SetPhase(PhasePlan)
8✔
576
        r.log.PrintRaw("starting interactive plan creation\n")
8✔
577
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
8✔
578

8✔
579
        // plan iterations use 20% of max_iterations (min 5)
8✔
580
        maxPlanIterations := max(5, r.cfg.MaxIterations/5)
8✔
581

8✔
582
        for i := 1; i <= maxPlanIterations; i++ {
21✔
583
                select {
13✔
584
                case <-ctx.Done():
1✔
585
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
586
                default:
12✔
587
                }
588

589
                r.log.PrintSection(NewPlanIterationSection(i))
12✔
590

12✔
591
                prompt := r.buildPlanPrompt()
12✔
592
                result := r.claude.Run(ctx, prompt)
12✔
593
                if result.Error != nil {
14✔
594
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
595
                                return err
1✔
596
                        }
1✔
597
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
598
                }
599

600
                if result.Signal == SignalFailed {
11✔
601
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
602
                }
1✔
603

604
                // check for PLAN_READY signal
605
                if IsPlanReady(result.Signal) {
11✔
606
                        r.log.Print("plan creation completed")
2✔
607
                        return nil
2✔
608
                }
2✔
609

610
                // check for QUESTION signal
611
                question, err := ParseQuestionPayload(result.Output)
7✔
612
                if err == nil {
9✔
613
                        // got a question - ask user and log answer
2✔
614
                        r.log.LogQuestion(question.Question, question.Options)
2✔
615

2✔
616
                        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
2✔
617
                        if askErr != nil {
3✔
618
                                return fmt.Errorf("collect answer: %w", askErr)
1✔
619
                        }
1✔
620

621
                        r.log.LogAnswer(answer)
1✔
622

1✔
623
                        time.Sleep(r.iterationDelay)
1✔
624
                        continue
1✔
625
                }
626

627
                // log malformed question signals (but not "no question signal" which is expected)
628
                if !errors.Is(err, ErrNoQuestionSignal) {
5✔
629
                        r.log.Print("warning: %v", err)
×
630
                }
×
631

632
                // no question and no completion - continue
633
                time.Sleep(r.iterationDelay)
5✔
634
        }
635

636
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
637
}
638

639
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
640
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
641
func (r *Runner) handlePatternMatchError(err error, tool string) error {
7✔
642
        var patternErr *executor.PatternMatchError
7✔
643
        if errors.As(err, &patternErr) {
11✔
644
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
4✔
645
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
4✔
646
                return err
4✔
647
        }
4✔
648
        return nil
3✔
649
}
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