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

umputun / ralphex / 21613653476

03 Feb 2026 01:52AM UTC coverage: 80.674% (-0.1%) from 80.772%
21613653476

Pull #54

github

umputun
fix(docker): add symlink resolution for ralphex config directories

resolve symlink targets for both user-level (~/.config/ralphex/) and
project-level (.ralphex/) config directories. ensures configs with
symlinks to files under $HOME are accessible inside the container.
Pull Request #54: feat: add Docker support for isolated execution

4237 of 5252 relevant lines covered (80.67%)

124.62 hits per line

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

85.04
/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
)
16

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

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

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

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

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

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

55
// Logger provides logging functionality.
56
type Logger interface {
57
        SetPhase(phase Phase)
58
        Print(format string, args ...any)
59
        PrintRaw(format string, args ...any)
60
        PrintSection(section Section)
61
        PrintAligned(text string)
62
        LogQuestion(question string, options []string)
63
        LogAnswer(answer string)
64
        LogDraftReview(action string, feedback 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
        AskDraftReview(ctx context.Context, question string, planContent string) (action string, feedback string, err error)
72
}
73

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4✔
294
                r.log.PrintSection(NewTaskIterationSection(i))
1✔
295

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

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

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

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

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

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

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

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

1✔
350
        return nil
351
}
352

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

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

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

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

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

24✔
379
                if IsReviewDone(result.Signal) {
12✔
380
                        r.log.Print("claude review complete - no more findings")
×
381
                        return nil
×
382
                }
12✔
383

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

12✔
388
        r.log.Print("max claude review iterations reached, continuing...")
13✔
389
        return nil
2✔
390
}
1✔
391

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

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

11✔
403
        var claudeResponse string // first iteration has no prior response
404

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

412
                r.log.PrintSection(NewCodexIterationSection(i))
413

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

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

×
428
                // show codex findings summary before Claude evaluation
×
429
                r.showCodexSummary(codexResult.Output)
7✔
430

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

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

2✔
445
                claudeResponse = claudeResult.Output
2✔
446

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

3✔
453
                time.Sleep(r.iterationDelay)
3✔
454
        }
3✔
455

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

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

6✔
469
---
3✔
470
`, r.resolvePlanFilePath())
3✔
471
        }
3✔
472

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

8✔
484
        basePrompt := fmt.Sprintf(`%sReview the %s.
11✔
485

3✔
486
%s
3✔
487

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

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

8✔
497
        if claudeResponse != "" {
8✔
498
                return fmt.Sprintf(`%s
8✔
499

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

504
%s
8✔
505

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

8✔
510
        return basePrompt
8✔
511
}
8✔
512

8✔
513
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
8✔
514
func (r *Runner) hasUncompletedTasks() bool {
8✔
515
        content, err := os.ReadFile(r.resolvePlanFilePath())
8✔
516
        if err != nil {
8✔
517
                return true // assume incomplete if can't read
8✔
518
        }
×
519

×
520
        // look for uncompleted checkbox pattern: [ ] (not [x])
×
521
        for line := range strings.SplitSeq(string(content), "\n") {
×
522
                trimmed := strings.TrimSpace(line)
×
523
                if strings.HasPrefix(trimmed, "- [ ]") {
×
524
                        return true
×
525
                }
×
526
        }
×
527
        return false
×
528
}
×
529

530
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
8✔
531
// extracts text until first code block or 500 chars, whichever is shorter.
532
func (r *Runner) showCodexSummary(output string) {
533
        summary := output
534

9✔
535
        // trim to first code block if present
9✔
536
        if idx := strings.Index(summary, "```"); idx > 0 {
9✔
537
                summary = summary[:idx]
×
538
        }
×
539

540
        // limit to 5000 chars
541
        if len(summary) > 5000 {
31✔
542
                summary = summary[:5000] + "..."
22✔
543
        }
25✔
544

3✔
545
        summary = strings.TrimSpace(summary)
3✔
546
        if summary == "" {
547
                return
6✔
548
        }
549

550
        r.log.Print("codex findings:")
551
        for line := range strings.SplitSeq(summary, "\n") {
552
                if strings.TrimSpace(line) == "" {
3✔
553
                        continue
3✔
554
                }
3✔
555
                r.log.PrintAligned("  " + line)
3✔
556
        }
3✔
557
}
×
558

×
559
// ErrUserRejectedPlan is returned when user rejects the plan draft.
560
var ErrUserRejectedPlan = errors.New("user rejected plan")
561

3✔
562
// draftReviewResult holds the result of draft review handling.
×
563
type draftReviewResult struct {
×
564
        handled  bool   // true if draft was found and handled
565
        feedback string // revision feedback (non-empty only for "revise" action)
3✔
566
        err      error  // error if review failed or user rejected
3✔
567
}
×
568

×
569
// handlePlanDraft processes PLAN_DRAFT signal if present in output.
570
// returns result indicating whether draft was handled and any feedback/errors.
3✔
571
func (r *Runner) handlePlanDraft(ctx context.Context, output string) draftReviewResult {
6✔
572
        planContent, draftErr := ParsePlanDraftPayload(output)
3✔
573
        if draftErr != nil {
×
574
                // log malformed signals (but not "no signal" which is expected)
575
                if !errors.Is(draftErr, ErrNoPlanDraftSignal) {
3✔
576
                        r.log.Print("warning: %v", draftErr)
577
                }
578
                return draftReviewResult{handled: false}
579
        }
580

581
        r.log.Print("plan draft ready for review")
582

583
        action, feedback, askErr := r.inputCollector.AskDraftReview(ctx, "Review the plan draft", planContent)
584
        if askErr != nil {
585
                return draftReviewResult{handled: true, err: fmt.Errorf("collect draft review: %w", askErr)}
586
        }
587

588
        // log the draft review action and feedback to progress file
589
        r.log.LogDraftReview(action, feedback)
590

591
        switch action {
15✔
592
        case "accept":
15✔
593
                r.log.Print("draft accepted, continuing to write plan file...")
24✔
594
                return draftReviewResult{handled: true}
9✔
595
        case "revise":
10✔
596
                r.log.Print("revision requested, re-running with feedback...")
1✔
597
                return draftReviewResult{handled: true, feedback: feedback}
1✔
598
        case "reject":
9✔
599
                r.log.Print("plan rejected by user")
600
                return draftReviewResult{handled: true, err: ErrUserRejectedPlan}
601
        }
6✔
602

6✔
603
        return draftReviewResult{handled: true}
6✔
604
}
7✔
605

1✔
606
// handlePlanQuestion processes QUESTION signal if present in output.
1✔
607
// returns true if question was found and handled, false otherwise.
608
// returns error if question handling failed.
609
func (r *Runner) handlePlanQuestion(ctx context.Context, output string) (bool, error) {
5✔
610
        question, err := ParseQuestionPayload(output)
5✔
611
        if err != nil {
5✔
612
                // log malformed signals (but not "no signal" which is expected)
3✔
613
                if !errors.Is(err, ErrNoQuestionSignal) {
3✔
614
                        r.log.Print("warning: %v", err)
3✔
615
                }
1✔
616
                return false, nil
1✔
617
        }
1✔
618

1✔
619
        r.log.LogQuestion(question.Question, question.Options)
1✔
620

1✔
621
        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
622
        if askErr != nil {
623
                return true, fmt.Errorf("collect answer: %w", askErr)
×
624
        }
625

626
        r.log.LogAnswer(answer)
627
        return true, nil
628
}
629

9✔
630
// runPlanCreation executes the interactive plan creation loop.
9✔
631
// the loop continues until PLAN_READY signal or max iterations reached.
15✔
632
// handles QUESTION signals for Q&A and PLAN_DRAFT signals for draft review.
6✔
633
func (r *Runner) runPlanCreation(ctx context.Context) error {
6✔
634
        if r.cfg.PlanDescription == "" {
×
635
                return errors.New("plan description required for plan mode")
×
636
        }
6✔
637
        if r.inputCollector == nil {
638
                return errors.New("input collector required for plan mode")
639
        }
3✔
640

3✔
641
        r.log.SetPhase(PhasePlan)
3✔
642
        r.log.PrintRaw("starting interactive plan creation\n")
4✔
643
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
1✔
644

1✔
645
        // plan iterations use 20% of max_iterations (min 5)
646
        maxPlanIterations := max(5, r.cfg.MaxIterations/5)
2✔
647

2✔
648
        // track revision feedback for context in next iteration
649
        var lastRevisionFeedback string
650

651
        for i := 1; i <= maxPlanIterations; i++ {
652
                select {
653
                case <-ctx.Done():
16✔
654
                        return fmt.Errorf("plan creation: %w", ctx.Err())
17✔
655
                default:
1✔
656
                }
1✔
657

16✔
658
                r.log.PrintSection(NewPlanIterationSection(i))
1✔
659

1✔
660
                prompt := r.buildPlanPrompt()
661
                // append revision feedback context if present
14✔
662
                if lastRevisionFeedback != "" {
14✔
663
                        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)
14✔
664
                        lastRevisionFeedback = "" // clear after use
14✔
665
                }
14✔
666

14✔
667
                result := r.claude.Run(ctx, prompt)
14✔
668
                if result.Error != nil {
14✔
669
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
14✔
670
                                return err
14✔
671
                        }
39✔
672
                        return fmt.Errorf("claude execution: %w", result.Error)
25✔
673
                }
1✔
674

1✔
675
                if result.Signal == SignalFailed {
24✔
676
                        return errors.New("plan creation failed (FAILED signal received)")
677
                }
678

24✔
679
                // check for PLAN_READY signal
24✔
680
                if IsPlanReady(result.Signal) {
24✔
681
                        r.log.Print("plan creation completed")
24✔
682
                        return nil
25✔
683
                }
1✔
684

1✔
685
                // check for PLAN_DRAFT signal - present draft for user review
1✔
686
                draftResult := r.handlePlanDraft(ctx, result.Output)
687
                if draftResult.err != nil {
24✔
688
                        return draftResult.err
26✔
689
                }
3✔
690
                if draftResult.handled {
1✔
691
                        lastRevisionFeedback = draftResult.feedback
1✔
692
                        time.Sleep(r.iterationDelay)
1✔
693
                        continue
694
                }
695

23✔
696
                // check for QUESTION signal
1✔
697
                handled, err := r.handlePlanQuestion(ctx, result.Output)
1✔
698
                if err != nil {
699
                        return err
700
                }
27✔
701
                if handled {
6✔
702
                        time.Sleep(r.iterationDelay)
6✔
703
                        continue
6✔
704
                }
705

706
                // no question, no draft, and no completion - continue
15✔
707
                time.Sleep(r.iterationDelay)
17✔
708
        }
2✔
709

2✔
710
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
17✔
711
}
4✔
712

4✔
713
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
4✔
714
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
715
func (r *Runner) handlePatternMatchError(err error, tool string) error {
716
        var patternErr *executor.PatternMatchError
717
        if errors.As(err, &patternErr) {
9✔
718
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
10✔
719
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
1✔
720
                return err
1✔
721
        }
10✔
722
        return nil
2✔
723
}
2✔
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