diff --git a/cmd/ralph/main.go b/cmd/ralph/main.go index 7ac2508..3cfc697 100644 --- a/cmd/ralph/main.go +++ b/cmd/ralph/main.go @@ -51,19 +51,6 @@ func init() { rootCmd.Flags().BoolVar(&cfg.DryRun, "dry-run", cfg.DryRun, "Show what would run without executing") rootCmd.Flags().StringVar(&cfg.Model, "model", cfg.Model, "Model to use (e.g., sonnet, opus, haiku)") - // OTEL options - rootCmd.Flags().BoolVar(&cfg.OTELEnabled, "otel-enabled", cfg.OTELEnabled, "Enable metrics export") - rootCmd.Flags().StringVar(&cfg.OTELEndpoint, "otel-endpoint", cfg.OTELEndpoint, "OTLP endpoint") - rootCmd.Flags().StringVar(&cfg.MetricsPrefix, "metrics-prefix", cfg.MetricsPrefix, "Metric name prefix") - rootCmd.Flags().StringVar(&cfg.ProjectName, "project-name", cfg.ProjectName, "Override project label") - - // Slack options - rootCmd.Flags().BoolVar(&cfg.SlackEnabled, "slack-enabled", cfg.SlackEnabled, "Enable Slack notifications") - rootCmd.Flags().StringVar(&cfg.SlackWebhookURL, "slack-webhook-url", cfg.SlackWebhookURL, "Slack webhook URL") - rootCmd.Flags().StringVar(&cfg.SlackChannel, "slack-channel", cfg.SlackChannel, "Slack channel ID") - rootCmd.Flags().StringVar(&cfg.SlackNotifyUsers, "slack-notify-users", cfg.SlackNotifyUsers, "Comma-separated Slack user IDs to @mention on completion") - rootCmd.Flags().StringVar(&cfg.SlackBotToken, "slack-bot-token", cfg.SlackBotToken, "Slack bot token for thread replies") - // Behavior options rootCmd.Flags().BoolVarP(&cfg.StopOnCompletion, "stop-on-completion", "s", cfg.StopOnCompletion, "Exit when all todos are complete") @@ -120,24 +107,6 @@ func init() { savedValues["dry-run"] = cfg.DryRun case "model": savedValues["model"] = cfg.Model - case "otel-enabled": - savedValues["otel-enabled"] = cfg.OTELEnabled - case "otel-endpoint": - savedValues["otel-endpoint"] = cfg.OTELEndpoint - case "metrics-prefix": - savedValues["metrics-prefix"] = cfg.MetricsPrefix - case "project-name": - savedValues["project-name"] = cfg.ProjectName - case "slack-enabled": - savedValues["slack-enabled"] = cfg.SlackEnabled - case "slack-webhook-url": - savedValues["slack-webhook-url"] = cfg.SlackWebhookURL - case "slack-channel": - savedValues["slack-channel"] = cfg.SlackChannel - case "slack-notify-users": - savedValues["slack-notify-users"] = cfg.SlackNotifyUsers - case "slack-bot-token": - savedValues["slack-bot-token"] = cfg.SlackBotToken case "stop-on-completion": savedValues["stop-on-completion"] = cfg.StopOnCompletion case "code-review": @@ -211,24 +180,6 @@ func init() { cfg.DryRun = val.(bool) case "model": cfg.Model = val.(string) - case "otel-enabled": - cfg.OTELEnabled = val.(bool) - case "otel-endpoint": - cfg.OTELEndpoint = val.(string) - case "metrics-prefix": - cfg.MetricsPrefix = val.(string) - case "project-name": - cfg.ProjectName = val.(string) - case "slack-enabled": - cfg.SlackEnabled = val.(bool) - case "slack-webhook-url": - cfg.SlackWebhookURL = val.(string) - case "slack-channel": - cfg.SlackChannel = val.(string) - case "slack-notify-users": - cfg.SlackNotifyUsers = val.(string) - case "slack-bot-token": - cfg.SlackBotToken = val.(string) case "stop-on-completion": cfg.StopOnCompletion = val.(bool) case "code-review": @@ -266,6 +217,14 @@ func init() { } } + // Apply stop-on-completion default for code review max iterations + // Priority: explicit flag > stop-on-completion default > config default (0/unlimited) + if _, explicitlySet := savedValues["code-review-max-iterations"]; !explicitlySet { + if cfg.StopOnCompletion && cfg.CodeReviewMaxIterations == 0 { + cfg.CodeReviewMaxIterations = 1 + } + } + // Handle -q flag (inverts verbose) if cmd.Flags().Changed("quiet") { cfg.Verbose = false @@ -296,25 +255,12 @@ Options: --config FILE Path to config file (default: ./ralph.yaml) --model MODEL Model to use (e.g., sonnet, opus, haiku) -OTEL Options: - --otel-enabled Enable metrics export (default: false) - --otel-endpoint URL OTLP endpoint (default: localhost:4317) - --metrics-prefix PREFIX Metric name prefix (default: ralph) - --project-name NAME Override project label (default: cwd basename) - -Slack Options: - --slack-enabled Enable Slack notifications (default: false) - --slack-webhook-url URL Slack webhook URL (or RALPH_SLACK_WEBHOOK_URL env) - --slack-channel ID Slack channel ID (or RALPH_SLACK_CHANNEL env) - --slack-notify-users IDS Comma-separated user IDs to @mention (or RALPH_SLACK_NOTIFY_USERS env) - --slack-bot-token TOKEN Bot token for thread replies (or RALPH_SLACK_BOT_TOKEN env) - Behavior Options: -s, --stop-on-completion Exit when all todos are complete (default: false) Code Review Options: --code-review Run code review phase after todos complete (default: false) - --code-review-max-iterations N Max iterations for code review phase (default: 3) + --code-review-max-iterations N Max iterations for code review phase (default: 0=unlimited, 1 with -s) --code-review-prompt TEXT Custom prompt for code review phase --code-review-model MODEL Model for code review phase (defaults to --model) @@ -358,7 +304,9 @@ Examples: ralph -w -s --pr # Worktree + stop + PR (common pattern) ralph --sound # Play Ralph Wiggum quotes after each iteration ralph --test-mode # Run in test mode (mock Claude) - ralph --test-mode --slack-enabled # Test mode with Slack notifications + +OTEL and Slack notifications are configured via config file or environment variables. +See: https://github.com/hev/ralph#configuration "I'm in danger!" - Ralph Wiggum `, config.Version)) diff --git a/internal/config/config.go b/internal/config/config.go index 83ffab8..2f23eb3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,7 +119,7 @@ type yamlConfig struct { CodeReview struct { Enabled *bool `yaml:"enabled"` - MaxIterations int `yaml:"max_iterations"` + MaxIterations *int `yaml:"max_iterations"` Prompt string `yaml:"prompt"` Model string `yaml:"model"` } `yaml:"code_review"` @@ -185,7 +185,7 @@ func DefaultConfig() *Config { SlackBotToken: getEnvOrDefault("RALPH_SLACK_BOT_TOKEN", ""), CodeReviewEnabled: false, - CodeReviewMaxIterations: 3, + CodeReviewMaxIterations: 0, CodeReviewPrompt: "", CleanupEnabled: false, @@ -441,8 +441,8 @@ func (c *Config) LoadFromFile(path string) error { if yc.CodeReview.Enabled != nil { c.CodeReviewEnabled = *yc.CodeReview.Enabled } - if yc.CodeReview.MaxIterations != 0 { - c.CodeReviewMaxIterations = yc.CodeReview.MaxIterations + if yc.CodeReview.MaxIterations != nil { + c.CodeReviewMaxIterations = *yc.CodeReview.MaxIterations } if yc.CodeReview.Prompt != "" { c.CodeReviewPrompt = yc.CodeReview.Prompt diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ad7464a..de6618f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,8 +54,8 @@ func TestDefaultConfig(t *testing.T) { if cfg.CodeReviewEnabled { t.Error("CodeReviewEnabled = true, want false") } - if cfg.CodeReviewMaxIterations != 3 { - t.Errorf("CodeReviewMaxIterations = %d, want 3", cfg.CodeReviewMaxIterations) + if cfg.CodeReviewMaxIterations != 0 { + t.Errorf("CodeReviewMaxIterations = %d, want 0 (unlimited)", cfg.CodeReviewMaxIterations) } // Cleanup options diff --git a/internal/ralph/sound.go b/internal/ralph/sound.go index 24b7fcd..15a113c 100644 --- a/internal/ralph/sound.go +++ b/internal/ralph/sound.go @@ -1,183 +1,115 @@ package ralph import ( + "embed" "fmt" - "io" - "math/rand" - "net/http" - "net/url" "os" "os/exec" "path/filepath" - "regexp" - "strings" - "time" "github.com/hev/ralph/internal/config" ) -// HTTP client with timeout -var httpClient = &http.Client{ - Timeout: 10 * time.Second, -} +//go:embed sounds/* +var soundFiles embed.FS + +// SoundType represents the type of sound event +type SoundType string const ( - soundsCacheFile = "ralph_sounds.txt" + SoundSessionStart SoundType = "session-start.mp3" + SoundIterationFinish SoundType = "iteration-finish.mp3" + SoundTodoComplete SoundType = "todo-complete.mp3" ) -// SoundPlayer handles fetching and playing Ralph Wiggum audio clips +// SoundPlayer handles playing Ralph Wiggum audio clips type SoundPlayer struct { - config config.SoundConfig - sounds []string + config config.SoundConfig + cacheDir string } // NewSoundPlayer creates a new SoundPlayer with the given config func NewSoundPlayer(cfg config.SoundConfig) *SoundPlayer { return &SoundPlayer{ - config: cfg, + config: cfg, + cacheDir: cfg.CacheDir, } } -// Play fetches a random Ralph quote and plays it asynchronously -func (p *SoundPlayer) Play() error { - if !p.config.Enabled || p.config.Mute { - return nil - } - - // Load sounds if not already loaded (do this synchronously on first call) - if len(p.sounds) == 0 { - if err := p.loadSounds(); err != nil { - return fmt.Errorf("failed to load sounds: %w", err) - } - } - - if len(p.sounds) == 0 { - return fmt.Errorf("no sounds available") - } - - // Pick a random sound - rand.Seed(time.Now().UnixNano()) - soundURL := p.sounds[rand.Intn(len(p.sounds))] - - // Play asynchronously so we don't block the loop - go func() { - tmpFile, err := p.downloadSound(soundURL) - if err != nil { - return - } - defer os.Remove(tmpFile) - p.playSound(tmpFile) - }() - - return nil +// PlaySessionStart plays the session start sound (synchronous - blocks until done) +func (p *SoundPlayer) PlaySessionStart() error { + return p.playHookSoundSync(SoundSessionStart) } -// loadSounds loads the sound URLs from cache or fetches them -func (p *SoundPlayer) loadSounds() error { - // Ensure cache directory exists - if err := os.MkdirAll(p.config.CacheDir, 0755); err != nil { - return err - } - - cacheFile := filepath.Join(p.config.CacheDir, soundsCacheFile) +// PlayIterationFinish plays the iteration finish sound (asynchronous) +func (p *SoundPlayer) PlayIterationFinish() error { + return p.playHookSoundAsync(SoundIterationFinish) +} - // Try to load from cache first - if data, err := os.ReadFile(cacheFile); err == nil { - lines := strings.Split(string(data), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" { - p.sounds = append(p.sounds, line) - } - } - if len(p.sounds) > 0 { - return nil - } - } +// PlayTodoComplete plays the todo list complete sound (synchronous - blocks until done) +func (p *SoundPlayer) PlayTodoComplete() error { + return p.playHookSoundSync(SoundTodoComplete) +} - // Fetch from the sound page - if err := p.fetchSounds(); err != nil { - return err - } +// Play plays the iteration finish sound (for backwards compatibility) +func (p *SoundPlayer) Play() error { + return p.PlayIterationFinish() +} - // Cache the sounds - if len(p.sounds) > 0 { - data := strings.Join(p.sounds, "\n") - os.WriteFile(cacheFile, []byte(data), 0644) +// playHookSoundSync plays a specific embedded sound file synchronously (blocks until done) +func (p *SoundPlayer) playHookSoundSync(soundType SoundType) error { + if !p.config.Enabled || p.config.Mute { + return nil } - return nil -} - -// fetchSounds fetches the sound page and extracts MP3 URLs -func (p *SoundPlayer) fetchSounds() error { - resp, err := httpClient.Get(p.config.PageURL) + tmpFile, err := p.extractSound(soundType) if err != nil { return err } - defer resp.Body.Close() + defer os.Remove(tmpFile) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to fetch sound page: %s", resp.Status) - } + return p.playSound(tmpFile) +} - body, err := io.ReadAll(resp.Body) - if err != nil { - return err +// playHookSoundAsync plays a specific embedded sound file asynchronously +func (p *SoundPlayer) playHookSoundAsync(soundType SoundType) error { + if !p.config.Enabled || p.config.Mute { + return nil } - // Parse base URL for resolving relative paths - baseURL, err := url.Parse(p.config.PageURL) + tmpFile, err := p.extractSound(soundType) if err != nil { return err } - // Extract MP3 links using regex - // Look for href="...mp3" or src="...mp3" patterns - mp3Pattern := regexp.MustCompile(`(?:href|src)=["']([^"']*\.mp3)["']`) - matches := mp3Pattern.FindAllStringSubmatch(string(body), -1) - - for _, match := range matches { - if len(match) > 1 { - mp3URL := match[1] - // Resolve relative URLs - if !strings.HasPrefix(mp3URL, "http://") && !strings.HasPrefix(mp3URL, "https://") { - ref, err := url.Parse(mp3URL) - if err != nil { - continue - } - mp3URL = baseURL.ResolveReference(ref).String() - } - p.sounds = append(p.sounds, mp3URL) - } - } + go func() { + defer os.Remove(tmpFile) + p.playSound(tmpFile) + }() return nil } -// downloadSound downloads a sound file to a temporary location -func (p *SoundPlayer) downloadSound(soundURL string) (string, error) { - resp, err := httpClient.Get(soundURL) +// extractSound extracts an embedded sound to a temp file and returns the path +func (p *SoundPlayer) extractSound(soundType SoundType) (string, error) { + soundPath := "sounds/" + string(soundType) + data, err := soundFiles.ReadFile(soundPath) if err != nil { - return "", err + return "", fmt.Errorf("failed to read embedded sound %s: %w", soundType, err) } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to download sound: %s", resp.Status) - } - - // Create temp file - tmpFile, err := os.CreateTemp("", "ralph_*.mp3") + ext := filepath.Ext(string(soundType)) + tmpFile, err := os.CreateTemp("", "ralph_*"+ext) if err != nil { - return "", err + return "", fmt.Errorf("failed to create temp file: %w", err) } - defer tmpFile.Close() - if _, err := io.Copy(tmpFile, resp.Body); err != nil { + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() os.Remove(tmpFile.Name()) - return "", err + return "", fmt.Errorf("failed to write sound data: %w", err) } + tmpFile.Close() return tmpFile.Name(), nil } @@ -231,12 +163,7 @@ func (p *SoundPlayer) playSound(filePath string) error { return fmt.Errorf("no audio player found (tried: afplay, ffplay, mpg123, mpg321)") } -// ClearCache removes the cached sound URLs +// ClearCache is kept for backwards compatibility but is now a no-op func (p *SoundPlayer) ClearCache() error { - cacheFile := filepath.Join(p.config.CacheDir, soundsCacheFile) - if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) { - return err - } - p.sounds = nil return nil } diff --git a/internal/ralph/sounds/iteration-finish.mp3 b/internal/ralph/sounds/iteration-finish.mp3 new file mode 100644 index 0000000..aa05d57 Binary files /dev/null and b/internal/ralph/sounds/iteration-finish.mp3 differ diff --git a/internal/ralph/sounds/session-start.mp3 b/internal/ralph/sounds/session-start.mp3 new file mode 100644 index 0000000..774291c Binary files /dev/null and b/internal/ralph/sounds/session-start.mp3 differ diff --git a/internal/ralph/sounds/todo-complete.mp3 b/internal/ralph/sounds/todo-complete.mp3 new file mode 100644 index 0000000..cf7c5d8 Binary files /dev/null and b/internal/ralph/sounds/todo-complete.mp3 differ diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8a6ee09..48086c5 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -20,6 +20,7 @@ import ( "github.com/hev/ralph/internal/ralph" "github.com/hev/ralph/internal/slack" "github.com/hev/ralph/internal/testmode" + "github.com/hev/ralph/internal/todo" "github.com/hev/ralph/internal/worktree" ) @@ -176,7 +177,10 @@ func Run(cfg *config.Config) error { // Set reasonable defaults for test mode if cfg.MaxIterations == 0 { - cfg.MaxIterations = 5 // Default test iterations + cfg.MaxIterations = 3 // Default test iterations + } + if cfg.Cooldown == 0 { + cfg.Cooldown = 2 // Default cooldown to prevent sound overlap } // Enable all phases for success scenario to exercise full flow @@ -246,6 +250,11 @@ func Run(cfg *config.Config) error { } } + // Play session start sound + if err := soundPlayer.PlaySessionStart(); err != nil { + logVerbose(cfg, "Failed to play session start sound: %v", err) + } + // Track state startTime := time.Now() iteration := 0 @@ -358,15 +367,20 @@ func Run(cfg *config.Config) error { // Update previous todos for next iteration comparison tracker.UpdatePreviousTodos() + } - // Check for stop-on-completion - if cfg.StopOnCompletion { - if counts, err := tracker.GetTodoCounts(); err == nil { - if counts.Pending == 0 && counts.Completed > 0 { - exitReason = "all todos complete" - log("All todos complete, stopping...") - break + // Check for stop-on-completion (works even without tracker) + if cfg.StopOnCompletion { + todoPath := filepath.Join(cfg.AgentDir, "TODO.md") + if counts, err := todo.ParseFile(todoPath); err == nil { + if counts.Pending == 0 && counts.Completed > 0 { + exitReason = "all todos complete" + log("All todos complete, stopping...") + // Play todo list complete sound + if err := soundPlayer.PlayTodoComplete(); err != nil { + logVerbose(cfg, "Failed to play todo complete sound: %v", err) } + break } } } @@ -447,7 +461,7 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack issuesFound := 0 issuesFixed := 0 - for reviewIteration < cfg.CodeReviewMaxIterations { + for cfg.CodeReviewMaxIterations == 0 || reviewIteration < cfg.CodeReviewMaxIterations { select { case <-ctx.Done(): return "interrupted during code review", reviewIteration @@ -455,7 +469,11 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack } reviewIteration++ - log("=== Code Review Iteration %d of %d ===", reviewIteration, cfg.CodeReviewMaxIterations) + if cfg.CodeReviewMaxIterations == 0 { + log("=== Code Review Iteration %d (unlimited) ===", reviewIteration) + } else { + log("=== Code Review Iteration %d of %d ===", reviewIteration, cfg.CodeReviewMaxIterations) + } // Send Slack notification for review iteration if notifier.IsEnabled() { @@ -552,7 +570,7 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack } // Sleep between iterations - if reviewIteration < cfg.CodeReviewMaxIterations { + if cfg.CodeReviewMaxIterations == 0 || reviewIteration < cfg.CodeReviewMaxIterations { logVerbose(cfg, "Sleeping for %ds...", cfg.Cooldown) select { case <-ctx.Done(): @@ -562,11 +580,14 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack } } - log("Code review max iterations reached") - if notifier.IsEnabled() { - reviewDuration := time.Since(reviewStartTime) - if err := notifier.CodeReviewComplete(ctx, reviewIteration, issuesFound, issuesFixed, reviewDuration); err != nil { - logError("Failed to send code review complete notification: %v", err) + // Only reached when max iterations is hit (not in unlimited mode) + if cfg.CodeReviewMaxIterations > 0 { + log("Code review max iterations reached") + if notifier.IsEnabled() { + reviewDuration := time.Since(reviewStartTime) + if err := notifier.CodeReviewComplete(ctx, reviewIteration, issuesFound, issuesFixed, reviewDuration); err != nil { + logError("Failed to send code review complete notification: %v", err) + } } } diff --git a/internal/testmode/mock_claude.go b/internal/testmode/mock_claude.go index 5db6d03..1753cd4 100644 --- a/internal/testmode/mock_claude.go +++ b/internal/testmode/mock_claude.go @@ -100,46 +100,28 @@ func (m *MockClaude) getMainPhaseTodos() string { case 1: return `# Tasks -- [ ] Implement feature A -- [ ] Add tests for feature A -- [ ] Update documentation -` - case 2: - return `# Tasks - - [-] Implement feature A - [ ] Add tests for feature A -- [ ] Update documentation -` - case 3: - return `# Tasks - -- [x] Implement feature A -- [-] Add tests for feature A -- [ ] Update documentation ` - case 4: + case 2: if m.scenario == ScenarioPartial { // Partial scenario: max iterations hit before completion return `# Tasks - [x] Implement feature A -- [x] Add tests for feature A -- [ ] Update documentation +- [ ] Add tests for feature A ` } return `# Tasks - [x] Implement feature A -- [x] Add tests for feature A -- [-] Update documentation +- [-] Add tests for feature A ` - default: // 5+ + default: // 3+ return `# Tasks - [x] Implement feature A - [x] Add tests for feature A -- [x] Update documentation ` } } @@ -200,7 +182,7 @@ func (m *MockClaude) AllTodosComplete() bool { if m.scenario == ScenarioPartial { return false // Partial never completes } - return m.iteration >= 5 + return m.iteration >= 3 } if m.phase == "code_review" { return m.iteration >= 3 diff --git a/internal/testmode/mock_claude_test.go b/internal/testmode/mock_claude_test.go index bc1986e..41d64f7 100644 --- a/internal/testmode/mock_claude_test.go +++ b/internal/testmode/mock_claude_test.go @@ -32,7 +32,7 @@ func TestMockClaude_SuccessScenario(t *testing.T) { ctx := context.Background() // Run through all iterations - for i := 1; i <= 5; i++ { + for i := 1; i <= 3; i++ { exitCode, err := mock.RunIteration(ctx) if err != nil { t.Errorf("Iteration %d failed: %v", i, err) @@ -55,7 +55,7 @@ func TestMockClaude_SuccessScenario(t *testing.T) { // Verify all todos complete if !mock.AllTodosComplete() { - t.Error("expected AllTodosComplete to return true after 5 iterations") + t.Error("expected AllTodosComplete to return true after 3 iterations") } } @@ -155,28 +155,34 @@ func TestMockClaude_TodoContent(t *testing.T) { mock := NewMockClaude("success", tmpDir) ctx := context.Background() - // Iteration 1: All pending + // Iteration 1: First in progress, second pending mock.RunIteration(ctx) content := readTodoFile(t, tmpDir) - if !strings.Contains(content, "- [ ] Implement feature A") { - t.Error("Iteration 1: expected pending todo for 'Implement feature A'") + if !strings.Contains(content, "- [-] Implement feature A") { + t.Error("Iteration 1: expected in-progress todo for 'Implement feature A'") + } + if !strings.Contains(content, "- [ ] Add tests for feature A") { + t.Error("Iteration 1: expected pending todo for 'Add tests for feature A'") } - // Iteration 2: First in progress + // Iteration 2: First complete, second in progress mock.RunIteration(ctx) content = readTodoFile(t, tmpDir) - if !strings.Contains(content, "- [-] Implement feature A") { - t.Error("Iteration 2: expected in-progress todo for 'Implement feature A'") + if !strings.Contains(content, "- [x] Implement feature A") { + t.Error("Iteration 2: expected completed todo for 'Implement feature A'") + } + if !strings.Contains(content, "- [-] Add tests for feature A") { + t.Error("Iteration 2: expected in-progress todo for 'Add tests for feature A'") } - // Iteration 3: First complete, second in progress + // Iteration 3: All complete mock.RunIteration(ctx) content = readTodoFile(t, tmpDir) if !strings.Contains(content, "- [x] Implement feature A") { t.Error("Iteration 3: expected completed todo for 'Implement feature A'") } - if !strings.Contains(content, "- [-] Add tests for feature A") { - t.Error("Iteration 3: expected in-progress todo for 'Add tests for feature A'") + if !strings.Contains(content, "- [x] Add tests for feature A") { + t.Error("Iteration 3: expected completed todo for 'Add tests for feature A'") } }