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

umputun / ralphex / 21494411578

29 Jan 2026 08:55PM UTC coverage: 79.976% (+0.1%) from 79.83%
21494411578

push

github

web-flow
fix: resolve {{PLAN_FILE}} to completed/ path after plan is moved (#50)

* fix: resolve {{PLAN_FILE}} to completed/ path after plan is moved

When a plan file is moved to docs/plans/completed/ during execution,
subsequent {{PLAN_FILE}} template expansions still referenced the
original (now non-existent) path.

A plan file can be moved to completed/ in two scenarios:
- ralphex moves it automatically after all tasks complete
- a task in the plan itself moves the file as part of its instructions

Added resolvePlanFilePath() method that checks both locations and
returns the path where the file actually exists. Updated all callers:
getPlanFileRef(), getGoal(), and buildCodexPrompt().

* refactor: use resolvePlanFilePath() in hasUncompletedTasks, add tests

Address Copilot review comments:
- hasUncompletedTasks() now uses resolvePlanFilePath() instead of
  duplicating path resolution logic
- Added test for hasUncompletedTasks with file in completed/ dir
- Added test for buildCodexPrompt with file in completed/ dir
- Added TestBuildCodexPrompt export for testing

17 of 21 new or added lines in 2 files covered. (80.95%)

4066 of 5084 relevant lines covered (79.98%)

123.92 hits per line

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

82.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
)
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
        Path() string
65
}
66

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

348
        return nil
6✔
349
}
350

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

443
                claudeResponse = claudeResult.Output
3✔
444

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

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

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

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

3✔
467
---
3✔
468
`, r.resolvePlanFilePath())
3✔
469
        }
3✔
470

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

482
        basePrompt := fmt.Sprintf(`%sReview the %s.
8✔
483

8✔
484
%s
8✔
485

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

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

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

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

×
502
%s
×
503

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

508
        return basePrompt
8✔
509
}
510

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

518
        // look for uncompleted checkbox pattern: [ ] (not [x])
519
        for line := range strings.SplitSeq(string(content), "\n") {
24✔
520
                trimmed := strings.TrimSpace(line)
17✔
521
                if strings.HasPrefix(trimmed, "- [ ]") {
20✔
522
                        return true
3✔
523
                }
3✔
524
        }
525
        return false
4✔
526
}
527

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

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

538
        // limit to 5000 chars
539
        if len(summary) > 5000 {
3✔
540
                summary = summary[:5000] + "..."
×
541
        }
×
542

543
        summary = strings.TrimSpace(summary)
3✔
544
        if summary == "" {
3✔
545
                return
×
546
        }
×
547

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

557
// runPlanCreation executes the interactive plan creation loop.
558
// the loop continues until PLAN_READY signal or max iterations reached.
559
func (r *Runner) runPlanCreation(ctx context.Context) error {
10✔
560
        if r.cfg.PlanDescription == "" {
11✔
561
                return errors.New("plan description required for plan mode")
1✔
562
        }
1✔
563
        if r.inputCollector == nil {
10✔
564
                return errors.New("input collector required for plan mode")
1✔
565
        }
1✔
566

567
        r.log.SetPhase(PhasePlan)
8✔
568
        r.log.PrintRaw("starting interactive plan creation\n")
8✔
569
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
8✔
570

8✔
571
        // plan iterations use 20% of max_iterations (min 5)
8✔
572
        maxPlanIterations := max(5, r.cfg.MaxIterations/5)
8✔
573

8✔
574
        for i := 1; i <= maxPlanIterations; i++ {
21✔
575
                select {
13✔
576
                case <-ctx.Done():
1✔
577
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
578
                default:
12✔
579
                }
580

581
                r.log.PrintSection(NewPlanIterationSection(i))
12✔
582

12✔
583
                prompt := r.buildPlanPrompt()
12✔
584
                result := r.claude.Run(ctx, prompt)
12✔
585
                if result.Error != nil {
14✔
586
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
587
                                return err
1✔
588
                        }
1✔
589
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
590
                }
591

592
                if result.Signal == SignalFailed {
11✔
593
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
594
                }
1✔
595

596
                // check for PLAN_READY signal
597
                if IsPlanReady(result.Signal) {
11✔
598
                        r.log.Print("plan creation completed")
2✔
599
                        return nil
2✔
600
                }
2✔
601

602
                // check for QUESTION signal
603
                question, err := ParseQuestionPayload(result.Output)
7✔
604
                if err == nil {
9✔
605
                        // got a question - ask user and log answer
2✔
606
                        r.log.LogQuestion(question.Question, question.Options)
2✔
607

2✔
608
                        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
2✔
609
                        if askErr != nil {
3✔
610
                                return fmt.Errorf("collect answer: %w", askErr)
1✔
611
                        }
1✔
612

613
                        r.log.LogAnswer(answer)
1✔
614

1✔
615
                        time.Sleep(r.iterationDelay)
1✔
616
                        continue
1✔
617
                }
618

619
                // log malformed question signals (but not "no question signal" which is expected)
620
                if !errors.Is(err, ErrNoQuestionSignal) {
5✔
621
                        r.log.Print("warning: %v", err)
×
622
                }
×
623

624
                // no question and no completion - continue
625
                time.Sleep(r.iterationDelay)
5✔
626
        }
627

628
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
629
}
630

631
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
632
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
633
func (r *Runner) handlePatternMatchError(err error, tool string) error {
7✔
634
        var patternErr *executor.PatternMatchError
7✔
635
        if errors.As(err, &patternErr) {
11✔
636
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
4✔
637
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
4✔
638
                return err
4✔
639
        }
4✔
640
        return nil
3✔
641
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc