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

umputun / ralphex / 21657301218

04 Feb 2026 03:30AM UTC coverage: 80.632% (-0.02%) from 80.647%
21657301218

Pull #63

github

Claude
feat: add finalize step for optional post-completion actions

Add optional finalize phase that runs after successful review phases
(ModeFull, ModeReview, ModeCodexOnly). Disabled by default.

- Add PhaseFinalize constant with task color (green)
- Add finalize_enabled config option (default: false)
- Add finalize.txt prompt with commit rebase workflow
- Implement runFinalize() method (best-effort, runs once)
- Call finalize from review-completing modes
- Add tests for finalize step behavior
- Document in CLAUDE.md and README.md
Pull Request #63: Add finalize step for optional post-completion actions

49 of 59 new or added lines in 6 files covered. (83.05%)

4 existing lines in 1 file now uncovered.

4284 of 5313 relevant lines covered (80.63%)

134.48 hits per line

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

84.58
/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
        ModeTasksOnly Mode = "tasks-only" // run only task phase, skip all reviews
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
        FinalizeEnabled  bool           // whether finalize step is enabled
44
        DefaultBranch    string         // default branch name (detected from repo)
45
        AppConfig        *config.Config // full application config (for executors and prompts)
46
}
47

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

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

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

70
// InputCollector provides interactive input collection for plan creation.
71
type InputCollector interface {
72
        AskQuestion(ctx context.Context, question string, options []string) (string, error)
73
        AskDraftReview(ctx context.Context, question string, planContent string) (action string, feedback string, err error)
74
}
75

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

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

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

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

131
        return NewWithExecutors(cfg, log, claudeExec, codexExec)
1✔
132
}
133

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

142
        // determine task retry count from config
143
        // appConfig.TaskRetryCountSet means user explicitly set it (even to 0 for no retries)
144
        retryCount := 1
55✔
145
        if cfg.AppConfig != nil && cfg.AppConfig.TaskRetryCountSet {
98✔
146
                retryCount = cfg.TaskRetryCount
43✔
147
        } else if cfg.TaskRetryCount > 0 {
56✔
148
                retryCount = cfg.TaskRetryCount
1✔
149
        }
1✔
150

151
        return &Runner{
55✔
152
                cfg:            cfg,
55✔
153
                log:            log,
55✔
154
                claude:         claude,
55✔
155
                codex:          codex,
55✔
156
                iterationDelay: iterDelay,
55✔
157
                taskRetryCount: retryCount,
55✔
158
        }
55✔
159
}
160

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

166
// Run executes the main loop based on configured mode.
167
func (r *Runner) Run(ctx context.Context) error {
45✔
168
        switch r.cfg.Mode {
45✔
169
        case ModeFull:
13✔
170
                return r.runFull(ctx)
13✔
171
        case ModeReview:
7✔
172
                return r.runReviewOnly(ctx)
7✔
173
        case ModeCodexOnly:
4✔
174
                return r.runCodexOnly(ctx)
4✔
175
        case ModeTasksOnly:
4✔
176
                return r.runTasksOnly(ctx)
4✔
177
        case ModePlan:
16✔
178
                return r.runPlanCreation(ctx)
16✔
179
        default:
1✔
180
                return fmt.Errorf("unknown mode: %s", r.cfg.Mode)
1✔
181
        }
182
}
183

184
// runFull executes the complete pipeline: tasks → review → codex → review.
185
func (r *Runner) runFull(ctx context.Context) error {
13✔
186
        if r.cfg.PlanFile == "" {
14✔
187
                return errors.New("plan file required for full mode")
1✔
188
        }
1✔
189

190
        // phase 1: task execution
191
        r.log.SetPhase(PhaseTask)
12✔
192
        r.log.PrintRaw("starting task execution phase\n")
12✔
193

12✔
194
        if err := r.runTaskPhase(ctx); err != nil {
18✔
195
                return fmt.Errorf("task phase: %w", err)
6✔
196
        }
6✔
197

198
        // phase 2: first review pass - address ALL findings
199
        r.log.SetPhase(PhaseReview)
6✔
200
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
6✔
201

6✔
202
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
6✔
203
                return fmt.Errorf("first review: %w", err)
×
204
        }
×
205

206
        // phase 2.1: claude review loop (critical/major) before codex
207
        if err := r.runClaudeReviewLoop(ctx); err != nil {
6✔
208
                return fmt.Errorf("pre-codex review loop: %w", err)
×
209
        }
×
210

211
        // phase 2.5: codex external review loop
212
        r.log.SetPhase(PhaseCodex)
6✔
213
        r.log.PrintSection(NewGenericSection("codex external review"))
6✔
214

6✔
215
        if err := r.runCodexLoop(ctx); err != nil {
6✔
216
                return fmt.Errorf("codex loop: %w", err)
×
217
        }
×
218

219
        // phase 3: claude review loop (critical/major) after codex
220
        r.log.SetPhase(PhaseReview)
6✔
221

6✔
222
        if err := r.runClaudeReviewLoop(ctx); err != nil {
6✔
223
                return fmt.Errorf("post-codex review loop: %w", err)
×
224
        }
×
225

226
        // optional finalize step (best-effort, but propagates context cancellation)
227
        if err := r.runFinalize(ctx); err != nil {
6✔
NEW
228
                return err
×
NEW
229
        }
×
230

231
        r.log.Print("all phases completed successfully")
6✔
232
        return nil
6✔
233
}
234

235
// runReviewOnly executes only the review pipeline: review → codex → review.
236
func (r *Runner) runReviewOnly(ctx context.Context) error {
7✔
237
        // phase 1: first review
7✔
238
        r.log.SetPhase(PhaseReview)
7✔
239
        r.log.PrintSection(NewGenericSection("claude review 0: all findings"))
7✔
240

7✔
241
        if err := r.runClaudeReview(ctx, r.replacePromptVariables(r.cfg.AppConfig.ReviewFirstPrompt)); err != nil {
8✔
242
                return fmt.Errorf("first review: %w", err)
1✔
243
        }
1✔
244

245
        // phase 1.1: claude review loop (critical/major) before codex
246
        if err := r.runClaudeReviewLoop(ctx); err != nil {
7✔
247
                return fmt.Errorf("pre-codex review loop: %w", err)
1✔
248
        }
1✔
249

250
        // phase 2: codex external review loop
251
        r.log.SetPhase(PhaseCodex)
5✔
252
        r.log.PrintSection(NewGenericSection("codex external review"))
5✔
253

5✔
254
        if err := r.runCodexLoop(ctx); err != nil {
7✔
255
                return fmt.Errorf("codex loop: %w", err)
2✔
256
        }
2✔
257

258
        // phase 3: claude review loop (critical/major) after codex
259
        r.log.SetPhase(PhaseReview)
3✔
260

3✔
261
        if err := r.runClaudeReviewLoop(ctx); err != nil {
3✔
262
                return fmt.Errorf("post-codex review loop: %w", err)
×
263
        }
×
264

265
        // optional finalize step (best-effort, but propagates context cancellation)
266
        if err := r.runFinalize(ctx); err != nil {
4✔
267
                return err
1✔
268
        }
1✔
269

270
        r.log.Print("review phases completed successfully")
2✔
271
        return nil
2✔
272
}
273

274
// runCodexOnly executes only the codex pipeline: codex → review.
275
func (r *Runner) runCodexOnly(ctx context.Context) error {
4✔
276
        // phase 1: codex external review loop
4✔
277
        r.log.SetPhase(PhaseCodex)
4✔
278
        r.log.PrintSection(NewGenericSection("codex external review"))
4✔
279

4✔
280
        if err := r.runCodexLoop(ctx); err != nil {
4✔
281
                return fmt.Errorf("codex loop: %w", err)
×
282
        }
×
283

284
        // phase 2: claude review loop (critical/major) after codex
285
        r.log.SetPhase(PhaseReview)
4✔
286

4✔
287
        if err := r.runClaudeReviewLoop(ctx); err != nil {
4✔
288
                return fmt.Errorf("post-codex review loop: %w", err)
×
289
        }
×
290

291
        // optional finalize step (best-effort, but propagates context cancellation)
292
        if err := r.runFinalize(ctx); err != nil {
4✔
NEW
293
                return err
×
NEW
294
        }
×
295

296
        r.log.Print("codex phases completed successfully")
4✔
297
        return nil
4✔
298
}
299

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

306
        r.log.SetPhase(PhaseTask)
3✔
307
        r.log.PrintRaw("starting task execution phase\n")
3✔
308

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

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

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

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

330
                r.log.PrintSection(NewTaskIterationSection(i))
18✔
331

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

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

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

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

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

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

378
        if result.Signal == SignalFailed {
14✔
379
                return errors.New("review failed (FAILED signal received)")
1✔
380
        }
1✔
381

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

386
        return nil
12✔
387
}
388

389
// runClaudeReviewLoop runs claude review iterations using second review prompt.
390
func (r *Runner) runClaudeReviewLoop(ctx context.Context) error {
25✔
391
        // review iterations = 10% of max_iterations (min 3)
25✔
392
        maxReviewIterations := max(3, r.cfg.MaxIterations/10)
25✔
393

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

401
                r.log.PrintSection(NewClaudeReviewSection(i, ": critical/major"))
25✔
402

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

411
                if result.Signal == SignalFailed {
24✔
412
                        return errors.New("review failed (FAILED signal received)")
×
413
                }
×
414

415
                if IsReviewDone(result.Signal) {
48✔
416
                        r.log.Print("claude review complete - no more findings")
24✔
417
                        return nil
24✔
418
                }
24✔
419

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

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

428
// runCodexLoop runs the codex-claude review loop until no findings.
429
func (r *Runner) runCodexLoop(ctx context.Context) error {
15✔
430
        // skip codex phase if disabled
15✔
431
        if !r.cfg.CodexEnabled {
23✔
432
                r.log.Print("codex review disabled, skipping...")
8✔
433
                return nil
8✔
434
        }
8✔
435

436
        // codex iterations = 20% of max_iterations (min 3)
437
        maxCodexIterations := max(3, r.cfg.MaxIterations/5)
7✔
438

7✔
439
        var claudeResponse string // first iteration has no prior response
7✔
440

7✔
441
        for i := 1; i <= maxCodexIterations; i++ {
14✔
442
                select {
7✔
443
                case <-ctx.Done():
×
444
                        return fmt.Errorf("codex loop: %w", ctx.Err())
×
445
                default:
7✔
446
                }
447

448
                r.log.PrintSection(NewCodexIterationSection(i))
7✔
449

7✔
450
                // run codex analysis
7✔
451
                codexResult := r.codex.Run(ctx, r.buildCodexPrompt(i == 1, claudeResponse))
7✔
452
                if codexResult.Error != nil {
9✔
453
                        if err := r.handlePatternMatchError(codexResult.Error, "codex"); err != nil {
3✔
454
                                return err
1✔
455
                        }
1✔
456
                        return fmt.Errorf("codex execution: %w", codexResult.Error)
1✔
457
                }
458

459
                if codexResult.Output == "" {
7✔
460
                        r.log.Print("codex review returned no output, skipping...")
2✔
461
                        break
2✔
462
                }
463

464
                // show codex findings summary before Claude evaluation
465
                r.showCodexSummary(codexResult.Output)
3✔
466

3✔
467
                // pass codex output to claude for evaluation and fixing
3✔
468
                r.log.SetPhase(PhaseClaudeEval)
3✔
469
                r.log.PrintSection(NewClaudeEvalSection())
3✔
470
                claudeResult := r.claude.Run(ctx, r.buildCodexEvaluationPrompt(codexResult.Output))
3✔
471

3✔
472
                // restore codex phase for next iteration
3✔
473
                r.log.SetPhase(PhaseCodex)
3✔
474
                if claudeResult.Error != nil {
3✔
475
                        if err := r.handlePatternMatchError(claudeResult.Error, "claude"); err != nil {
×
476
                                return err
×
477
                        }
×
478
                        return fmt.Errorf("claude execution: %w", claudeResult.Error)
×
479
                }
480

481
                claudeResponse = claudeResult.Output
3✔
482

3✔
483
                // exit only when claude sees "no findings" from codex
3✔
484
                if IsCodexDone(claudeResult.Signal) {
6✔
485
                        r.log.Print("codex review complete - no more findings")
3✔
486
                        return nil
3✔
487
                }
3✔
488

489
                time.Sleep(r.iterationDelay)
×
490
        }
491

492
        r.log.Print("max codex iterations reached, continuing to next phase...")
2✔
493
        return nil
2✔
494
}
495

496
// buildCodexPrompt creates the prompt for codex review.
497
func (r *Runner) buildCodexPrompt(isFirst bool, claudeResponse string) string {
8✔
498
        // build plan context if available
8✔
499
        planContext := ""
8✔
500
        if r.cfg.PlanFile != "" {
11✔
501
                planContext = fmt.Sprintf(`
3✔
502
## Plan Context
3✔
503
The code implements the plan at: %s
3✔
504

3✔
505
---
3✔
506
`, r.resolvePlanFilePath())
3✔
507
        }
3✔
508

509
        // different diff command based on iteration
510
        var diffInstruction, diffDescription string
8✔
511
        if isFirst {
16✔
512
                defaultBranch := r.getDefaultBranch()
8✔
513
                diffInstruction = fmt.Sprintf("Run: git diff %s...HEAD", defaultBranch)
8✔
514
                diffDescription = fmt.Sprintf("code changes between %s and HEAD branch", defaultBranch)
8✔
515
        } else {
8✔
516
                diffInstruction = "Run: git diff"
×
517
                diffDescription = "uncommitted changes (Claude's fixes from previous iteration)"
×
518
        }
×
519

520
        basePrompt := fmt.Sprintf(`%sReview the %s.
8✔
521

8✔
522
%s
8✔
523

8✔
524
Analyze for:
8✔
525
- Bugs and logic errors
8✔
526
- Security vulnerabilities
8✔
527
- Race conditions
8✔
528
- Error handling gaps
8✔
529
- Code quality issues
8✔
530

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

8✔
533
        if claudeResponse != "" {
8✔
534
                return fmt.Sprintf(`%s
×
535

×
536
---
×
537
PREVIOUS REVIEW CONTEXT:
×
538
Claude (previous reviewer) responded to your findings:
×
539

×
540
%s
×
541

×
542
Re-evaluate considering Claude's arguments. If Claude's fixes are correct, acknowledge them.
×
543
If Claude's arguments are invalid, explain why the issues still exist.`, basePrompt, claudeResponse)
×
544
        }
×
545

546
        return basePrompt
8✔
547
}
548

549
// hasUncompletedTasks checks if plan file has any uncompleted checkboxes.
550
func (r *Runner) hasUncompletedTasks() bool {
13✔
551
        content, err := os.ReadFile(r.resolvePlanFilePath())
13✔
552
        if err != nil {
13✔
553
                return true // assume incomplete if can't read
×
554
        }
×
555

556
        // look for uncompleted checkbox pattern: [ ] (not [x])
557
        for line := range strings.SplitSeq(string(content), "\n") {
43✔
558
                trimmed := strings.TrimSpace(line)
30✔
559
                if strings.HasPrefix(trimmed, "- [ ]") {
33✔
560
                        return true
3✔
561
                }
3✔
562
        }
563
        return false
10✔
564
}
565

566
// showCodexSummary displays a condensed summary of codex output before Claude evaluation.
567
// extracts text until first code block or 500 chars, whichever is shorter.
568
func (r *Runner) showCodexSummary(output string) {
3✔
569
        summary := output
3✔
570

3✔
571
        // trim to first code block if present
3✔
572
        if idx := strings.Index(summary, "```"); idx > 0 {
3✔
573
                summary = summary[:idx]
×
574
        }
×
575

576
        // limit to 5000 chars
577
        if len(summary) > 5000 {
3✔
578
                summary = summary[:5000] + "..."
×
579
        }
×
580

581
        summary = strings.TrimSpace(summary)
3✔
582
        if summary == "" {
3✔
583
                return
×
584
        }
×
585

586
        r.log.Print("codex findings:")
3✔
587
        for line := range strings.SplitSeq(summary, "\n") {
6✔
588
                if strings.TrimSpace(line) == "" {
3✔
589
                        continue
×
590
                }
591
                r.log.PrintAligned("  " + line)
3✔
592
        }
593
}
594

595
// ErrUserRejectedPlan is returned when user rejects the plan draft.
596
var ErrUserRejectedPlan = errors.New("user rejected plan")
597

598
// draftReviewResult holds the result of draft review handling.
599
type draftReviewResult struct {
600
        handled  bool   // true if draft was found and handled
601
        feedback string // revision feedback (non-empty only for "revise" action)
602
        err      error  // error if review failed or user rejected
603
}
604

605
// handlePlanDraft processes PLAN_DRAFT signal if present in output.
606
// returns result indicating whether draft was handled and any feedback/errors.
607
func (r *Runner) handlePlanDraft(ctx context.Context, output string) draftReviewResult {
15✔
608
        planContent, draftErr := ParsePlanDraftPayload(output)
15✔
609
        if draftErr != nil {
24✔
610
                // log malformed signals (but not "no signal" which is expected)
9✔
611
                if !errors.Is(draftErr, ErrNoPlanDraftSignal) {
10✔
612
                        r.log.Print("warning: %v", draftErr)
1✔
613
                }
1✔
614
                return draftReviewResult{handled: false}
9✔
615
        }
616

617
        r.log.Print("plan draft ready for review")
6✔
618

6✔
619
        action, feedback, askErr := r.inputCollector.AskDraftReview(ctx, "Review the plan draft", planContent)
6✔
620
        if askErr != nil {
7✔
621
                return draftReviewResult{handled: true, err: fmt.Errorf("collect draft review: %w", askErr)}
1✔
622
        }
1✔
623

624
        // log the draft review action and feedback to progress file
625
        r.log.LogDraftReview(action, feedback)
5✔
626

5✔
627
        switch action {
5✔
628
        case "accept":
3✔
629
                r.log.Print("draft accepted, continuing to write plan file...")
3✔
630
                return draftReviewResult{handled: true}
3✔
631
        case "revise":
1✔
632
                r.log.Print("revision requested, re-running with feedback...")
1✔
633
                return draftReviewResult{handled: true, feedback: feedback}
1✔
634
        case "reject":
1✔
635
                r.log.Print("plan rejected by user")
1✔
636
                return draftReviewResult{handled: true, err: ErrUserRejectedPlan}
1✔
637
        }
638

639
        return draftReviewResult{handled: true}
×
640
}
641

642
// handlePlanQuestion processes QUESTION signal if present in output.
643
// returns true if question was found and handled, false otherwise.
644
// returns error if question handling failed.
645
func (r *Runner) handlePlanQuestion(ctx context.Context, output string) (bool, error) {
9✔
646
        question, err := ParseQuestionPayload(output)
9✔
647
        if err != nil {
15✔
648
                // log malformed signals (but not "no signal" which is expected)
6✔
649
                if !errors.Is(err, ErrNoQuestionSignal) {
6✔
650
                        r.log.Print("warning: %v", err)
×
651
                }
×
652
                return false, nil
6✔
653
        }
654

655
        r.log.LogQuestion(question.Question, question.Options)
3✔
656

3✔
657
        answer, askErr := r.inputCollector.AskQuestion(ctx, question.Question, question.Options)
3✔
658
        if askErr != nil {
4✔
659
                return true, fmt.Errorf("collect answer: %w", askErr)
1✔
660
        }
1✔
661

662
        r.log.LogAnswer(answer)
2✔
663
        return true, nil
2✔
664
}
665

666
// runPlanCreation executes the interactive plan creation loop.
667
// the loop continues until PLAN_READY signal or max iterations reached.
668
// handles QUESTION signals for Q&A and PLAN_DRAFT signals for draft review.
669
func (r *Runner) runPlanCreation(ctx context.Context) error {
16✔
670
        if r.cfg.PlanDescription == "" {
17✔
671
                return errors.New("plan description required for plan mode")
1✔
672
        }
1✔
673
        if r.inputCollector == nil {
16✔
674
                return errors.New("input collector required for plan mode")
1✔
675
        }
1✔
676

677
        r.log.SetPhase(PhasePlan)
14✔
678
        r.log.PrintRaw("starting interactive plan creation\n")
14✔
679
        r.log.Print("plan request: %s", r.cfg.PlanDescription)
14✔
680

14✔
681
        // plan iterations use 20% of max_iterations (min 5)
14✔
682
        maxPlanIterations := max(5, r.cfg.MaxIterations/5)
14✔
683

14✔
684
        // track revision feedback for context in next iteration
14✔
685
        var lastRevisionFeedback string
14✔
686

14✔
687
        for i := 1; i <= maxPlanIterations; i++ {
39✔
688
                select {
25✔
689
                case <-ctx.Done():
1✔
690
                        return fmt.Errorf("plan creation: %w", ctx.Err())
1✔
691
                default:
24✔
692
                }
693

694
                r.log.PrintSection(NewPlanIterationSection(i))
24✔
695

24✔
696
                prompt := r.buildPlanPrompt()
24✔
697
                // append revision feedback context if present
24✔
698
                if lastRevisionFeedback != "" {
25✔
699
                        prompt = fmt.Sprintf("%s\n\n---\nPREVIOUS DRAFT FEEDBACK:\nUser requested revisions with this feedback:\n%s\n\nPlease revise the plan accordingly and present a new PLAN_DRAFT.", prompt, lastRevisionFeedback)
1✔
700
                        lastRevisionFeedback = "" // clear after use
1✔
701
                }
1✔
702

703
                result := r.claude.Run(ctx, prompt)
24✔
704
                if result.Error != nil {
26✔
705
                        if err := r.handlePatternMatchError(result.Error, "claude"); err != nil {
3✔
706
                                return err
1✔
707
                        }
1✔
708
                        return fmt.Errorf("claude execution: %w", result.Error)
1✔
709
                }
710

711
                if result.Signal == SignalFailed {
23✔
712
                        return errors.New("plan creation failed (FAILED signal received)")
1✔
713
                }
1✔
714

715
                // check for PLAN_READY signal
716
                if IsPlanReady(result.Signal) {
27✔
717
                        r.log.Print("plan creation completed")
6✔
718
                        return nil
6✔
719
                }
6✔
720

721
                // check for PLAN_DRAFT signal - present draft for user review
722
                draftResult := r.handlePlanDraft(ctx, result.Output)
15✔
723
                if draftResult.err != nil {
17✔
724
                        return draftResult.err
2✔
725
                }
2✔
726
                if draftResult.handled {
17✔
727
                        lastRevisionFeedback = draftResult.feedback
4✔
728
                        time.Sleep(r.iterationDelay)
4✔
729
                        continue
4✔
730
                }
731

732
                // check for QUESTION signal
733
                handled, err := r.handlePlanQuestion(ctx, result.Output)
9✔
734
                if err != nil {
10✔
735
                        return err
1✔
736
                }
1✔
737
                if handled {
10✔
738
                        time.Sleep(r.iterationDelay)
2✔
739
                        continue
2✔
740
                }
741

742
                // no question, no draft, and no completion - continue
743
                time.Sleep(r.iterationDelay)
6✔
744
        }
745

746
        return fmt.Errorf("max plan iterations (%d) reached without completion", maxPlanIterations)
1✔
747
}
748

749
// handlePatternMatchError checks if err is a PatternMatchError and logs appropriate messages.
750
// Returns the error if it's a pattern match (to trigger graceful exit), nil otherwise.
751
func (r *Runner) handlePatternMatchError(err error, tool string) error {
7✔
752
        var patternErr *executor.PatternMatchError
7✔
753
        if errors.As(err, &patternErr) {
11✔
754
                r.log.Print("error: detected %q in %s output", patternErr.Pattern, tool)
4✔
755
                r.log.Print("run '%s' for more information", patternErr.HelpCmd)
4✔
756
                return err
4✔
757
        }
4✔
758
        return nil
3✔
759
}
760

761
// runFinalize executes the optional finalize step after successful reviews.
762
// runs once, best-effort: failures are logged but don't block success.
763
// exception: context cancellation is propagated (user wants to abort).
764
func (r *Runner) runFinalize(ctx context.Context) error {
13✔
765
        if !r.cfg.FinalizeEnabled {
20✔
766
                return nil
7✔
767
        }
7✔
768

769
        r.log.SetPhase(PhaseFinalize)
6✔
770
        r.log.PrintSection(NewGenericSection("finalize step"))
6✔
771

6✔
772
        prompt := r.replacePromptVariables(r.cfg.AppConfig.FinalizePrompt)
6✔
773
        result := r.claude.Run(ctx, prompt)
6✔
774

6✔
775
        if result.Error != nil {
8✔
776
                // propagate context cancellation - user wants to abort
2✔
777
                if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, context.DeadlineExceeded) {
3✔
778
                        return fmt.Errorf("finalize step: %w", result.Error)
1✔
779
                }
1✔
780
                // check for pattern match (rate limit) - log but don't fail (best-effort)
781
                var patternErr *executor.PatternMatchError
1✔
782
                if errors.As(result.Error, &patternErr) {
1✔
NEW
783
                        r.log.Print("finalize step: detected %q in claude output", patternErr.Pattern)
×
NEW
784
                        r.log.Print("run '%s' for more information", patternErr.HelpCmd)
×
NEW
785
                        return nil
×
NEW
786
                }
×
787
                // best-effort: log error but don't fail
788
                r.log.Print("finalize step failed: %v", result.Error)
1✔
789
                return nil
1✔
790
        }
791

792
        if result.Signal == SignalFailed {
5✔
793
                r.log.Print("finalize step reported failure (non-blocking)")
1✔
794
                return nil
1✔
795
        }
1✔
796

797
        r.log.Print("finalize step completed")
3✔
798
        return nil
3✔
799
}
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