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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class Status
6
+ include ::Ralph::Helpers
7
+
8
+ def self.call(options:)
9
+ new(options).call
10
+ end
11
+
12
+ def initialize(options)
13
+ @options = options
14
+ end
15
+
16
+ def call
17
+ state = Storage::State.load
18
+ history = Storage::History.load_history
19
+ context = Storage::Context.new
20
+ show_tasks = @options[:tasks_mode] || state&.tasks_mode
21
+
22
+ print_header
23
+ print_loop_status(state)
24
+ print_pending_context(context)
25
+ print_tasks if show_tasks
26
+ print_history(history)
27
+ print_footer
28
+ end
29
+
30
+ private
31
+
32
+ def print_header
33
+ puts <<~HEADER
34
+
35
+ ╔#{"=" * 66}╗
36
+ ║ Ralph Wiggum Status ║
37
+ ╚#{"=" * 66}╝
38
+ HEADER
39
+ end
40
+
41
+ def print_loop_status(state)
42
+ if state&.active
43
+ print_active_loop(state)
44
+ else
45
+ puts "⏹️ No active loop"
46
+ end
47
+ end
48
+
49
+ def print_active_loop(state)
50
+ elapsed = now_ms - (Time.parse(state.started_at).to_f * 1000).to_i
51
+ elapsed_str = format_duration_long(elapsed)
52
+ puts "🔄 ACTIVE LOOP"
53
+ max_str = state.max_iterations > 0 ? " / #{state.max_iterations}" : " (unlimited)"
54
+ puts " Iteration: #{state.iteration}#{max_str}"
55
+ puts " Started: #{state.started_at}"
56
+ puts " Elapsed: #{elapsed_str}"
57
+ puts " Promise: #{state.completion_promise}"
58
+ agent_label =
59
+ if state.agent
60
+ cfg = Agents.resolve(state.agent)
61
+ cfg ? cfg.config_name : state.agent
62
+ else
63
+ "OpenCode"
64
+ end
65
+ puts " Agent: #{agent_label}"
66
+ puts " Model: #{state.model}" if state.model && !state.model.empty?
67
+ if state.tasks_mode
68
+ puts " Tasks Mode: ENABLED"
69
+ puts " Task Promise: #{state.task_promise}"
70
+ end
71
+ prompt_preview = state.prompt[0, 60] + (state.prompt.length > 60 ? "..." : "")
72
+ puts " Prompt: #{prompt_preview}"
73
+ end
74
+
75
+ def print_pending_context(context)
76
+ return unless context.present?
77
+
78
+ puts "\n📝 PENDING CONTEXT (will be injected next iteration):"
79
+ puts " #{context.content.split("\n").join("\n ")}"
80
+ end
81
+
82
+ def print_tasks
83
+ tasks_storage = Storage::Tasks.new
84
+ if File.exist?(tasks_storage.path)
85
+ begin
86
+ tasks_content = File.read(tasks_storage.path)
87
+ tasks = Tasks.parse(tasks_content)
88
+ print_current_tasks(tasks)
89
+ rescue StandardError
90
+ puts "\n📋 CURRENT TASKS: (error reading tasks)"
91
+ end
92
+ else
93
+ puts "\n📋 CURRENT TASKS: (no tasks file found)"
94
+ end
95
+ end
96
+
97
+ def print_current_tasks(tasks)
98
+ if tasks.any?
99
+ puts "\n📋 CURRENT TASKS:"
100
+ tasks.each_with_index do |task, i|
101
+ icon = tasks.status_icon(task.status)
102
+ puts " #{i + 1}. #{icon} #{task.text}"
103
+ task.subtasks.each do |subtask|
104
+ sub_icon = tasks.status_icon(subtask.status)
105
+ puts " #{sub_icon} #{subtask.text}"
106
+ end
107
+ end
108
+ complete = tasks.count { |t| t.status == :complete }
109
+ in_progress = tasks.count { |t| t.status == :in_progress }
110
+ puts "\n Progress: #{complete}/#{tasks.length} complete, #{in_progress} in progress"
111
+ else
112
+ puts "\n📋 CURRENT TASKS: (no tasks found)"
113
+ end
114
+ end
115
+
116
+ def print_history(history)
117
+ iterations = history["iterations"] || []
118
+ return unless iterations.any?
119
+
120
+ puts "\n📊 HISTORY (#{iterations.length} iterations)"
121
+ puts " Total time: #{format_duration_long(history["total_duration_ms"] || 0)}"
122
+
123
+ recent = iterations.last(5)
124
+ print_recent_iterations(recent)
125
+
126
+ si = history["struggle_indicators"] || {}
127
+ has_repeated = (si["repeated_errors"] || {}).values.any? { |c| c >= 2 }
128
+ if (si["no_progress_iterations"] || 0) >= 3 || (si["short_iterations"] || 0) >= 3 || has_repeated
129
+ print_struggle_warnings(si)
130
+ end
131
+ end
132
+
133
+ def print_recent_iterations(iterations)
134
+ puts "\n Recent iterations:"
135
+ iterations.each do |iter|
136
+ tools = (iter["tools_used"] || {})
137
+ .sort_by { |_, v| -v }
138
+ .first(3)
139
+ .map { |k, v| "#{k}:#{v}" }
140
+ .join(" ")
141
+ status_icon =
142
+ if iter["completion_detected"]
143
+ "✅"
144
+ elsif iter["exit_code"] != 0
145
+ "❌"
146
+ else
147
+ "🔄"
148
+ end
149
+ puts " #{status_icon} ##{iter["iteration"]}: #{format_duration_long(iter["duration_ms"])} | #{tools.empty? ? "no tools" : tools}"
150
+ end
151
+ end
152
+
153
+ def print_struggle_warnings(si)
154
+ puts "\n⚠️ STRUGGLE INDICATORS:"
155
+ if (si["no_progress_iterations"] || 0) >= 3
156
+ puts " - No file changes in #{si["no_progress_iterations"]} iterations"
157
+ end
158
+ if (si["short_iterations"] || 0) >= 3
159
+ puts " - #{si["short_iterations"]} very short iterations (< 30s)"
160
+ end
161
+ top_errors = (si["repeated_errors"] || {})
162
+ .select { |_, count| count >= 2 }
163
+ .sort_by { |_, count| -count }
164
+ .first(3)
165
+ top_errors.each do |error, count|
166
+ puts " - Same error #{count}x: \"#{error[0, 50]}...\""
167
+ end
168
+ puts "\n 💡 Consider using: ralph --add-context \"your hint here\""
169
+ end
170
+
171
+ def print_footer
172
+ puts ""
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class StruggleWarning
6
+ def self.call(loop_context)
7
+ puts "\n⚠️ Potential struggle detected:"
8
+ if loop_context.struggle_indicators[:no_progress_iterations] >= 3
9
+ puts " - No file changes in #{loop_context.struggle_indicators[:no_progress_iterations]} iterations"
10
+ end
11
+ if loop_context.struggle_indicators[:short_iterations] >= 3
12
+ puts " - #{loop_context.struggle_indicators[:short_iterations]} very short iterations"
13
+ end
14
+ puts " 💡 Tip: Use 'ralph --add-context \"hint\"' in another terminal to guide the agent"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class TaskCompletion
6
+ def self.call(loop_context)
7
+ puts "\n🔄 Task completion detected: <promise>#{loop_context.config.task_promise}</promise>"
8
+ puts " Moving to next task in iteration #{loop_context.state.iteration + 1}..."
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Output
5
+ class TasksFileCreated
6
+ def self.call(path:)
7
+ puts "📋 Created tasks file: #{path}"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ class PromptTemplate
5
+ class Error < StandardError; end
6
+
7
+ attr_reader :user_prompt, :context, :tasks
8
+
9
+ def initialize(user_prompt, context: nil, tasks: nil)
10
+ @user_prompt = user_prompt
11
+ @context = context
12
+ @tasks = tasks
13
+ end
14
+
15
+ def to_s = @user_prompt
16
+ def empty? = @user_prompt.strip.empty?
17
+
18
+ def context_section
19
+ @_context_section ||=
20
+ if @context.present?
21
+ "
22
+ ## Additional Context (added by user mid-loop)
23
+
24
+ #{@context.content}
25
+
26
+ ---
27
+ "
28
+ else
29
+ ""
30
+ end
31
+ end
32
+
33
+ def task_mode_prompt(state)
34
+ tasks_section = build_tasks_section(state)
35
+ <<~PROMPT.strip
36
+ # Ralph Wiggum Loop - Iteration #{state.iteration}
37
+
38
+ You are in an iterative development loop working through a task list.
39
+ #{context_section}#{tasks_section}
40
+ ## Your Main Goal
41
+
42
+ #{@user_prompt}
43
+
44
+ ## Critical Rules
45
+
46
+ - Work on ONE task at a time from .ralph/ralph-tasks.md
47
+ - ONLY output <promise>#{state.task_promise}</promise> when the current task is complete and marked in ralph-tasks.md
48
+ - ONLY output <promise>#{state.completion_promise}</promise> when ALL tasks are truly done
49
+ - Do NOT lie or output false promises to exit the loop
50
+ - If stuck, try a different approach
51
+ - Check your work before claiming completion
52
+
53
+ ## Current Iteration: #{state.iteration}#{state.max_iterations > 0 ? " / #{state.max_iterations}" : " (unlimited)"} (min: #{state.min_iterations || 1})
54
+
55
+ Tasks Mode: ENABLED - Work on one task at a time from ralph-tasks.md
56
+
57
+ Now, work on the current task. Good luck!
58
+ PROMPT
59
+ end
60
+
61
+ def non_task_mode_prompt(state)
62
+ <<~PROMPT.strip
63
+ # Ralph Wiggum Loop - Iteration #{state.iteration}
64
+
65
+ You are in an iterative development loop. Work on the task below until you can genuinely complete it.
66
+ #{context_section}
67
+ ## Your Task
68
+
69
+ #{@user_prompt}
70
+
71
+ ## Instructions
72
+
73
+ 1. Read the current state of files to understand what's been done
74
+ 2. Track your progress and plan remaining work
75
+ 3. Make progress on the task
76
+ 4. Run tests/verification if applicable
77
+ 5. When the task is GENUINELY COMPLETE, output:
78
+ <promise>#{state.completion_promise}</promise>
79
+
80
+ ## Critical Rules
81
+
82
+ - ONLY output <promise>#{state.completion_promise}</promise> when the task is truly done
83
+ - Do NOT lie or output false promises to exit the loop
84
+ - If stuck, try a different approach
85
+ - Check your work before claiming completion
86
+ - The loop will continue until you succeed
87
+
88
+ ## Current Iteration: #{state.iteration}#{state.max_iterations > 0 ? " / #{state.max_iterations}" : " (unlimited)"} (min: #{state.min_iterations || 1})
89
+
90
+ Now, work on the task. Good luck!
91
+ PROMPT
92
+ end
93
+
94
+ # Build the full iteration prompt sent to the LLM.
95
+ # +state+ is a Storage::State with iteration metadata.
96
+ # +_agent+ is the agent config (reserved for future use).
97
+ def build_iteration(state, _agent)
98
+ if state.tasks_mode
99
+ task_mode_prompt(state)
100
+ else
101
+ non_task_mode_prompt(state)
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def build_tasks_section(state)
108
+ tasks_path = @tasks.path
109
+ unless File.exist?(tasks_path)
110
+ return <<~SECTION
111
+
112
+ ## TASKS MODE: Enabled (no tasks file found)
113
+
114
+ Create .ralph/ralph-tasks.md with your task list, or use `ralph --add-task "description"` to add tasks.
115
+ SECTION
116
+ end
117
+
118
+ begin
119
+ tasks_content = File.read(tasks_path)
120
+ tasks = Tasks.parse(tasks_content)
121
+ current_task = tasks.current
122
+ next_task = tasks.next
123
+
124
+ task_instructions =
125
+ if current_task
126
+ <<~INST
127
+ 🔄 CURRENT TASK: "#{current_task.text}"
128
+ Focus on completing this specific task.
129
+ When done: Mark as [x] in .ralph/ralph-tasks.md and output <promise>#{state.task_promise}</promise>
130
+ INST
131
+ elsif next_task
132
+ <<~INST
133
+ 📍 NEXT TASK: "#{next_task.text}"
134
+ Mark as [/] in .ralph/ralph-tasks.md before starting.
135
+ When done: Mark as [x] and output <promise>#{state.task_promise}</promise>
136
+ INST
137
+ elsif tasks.all_complete?
138
+ <<~INST
139
+ ✅ ALL TASKS COMPLETE!
140
+ Output <promise>#{state.completion_promise}</promise> to finish.
141
+ INST
142
+ else
143
+ <<~INST
144
+ 📋 No tasks found. Add tasks to .ralph/ralph-tasks.md or use `ralph --add-task`
145
+ INST
146
+ end
147
+
148
+ <<~SECTION
149
+
150
+ ## TASKS MODE: Working through task list
151
+
152
+ Current tasks from .ralph/ralph-tasks.md:
153
+ ```markdown
154
+ #{tasks_content.strip}
155
+ ```
156
+ #{task_instructions}
157
+ ### Task Workflow
158
+ 1. Find any task marked [/] (in progress). If none, pick the first [ ] task.
159
+ 2. Mark the task as [/] in ralph-tasks.md before starting.
160
+ 3. Complete the task.
161
+ 4. Mark as [x] when verified complete.
162
+ 5. Output <promise>#{state.task_promise}</promise> to move to the next task.
163
+ 6. Only output <promise>#{state.completion_promise}</promise> when ALL tasks are [x].
164
+
165
+ ---
166
+ SECTION
167
+ rescue StandardError
168
+ <<~SECTION
169
+
170
+ ## TASKS MODE: Error reading tasks file
171
+
172
+ Unable to read .ralph/ralph-tasks.md
173
+ SECTION
174
+ end
175
+ end
176
+
177
+ class << self
178
+ def inject(user_prompt, context:, tasks:)
179
+ new(user_prompt, context: context, tasks: tasks)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ralph
6
+ module Storage
7
+ # Represents the persistent AI context file for conversational continuity.
8
+ #
9
+ # Always instantiate with Context.new — it represents the file on disk.
10
+ # Content is read lazily; the file is created on first write/append.
11
+ class Context
12
+ attr_reader :path
13
+
14
+ def initialize
15
+ FileUtils.mkdir_p(dir)
16
+ end
17
+
18
+ def self.dir
19
+ @dir ||= File.join(Dir.pwd, ".ralph")
20
+ end
21
+
22
+ def dir = self.class.dir
23
+
24
+ def path
25
+ @path ||= File.join(dir, "ralph-context.md")
26
+ end
27
+
28
+ def content
29
+ if File.exist?(path)
30
+ text = File.read(path).strip
31
+ text.empty? ? nil : text
32
+ end
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def present? = !content.nil?
38
+
39
+ def append(text)
40
+ if File.exist?(path)
41
+ write(File.read(path) + text)
42
+ else
43
+ write("# Ralph Loop Context\n#{text}")
44
+ end
45
+ end
46
+
47
+ def write(text)
48
+ File.write(path, text)
49
+ end
50
+
51
+ def clear
52
+ if File.exist?(path)
53
+ File.delete(path)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Ralph
7
+ module Storage
8
+ # Manages iteration history and performance tracking.
9
+ #
10
+ # Stores everything as plain hashes. No structs, no ceremony.
11
+ # Each iteration is a hash appended to an array, persisted as JSON.
12
+ class History
13
+ include ::Ralph::Helpers
14
+
15
+ EMPTY_HISTORY = {
16
+ "iterations" => [],
17
+ "total_duration_ms" => 0,
18
+ "struggle_indicators" => {
19
+ "repeated_errors" => {},
20
+ "no_progress_iterations" => 0,
21
+ "short_iterations" => 0
22
+ }
23
+ }.freeze
24
+
25
+ attr_reader :history
26
+
27
+ def initialize
28
+ @history = self.class.empty_history
29
+ self.class.save_history(@history)
30
+ end
31
+
32
+ # Record a completed iteration.
33
+ def record(state_iteration:, iteration_start:, result:, struggle_indicators:)
34
+ entry = {
35
+ "iteration" => state_iteration,
36
+ "started_at" => Time.at(iteration_start / 1000.0).utc.iso8601,
37
+ "ended_at" => Time.now.utc.iso8601,
38
+ "duration_ms" => result.duration_ms,
39
+ "tools_used" => result.tool_counts,
40
+ "files_modified" => result.files_modified,
41
+ "exit_code" => result.exit_code,
42
+ "completion_detected" => result.completion_detected,
43
+ "errors" => result.errors
44
+ }
45
+
46
+ append(entry, result.duration_ms, struggle_indicators)
47
+ end
48
+
49
+ # Record an iteration that raised an exception before producing a result.
50
+ def record_error(state_iteration:, iteration_start:, error:)
51
+ iteration_duration = now_ms - iteration_start
52
+
53
+ entry = {
54
+ "iteration" => state_iteration,
55
+ "started_at" => Time.at(iteration_start / 1000.0).utc.iso8601,
56
+ "ended_at" => Time.now.utc.iso8601,
57
+ "duration_ms" => iteration_duration,
58
+ "tools_used" => {},
59
+ "files_modified" => [],
60
+ "exit_code" => -1,
61
+ "completion_detected" => false,
62
+ "errors" => [error.to_s[0, 200]]
63
+ }
64
+
65
+ append(entry, iteration_duration, nil)
66
+ end
67
+
68
+ def total_duration_ms
69
+ @history["total_duration_ms"]
70
+ end
71
+
72
+ # --- Class API (raw persistence) ---
73
+
74
+ class << self
75
+ def state_dir
76
+ File.join(Dir.pwd, ".ralph")
77
+ end
78
+
79
+ def history_path
80
+ File.join(state_dir, "ralph-history.json")
81
+ end
82
+
83
+ def empty_history
84
+ JSON.parse(JSON.generate(EMPTY_HISTORY))
85
+ end
86
+
87
+ def save_history(history)
88
+ FileUtils.mkdir_p(state_dir)
89
+ File.write(history_path, JSON.pretty_generate(history))
90
+ end
91
+
92
+ def load_history
93
+ return empty_history unless File.exist?(history_path)
94
+
95
+ JSON.parse(File.read(history_path))
96
+ rescue StandardError
97
+ empty_history
98
+ end
99
+
100
+ def clear_history
101
+ File.delete(history_path) if File.exist?(history_path)
102
+ rescue StandardError
103
+ # ignore
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def append(entry, duration_ms, struggle_indicators)
110
+ @history["iterations"] << entry
111
+ @history["total_duration_ms"] += duration_ms
112
+ @history["struggle_indicators"] = struggle_indicators if struggle_indicators
113
+ self.class.save_history(@history)
114
+ end
115
+ end
116
+ end
117
+ end