ralph.rb 1.2.4355354345 → 2.0.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/gem-push.yml +2 -2
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +53 -0
  5. data/lib/ralph/cli.rb +67 -186
  6. data/lib/ralph/display.rb +105 -0
  7. data/lib/ralph/events.rb +117 -0
  8. data/lib/ralph/loop.rb +113 -170
  9. data/lib/ralph/metrics.rb +88 -0
  10. data/lib/ralph/opencode.rb +66 -0
  11. data/lib/ralph/version.rb +1 -1
  12. data/lib/ralph.rb +0 -3
  13. data/plans/00-complete-implementation.md +120 -0
  14. data/plans/01-cli-implementation.md +53 -0
  15. data/plans/02-loop-implementation.md +78 -0
  16. data/plans/03-agents-implementation.md +76 -0
  17. data/plans/04-metrics-implementation.md +98 -0
  18. data/plans/README.md +63 -0
  19. data/specs/README.md +4 -15
  20. data/specs/__templates__/API_TEMPLATE.md +0 -0
  21. data/specs/__templates__/AUTOMATION_ACTION_TEMPLATE.md +0 -0
  22. data/specs/__templates__/AUTOMATION_TRIGGER_TEMPLATE.md +0 -0
  23. data/specs/__templates__/CONTROLLER_TEMPLATE.md +32 -0
  24. data/specs/__templates__/INTEGRATION_TEMPLATE.md +0 -0
  25. data/specs/__templates__/MODEL_TEMPLATE.md +0 -0
  26. data/specs/agents.md +426 -120
  27. data/specs/cli.md +11 -218
  28. data/specs/lib/todo_item.rb +144 -0
  29. data/specs/log +15 -0
  30. data/specs/loop.md +42 -0
  31. data/specs/metrics.md +51 -0
  32. metadata +23 -39
  33. data/lib/ralph/agents/base.rb +0 -132
  34. data/lib/ralph/agents/claude_code.rb +0 -24
  35. data/lib/ralph/agents/codex.rb +0 -25
  36. data/lib/ralph/agents/open_code.rb +0 -30
  37. data/lib/ralph/agents.rb +0 -24
  38. data/lib/ralph/config.rb +0 -40
  39. data/lib/ralph/git/file_snapshot.rb +0 -60
  40. data/lib/ralph/helpers.rb +0 -76
  41. data/lib/ralph/iteration.rb +0 -220
  42. data/lib/ralph/output/active_loop_error.rb +0 -13
  43. data/lib/ralph/output/banner.rb +0 -29
  44. data/lib/ralph/output/completion_deferred.rb +0 -12
  45. data/lib/ralph/output/completion_detected.rb +0 -17
  46. data/lib/ralph/output/config_summary.rb +0 -31
  47. data/lib/ralph/output/context_consumed.rb +0 -11
  48. data/lib/ralph/output/iteration.rb +0 -45
  49. data/lib/ralph/output/max_iterations_reached.rb +0 -16
  50. data/lib/ralph/output/no_plugin_warning.rb +0 -14
  51. data/lib/ralph/output/nonzero_exit_warning.rb +0 -11
  52. data/lib/ralph/output/plugin_error.rb +0 -12
  53. data/lib/ralph/output/status.rb +0 -176
  54. data/lib/ralph/output/struggle_warning.rb +0 -18
  55. data/lib/ralph/output/task_completion.rb +0 -12
  56. data/lib/ralph/output/tasks_file_created.rb +0 -11
  57. data/lib/ralph/prompt_template.rb +0 -183
  58. data/lib/ralph/storage/context.rb +0 -58
  59. data/lib/ralph/storage/history.rb +0 -117
  60. data/lib/ralph/storage/state.rb +0 -178
  61. data/lib/ralph/storage/tasks.rb +0 -244
  62. data/lib/ralph/threads/heartbeat.rb +0 -44
  63. data/lib/ralph/threads/stream_reader.rb +0 -50
  64. data/original/bin/ralph.js +0 -13
  65. data/original/ralph.ts +0 -1706
  66. data/specs/iteration.md +0 -173
  67. data/specs/output.md +0 -104
  68. data/specs/storage/local-data-structure.md +0 -246
  69. data/specs/tasks.md +0 -295
@@ -1,183 +0,0 @@
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
@@ -1,58 +0,0 @@
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
@@ -1,117 +0,0 @@
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
@@ -1,178 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'fileutils'
5
-
6
- module Ralph
7
- module Storage
8
- # Represents and persists Ralph loop state
9
- class State
10
- attr_accessor :active, :iteration
11
- attr_reader :min_iterations, :max_iterations, :completion_promise,
12
- :tasks_mode, :task_promise, :prompt, :started_at,
13
- :model, :agent
14
-
15
- def initialize(active:, iteration:, min_iterations:, max_iterations:,
16
- completion_promise:, tasks_mode:, task_promise:, prompt:,
17
- started_at:, model:, agent:)
18
- @active = active
19
- @iteration = iteration
20
- @min_iterations = min_iterations
21
- @max_iterations = max_iterations
22
- @completion_promise = completion_promise
23
- @tasks_mode = tasks_mode
24
- @task_promise = task_promise
25
- @prompt = prompt
26
- @started_at = started_at
27
- @model = model
28
- @agent = agent
29
- end
30
-
31
- def save
32
- FileUtils.mkdir_p(self.class.dir)
33
- File.write(self.class.path, JSON.pretty_generate(to_h))
34
- end
35
-
36
- def clear
37
- File.delete(self.class.path) if File.exist?(self.class.path)
38
- rescue StandardError
39
- # ignore
40
- end
41
-
42
- def to_h
43
- {
44
- active: @active,
45
- iteration: @iteration,
46
- minIterations: @min_iterations,
47
- maxIterations: @max_iterations,
48
- completionPromise: @completion_promise,
49
- tasksMode: @tasks_mode,
50
- taskPromise: @task_promise,
51
- prompt: @prompt,
52
- startedAt: @started_at,
53
- model: @model,
54
- agent: @agent
55
- }
56
- end
57
-
58
- class << self
59
- def from_config(config, prompt:)
60
- new(
61
-
62
- iteration: 1,
63
- active: true,
64
- prompt: prompt.to_s,
65
- started_at: Time.now.utc.iso8601,
66
-
67
- model: config.model,
68
- agent: config.chosen_agent,
69
- tasks_mode: config.tasks_mode,
70
- task_promise: config.task_promise,
71
- min_iterations: config.min_iterations,
72
- max_iterations: config.max_iterations,
73
- completion_promise: config.completion_promise,
74
- )
75
- end
76
-
77
- def dir
78
- File.join(Dir.pwd, '.ralph')
79
- end
80
-
81
- def path
82
- File.join(dir, 'ralph-loop.state.json')
83
- end
84
-
85
- def load
86
- if File.exist?(path)
87
- JSON.parse(File.read(path)).then do |data|
88
- new(
89
- active: data['active'],
90
- iteration: data['iteration'],
91
- min_iterations: data['minIterations'],
92
- max_iterations: data['maxIterations'],
93
- completion_promise: data['completionPromise'],
94
- tasks_mode: data['tasksMode'],
95
- task_promise: data['taskPromise'],
96
- prompt: data['prompt'],
97
- started_at: data['startedAt'],
98
- model: data['model'],
99
- agent: data['agent']
100
- )
101
- end
102
- end
103
- rescue StandardError
104
- nil
105
- end
106
-
107
- def clear
108
- File.delete(path) if File.exist?(path)
109
- rescue StandardError
110
- # ignore
111
- end
112
- end
113
-
114
- # --- Configuration Management ---
115
- # NOTE: Commented out — this was generating a synthetic OpenCode config
116
- # to inject via OPENCODE_CONFIG env var. It worked around OpenCode's
117
- # permission system and reimplemented its config resolution logic.
118
- # If needed again, this should be an OpenCode feature, not a workaround.
119
- #
120
- # def self.load_plugins_from_config(config_path)
121
- # return [] unless File.exist?(config_path)
122
- # raw = File.read(config_path)
123
- # without_block = raw.gsub(/\/\*[\s\S]*?\*\//, "")
124
- # without_line = without_block.gsub(/^\s*\/\/.*$/, "")
125
- # parsed = JSON.parse(without_line)
126
- # plugins = parsed["plugin"]
127
- # return [] unless plugins.is_a?(Array)
128
- # plugins.select { |p| p.is_a?(String) }
129
- # rescue StandardError
130
- # []
131
- # end
132
- #
133
- # def self.ensure_ralph_config(filter_plugins: false, allow_all_permissions: false)
134
- # FileUtils.mkdir_p(dir)
135
- # config_path = File.join(dir, "ralph-opencode.config.json")
136
- #
137
- # xdg_config = ENV["XDG_CONFIG_HOME"] || File.join(ENV["HOME"] || "", ".config")
138
- # user_config_path = File.join(xdg_config, "opencode", "opencode.json")
139
- # project_config_path = File.join(Dir.pwd, ".ralph", "opencode.json")
140
- # legacy_project_config_path = File.join(Dir.pwd, ".opencode", "opencode.json")
141
- #
142
- # config = { "$schema" => "https://opencode.ai/config.json" }
143
- #
144
- # if filter_plugins
145
- # plugins = [
146
- # *load_plugins_from_config(user_config_path),
147
- # *load_plugins_from_config(project_config_path),
148
- # *load_plugins_from_config(legacy_project_config_path)
149
- # ].uniq.select { |p| p =~ /auth/i }
150
- # config["plugin"] = plugins
151
- # end
152
- #
153
- # if allow_all_permissions
154
- # config["permission"] = {
155
- # "read" => "allow",
156
- # "edit" => "allow",
157
- # "glob" => "allow",
158
- # "grep" => "allow",
159
- # "list" => "allow",
160
- # "bash" => "allow",
161
- # "task" => "allow",
162
- # "webfetch" => "allow",
163
- # "websearch" => "allow",
164
- # "codesearch" => "allow",
165
- # "todowrite" => "allow",
166
- # "todoread" => "allow",
167
- # "question" => "allow",
168
- # "lsp" => "allow",
169
- # "external_directory" => "allow"
170
- # }
171
- # end
172
- #
173
- # File.write(config_path, JSON.pretty_generate(config))
174
- # config_path
175
- # end
176
- end
177
- end
178
- end