ralph.rb 1.2.435535439

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/gem-push.yml +47 -0
  3. data/.gitignore +79 -0
  4. data/.rubocop.yml +6018 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +113 -0
  7. data/Gemfile +11 -0
  8. data/LICENSE +21 -0
  9. data/README.md +656 -0
  10. data/bin/rubocop +8 -0
  11. data/bin/test +5 -0
  12. data/exe/ralph +8 -0
  13. data/lib/ralph/agents/base.rb +132 -0
  14. data/lib/ralph/agents/claude_code.rb +24 -0
  15. data/lib/ralph/agents/codex.rb +25 -0
  16. data/lib/ralph/agents/open_code.rb +30 -0
  17. data/lib/ralph/agents.rb +24 -0
  18. data/lib/ralph/cli.rb +222 -0
  19. data/lib/ralph/config.rb +40 -0
  20. data/lib/ralph/git/file_snapshot.rb +60 -0
  21. data/lib/ralph/helpers.rb +76 -0
  22. data/lib/ralph/iteration.rb +220 -0
  23. data/lib/ralph/loop.rb +196 -0
  24. data/lib/ralph/output/active_loop_error.rb +13 -0
  25. data/lib/ralph/output/banner.rb +29 -0
  26. data/lib/ralph/output/completion_deferred.rb +12 -0
  27. data/lib/ralph/output/completion_detected.rb +17 -0
  28. data/lib/ralph/output/config_summary.rb +31 -0
  29. data/lib/ralph/output/context_consumed.rb +11 -0
  30. data/lib/ralph/output/iteration.rb +45 -0
  31. data/lib/ralph/output/max_iterations_reached.rb +16 -0
  32. data/lib/ralph/output/no_plugin_warning.rb +14 -0
  33. data/lib/ralph/output/nonzero_exit_warning.rb +11 -0
  34. data/lib/ralph/output/plugin_error.rb +12 -0
  35. data/lib/ralph/output/status.rb +176 -0
  36. data/lib/ralph/output/struggle_warning.rb +18 -0
  37. data/lib/ralph/output/task_completion.rb +12 -0
  38. data/lib/ralph/output/tasks_file_created.rb +11 -0
  39. data/lib/ralph/prompt_template.rb +183 -0
  40. data/lib/ralph/storage/context.rb +58 -0
  41. data/lib/ralph/storage/history.rb +117 -0
  42. data/lib/ralph/storage/state.rb +178 -0
  43. data/lib/ralph/storage/tasks.rb +244 -0
  44. data/lib/ralph/threads/heartbeat.rb +44 -0
  45. data/lib/ralph/threads/stream_reader.rb +50 -0
  46. data/lib/ralph/version.rb +5 -0
  47. data/lib/ralph.rb +67 -0
  48. data/original/bin/ralph.js +13 -0
  49. data/original/ralph.ts +1706 -0
  50. data/ralph.gemspec +35 -0
  51. data/ralph2.gemspec +35 -0
  52. data/screenshot.webp +0 -0
  53. data/specs/README.md +46 -0
  54. data/specs/agents.md +172 -0
  55. data/specs/cli.md +223 -0
  56. data/specs/iteration.md +173 -0
  57. data/specs/output.md +104 -0
  58. data/specs/storage/local-data-structure.md +246 -0
  59. data/specs/tasks.md +295 -0
  60. metadata +150 -0
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ class Iteration
5
+ # Statuses:
6
+ # :completed — completion promise detected, iteration succeeded
7
+ # :continuing — no completion detected, keep looping
8
+ # :failed — non-zero exit code from the agent process
9
+ # :fatal — fatal error detected in agent output (unrecoverable)
10
+ # :error — iteration raised an exception
11
+ class Result
12
+ STATUSES = %i[completed continuing failed fatal error].freeze
13
+
14
+ attr_reader :status, :agent_result, :duration_ms, :files_modified, :completion_detected, :errors
15
+
16
+ def initialize(status:, agent_result:, duration_ms:, files_modified:, completion_detected:, errors:)
17
+ @status = status
18
+ @agent_result = agent_result
19
+ @duration_ms = duration_ms
20
+ @files_modified = files_modified
21
+ @completion_detected = completion_detected
22
+ @errors = errors
23
+ end
24
+
25
+ def exit_code = agent_result&.exit_code
26
+ def stdout_text = agent_result&.stdout_text || ""
27
+ def stderr_text = agent_result&.stderr_text || ""
28
+ def tool_counts = agent_result&.tool_counts || {}
29
+ def combined_output = agent_result&.combined_output || ""
30
+
31
+ def completed? = status == :completed
32
+ def continuing? = status == :continuing
33
+ def failed? = status == :failed
34
+ def fatal? = status == :fatal
35
+ def error? = status == :error
36
+ end
37
+
38
+ include ::Ralph::Helpers
39
+
40
+ attr_reader :struggle_indicators, :iteration_start
41
+
42
+ def initialize(loop_context)
43
+ @loop = loop_context
44
+ @config = @loop.config
45
+ @agent = @loop.agent
46
+ @state = @loop.state
47
+ @struggle_indicators = @loop.struggle_indicators
48
+
49
+ # Streaming configuration
50
+ @compact_tools = !@config.verbose_tools
51
+ @tool_summary_interval_ms = 3000
52
+ @heartbeat_interval_ms = 10_000
53
+
54
+ @stream_tool_counts = Hash.new(0)
55
+ @mutex = Mutex.new
56
+ @timing = { last_printed_at: now_ms, last_activity_at: now_ms }
57
+ @last_tool_summary_at = 0
58
+
59
+ @iteration_start = now_ms
60
+ end
61
+
62
+ def context_at_start = @_context_at_start ||= @loop.context
63
+
64
+ def run
65
+ snapshot_before = Git::FileSnapshot.capture
66
+
67
+ if @config.stream_output
68
+ heartbeat = Threads::Heartbeat.new(iteration_start, @heartbeat_interval_ms, @timing, @mutex)
69
+ end
70
+
71
+ @agent.execute(
72
+ @loop.prompt.build_iteration(@state, @agent),
73
+ on_line: method(:handle_line),
74
+ model: @config.model,
75
+ stream_output: @config.stream_output,
76
+ disable_plugins: @config.disable_plugins,
77
+ allow_all_permissions: @config.allow_all_permissions,
78
+ ).then do |agent_result|
79
+ heartbeat&.stop
80
+
81
+ if @config.stream_output
82
+ @mutex.synchronize { maybe_print_tool_summary(force: true) }
83
+ end
84
+
85
+ unless @config.stream_output
86
+ if agent_result
87
+ warn agent_result.stderr_text unless agent_result.stderr_text.empty?
88
+ puts agent_result.stdout_text
89
+ end
90
+ end
91
+
92
+ snapshot_after = Git::FileSnapshot.capture
93
+
94
+ combined_output = agent_result.combined_output
95
+ completion_detected = check_completion(combined_output, @config.completion_promise)
96
+ fatal_error = @agent.detect_fatal_error(combined_output)
97
+
98
+ status = (
99
+ if fatal_error
100
+ :fatal
101
+ elsif agent_result.exit_code != 0
102
+ :failed
103
+ elsif completion_detected
104
+ :completed
105
+ else
106
+ :continuing
107
+ end
108
+ )
109
+
110
+ Result.new(
111
+ status: status,
112
+ agent_result: agent_result,
113
+ duration_ms: now_ms - iteration_start,
114
+ files_modified: snapshot_before.modified_since(snapshot_after),
115
+ completion_detected: completion_detected,
116
+ errors: @agent.extract_errors(combined_output)
117
+ ).tap do |result|
118
+ update_struggle_indicators(result)
119
+ end
120
+ end
121
+ rescue StandardError => error
122
+ if @config.current_pid
123
+ begin
124
+ Process.kill('TERM', @config.current_pid)
125
+ rescue StandardError
126
+ # process may have exited
127
+ end
128
+ @config.current_pid = nil
129
+ end
130
+
131
+ Output::Iteration::Error.call(@loop, error)
132
+
133
+ sleep 2
134
+
135
+ Result.new(
136
+ status: :error,
137
+ agent_result: nil,
138
+ duration_ms: now_ms - (iteration_start || now_ms), # this is so fucking wrong
139
+ files_modified: [],
140
+ completion_detected: false,
141
+ errors: [error.message]
142
+ )
143
+ end
144
+
145
+ # ---------- Warnings and error detection ----------
146
+
147
+ def handle_iteration_error(error, iteration_start)
148
+ end
149
+
150
+ # Returns true when the agent appears to be stuck.
151
+ # Should only be called after iteration > 2 for meaningful results.
152
+ def struggling?
153
+ @struggle_indicators[:no_progress_iterations] >= 3 ||
154
+ @struggle_indicators[:short_iterations] >= 3
155
+ end
156
+
157
+ private
158
+
159
+ # ---------- Struggle tracking ----------
160
+
161
+ def update_struggle_indicators(result)
162
+ if result.files_modified.empty?
163
+ @struggle_indicators[:no_progress_iterations] += 1
164
+ else
165
+ @struggle_indicators[:no_progress_iterations] = 0
166
+ end
167
+
168
+ if result.duration_ms < 30_000
169
+ @struggle_indicators[:short_iterations] += 1
170
+ else
171
+ @struggle_indicators[:short_iterations] = 0
172
+ end
173
+
174
+ if result.errors.empty?
175
+ @struggle_indicators[:repeated_errors] = {}
176
+ else
177
+ result.errors.each do |error|
178
+ key = error[0, 100]
179
+ @struggle_indicators[:repeated_errors][key] = (@struggle_indicators[:repeated_errors][key] || 0) + 1
180
+ end
181
+ end
182
+ end
183
+
184
+ # ---------- Agent execution ----------
185
+
186
+ def maybe_print_tool_summary(force: false)
187
+ if @compact_tools && @stream_tool_counts.any?
188
+ now = now_ms
189
+ if force || (now - @last_tool_summary_at >= @tool_summary_interval_ms)
190
+ format_tool_summary(@stream_tool_counts).then do |summary|
191
+ unless summary.empty?
192
+ puts "| Tools #{summary}"
193
+ @timing[:last_printed_at] = now_ms
194
+ @last_tool_summary_at = now_ms
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ def handle_line(line, is_error, tool)
202
+ @mutex.synchronize { @timing[:last_activity_at] = now_ms }
203
+
204
+ @mutex.synchronize { @stream_tool_counts[tool] += 1 } if tool
205
+
206
+ if tool && @compact_tools
207
+ @mutex.synchronize { maybe_print_tool_summary }
208
+ else
209
+ if line.empty?
210
+ puts ''
211
+ elsif is_error
212
+ warn line
213
+ else
214
+ puts line
215
+ end
216
+ @mutex.synchronize { @timing[:last_printed_at] = now_ms }
217
+ end
218
+ end
219
+ end
220
+ end
data/lib/ralph/loop.rb ADDED
@@ -0,0 +1,196 @@
1
+ # _ _ _
2
+ # _ __ __ _| |_ __ | |__ __ _(_) __ _ __ _ _ _ _ __ ___
3
+ # | '__/ _` | | '_ \| '_ \ \ \ /\ / / |/ _` |/ _` | | | | '_ ` _ \
4
+ # | | | (_| | | |_) | | | | \ V V /| | (_| | (_| | |_| | | | | | |
5
+ # |_| \__,_|_| .__/|_| |_| \_/\_/ |_|\__, |\__, |\__,_|_| |_| |_|
6
+ # |_| |___/ |___/
7
+ #
8
+ # frozen_string_literal: true
9
+
10
+ module Ralph
11
+ class Loop
12
+ include ::Ralph::Helpers
13
+
14
+ attr_reader :config, :agent, :state, :history, :context, :tasks, :prompt, :struggle_indicators
15
+
16
+ def initialize(config, state, history, context, tasks)
17
+ @config = config
18
+ @agent = Agents.resolve(@config.chosen_agent)
19
+ @state = state
20
+ @history = history
21
+ @context = context
22
+ @tasks = tasks
23
+
24
+ @struggle_indicators = {
25
+ repeated_errors: {},
26
+ no_progress_iterations: 0,
27
+ short_iterations: 0
28
+ }
29
+
30
+ @prompt = @config.prompt
31
+ @existing_state = Storage::State.load
32
+ @state.save
33
+ end
34
+
35
+ def existing_state = @existing_state
36
+
37
+ def run
38
+ if existing_state&.active
39
+ Output::ActiveLoopError.call(existing_state, path: Storage::State.path)
40
+ exit 1
41
+ end
42
+
43
+ Output::Banner.call(self)
44
+
45
+ # |..................................................|
46
+ # |--------------------------------------------------|
47
+ # |==================================================|
48
+ # |**************************************************|
49
+ # |##################################################|
50
+ # | Main loop |
51
+ # |##################################################|
52
+ # |**************************************************|
53
+ # |==================================================|
54
+ # |--------------------------------------------------|
55
+ # |..................................................|
56
+ #
57
+ # © 2026 Nathan K.
58
+ # Honestly, I've no idea where this graphic
59
+ # wonder came from. It's MIT lisenced now, thought.
60
+
61
+ setup_signal_handler
62
+
63
+ loop do
64
+ if @config.stopping
65
+ break
66
+ elsif max_iterations_reached?
67
+ Output::MaxIterationsReached.call(self)
68
+ @state.clear
69
+ break
70
+ else
71
+ Output::Iteration::Header.call(self)
72
+
73
+ iteration = Iteration.new(self)
74
+ result = iteration.run
75
+ should_continue = process_result(result, iteration)
76
+
77
+ if should_continue
78
+ @state.iteration += 1
79
+ @state.save
80
+ sleep 1
81
+ else
82
+ break
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def process_result(result, iteration)
91
+ unless result.error?
92
+ Output::Iteration::Summary.call(self, result)
93
+ end
94
+
95
+ case result.status
96
+ when :fatal
97
+ @history.record(
98
+ state_iteration: @state.iteration,
99
+ iteration_start: iteration.iteration_start,
100
+ result: result,
101
+ struggle_indicators: iteration.struggle_indicators
102
+ )
103
+ Output::PluginError.call
104
+ @state.clear
105
+ exit 1
106
+
107
+ when :failed
108
+ @history.record(
109
+ state_iteration: @state.iteration,
110
+ iteration_start: iteration.iteration_start,
111
+ result: result,
112
+ struggle_indicators: iteration.struggle_indicators
113
+ )
114
+ Output::NonzeroExitWarning.call(self, result)
115
+
116
+ if @config.tasks_mode
117
+ if check_completion(result.combined_output, @config.task_promise)
118
+ Output::TaskCompletion.call(self)
119
+ end
120
+ end
121
+
122
+ when :completed
123
+ @history.record(
124
+ state_iteration: @state.iteration,
125
+ iteration_start: iteration.iteration_start,
126
+ result: result,
127
+ struggle_indicators: iteration.struggle_indicators
128
+ )
129
+
130
+ if @state.iteration >= @config.min_iterations
131
+ Output::CompletionDetected.call(self)
132
+ @state.clear
133
+ Storage::History.clear_history
134
+ @context.clear
135
+ end
136
+
137
+ when :continuing
138
+ @history.record(
139
+ state_iteration: @state.iteration,
140
+ iteration_start: iteration.iteration_start,
141
+ result: result,
142
+ struggle_indicators: iteration.struggle_indicators
143
+ )
144
+
145
+ if @state.iteration > 2 && iteration.struggling?
146
+ Output::StruggleWarning.call(self)
147
+ end
148
+
149
+ if @config.tasks_mode
150
+ if check_completion(result.combined_output, @config.task_promise)
151
+ Output::TaskCompletion.call(self)
152
+ end
153
+ end
154
+
155
+ if iteration.context_at_start.present?
156
+ Output::ContextConsumed.call
157
+ iteration.context_at_start.clear
158
+ end
159
+
160
+ when :error
161
+ @history.record_error(
162
+ state_iteration: @state.iteration,
163
+ iteration_start: iteration.iteration_start,
164
+ error: result.errors.first || "Unknown error"
165
+ )
166
+ end
167
+
168
+ result.status != :completed || @state.iteration < @config.min_iterations
169
+ end
170
+
171
+ def max_iterations_reached?
172
+ @config.max_iterations > 0 && @state.iteration > @config.max_iterations
173
+ end
174
+
175
+ def setup_signal_handler
176
+ Signal.trap('INT') do
177
+ if @config.stopping
178
+ warn "\nForce stopping..."
179
+ exit 1
180
+ end
181
+ @config.stopping = true
182
+ warn "\nGracefully stopping Ralph loop..."
183
+ if @config.current_pid
184
+ begin
185
+ Process.kill('TERM', @config.current_pid)
186
+ rescue StandardError
187
+ # process may have exited
188
+ end
189
+ end
190
+ @state.clear
191
+ warn 'Loop cancelled.'
192
+ exit 0
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class ActiveLoopError
6
+ def self.call(existing_state, path:)
7
+ $stderr.puts "Error: A Ralph loop is already active (iteration #{existing_state.iteration})"
8
+ $stderr.puts "Started at: #{existing_state.started_at}"
9
+ $stderr.puts "To cancel it, press Ctrl+C in its terminal or delete #{path}"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class Banner
6
+ def self.call(loop_context)
7
+ Output::NoPluginWarning.call(loop_context) if loop_context.config.disable_plugins
8
+
9
+ puts <<~BANNER
10
+
11
+ ╔#{'=' * 66}╗
12
+ ║ Ralph Wiggum Loop ║
13
+ ║ Iterative AI Development with #{loop_context.agent.config_name.ljust(20)} ║
14
+ ╚#{'=' * 66}╝
15
+ BANNER
16
+
17
+ if loop_context.config.tasks_mode
18
+ tasks_storage = Storage::Tasks.new
19
+ unless File.exist?(tasks_storage.path)
20
+ tasks_storage.initialize_tasks_file
21
+ Output::TasksFileCreated.call(path: tasks_storage.path)
22
+ end
23
+ end
24
+
25
+ Output::ConfigSummary.call(loop_context)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class CompletionDeferred
6
+ def self.call(config:, next_iteration:)
7
+ puts "\n⏳ Completion promise detected, but minimum iterations (#{config.min_iterations}) not yet reached."
8
+ puts " Continuing to iteration #{next_iteration}..."
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class CompletionDetected
6
+ extend ::Ralph::Helpers
7
+
8
+ def self.call(loop_context)
9
+ puts "\n╔#{'=' * 66}╗"
10
+ puts "║ ✅ Completion promise detected: <promise>#{loop_context.config.completion_promise}</promise>"
11
+ puts "║ Task completed in #{loop_context.state.iteration} iteration(s)"
12
+ puts "║ Total time: #{format_duration_long(loop_context.history.total_duration_ms)}"
13
+ puts "╚#{'=' * 66}╝"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class ConfigSummary
6
+ def self.call(loop_context)
7
+ config = loop_context.config
8
+ agent = loop_context.agent
9
+ prompt = loop_context.prompt
10
+
11
+ prompt_text = prompt.to_s
12
+ prompt_preview = prompt_text.gsub(/\s+/, ' ')[0, 80] + (prompt_text.length > 80 ? '...' : '')
13
+ puts "Task: #{prompt_preview}"
14
+ puts "Completion promise: #{config.completion_promise}"
15
+ if config.tasks_mode
16
+ puts 'Tasks mode: ENABLED'
17
+ puts "Task promise: #{config.task_promise}"
18
+ end
19
+ puts "Min iterations: #{config.min_iterations}"
20
+ puts "Max iterations: #{config.max_iterations > 0 ? config.max_iterations : 'unlimited'}"
21
+ puts "Agent: #{agent.config_name}"
22
+ puts "Model: #{config.model}" if config.model && !config.model.empty?
23
+ puts 'OpenCode plugins: non-auth plugins disabled' if config.disable_plugins && agent.type == :opencode
24
+ puts 'Permissions: auto-approve all tools' if config.allow_all_permissions
25
+ puts ''
26
+ puts 'Starting loop... (Ctrl+C to stop)'
27
+ puts '═' * 68
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class ContextConsumed
6
+ def self.call
7
+ puts "📝 Context was consumed this iteration"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ module Iteration
6
+ class Header
7
+ def self.call(loop_context)
8
+ config = loop_context.config
9
+ iteration = loop_context.state.iteration
10
+
11
+ iter_info = config.max_iterations > 0 ? " / #{config.max_iterations}" : ''
12
+ min_info = config.min_iterations > 1 && iteration < config.min_iterations ? " (min: #{config.min_iterations})" : ''
13
+ puts "\n🔄 Iteration #{iteration}#{iter_info}#{min_info}"
14
+ puts '─' * 68
15
+ end
16
+ end
17
+
18
+ class Summary
19
+ extend ::Ralph::Helpers
20
+
21
+ def self.call(loop_context, result)
22
+ tool_summary = format_tool_summary(result.tool_counts)
23
+ puts "\nIteration Summary"
24
+ puts "─" * 68
25
+ puts "Iteration: #{loop_context.state.iteration}"
26
+ puts "Elapsed: #{format_duration(result.duration_ms)}"
27
+ if tool_summary && !tool_summary.empty?
28
+ puts "Tools: #{tool_summary}"
29
+ else
30
+ puts "Tools: none"
31
+ end
32
+ puts "Exit code: #{result.exit_code}"
33
+ puts "Completion promise: #{result.completion_detected ? "detected" : "not detected"}"
34
+ end
35
+ end
36
+
37
+ class Error
38
+ def self.call(loop_context, error)
39
+ $stderr.puts "\n❌ Error in iteration #{loop_context.state.iteration}: #{error}"
40
+ puts "Continuing to next iteration..."
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class MaxIterationsReached
6
+ extend ::Ralph::Helpers
7
+
8
+ def self.call(loop_context)
9
+ puts "\n╔#{'=' * 66}╗"
10
+ puts "║ Max iterations (#{loop_context.config.max_iterations}) reached. Loop stopped."
11
+ puts "║ Total time: #{format_duration_long(loop_context.history.total_duration_ms)}"
12
+ puts "╚#{'=' * 66}╝"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ module Ralph
2
+ module Output
3
+ class NoPluginWarning
4
+ def self.call(loop_context)
5
+ case loop_context.agent.type
6
+ when :claude_code
7
+ warn 'Warning: --no-plugins has no effect with Claude Code agent'
8
+ when :codex
9
+ warn 'Warning: --no-plugins has no effect with Codex agent'
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class NonzeroExitWarning
6
+ def self.call(loop_context, result)
7
+ warn "\n⚠️ #{loop_context.agent.config_name} exited with code #{result.exit_code}. Continuing to next iteration."
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class PluginError
6
+ def self.call
7
+ $stderr.puts "\n❌ OpenCode tried to load the legacy 'ralph-wiggum' plugin. This package is CLI-only."
8
+ $stderr.puts "Remove 'ralph-wiggum' from your opencode.json plugin list, or re-run with --no-plugins."
9
+ end
10
+ end
11
+ end
12
+ end