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,178 @@
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
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ralph
6
+ module Storage
7
+ # Manages task tracking and workflow coordination.
8
+ #
9
+ # Always instantiate with Tasks.new — it represents the tasks file on disk.
10
+ # Follows the same instance-based pattern as Context and History.
11
+ class Tasks
12
+ def initialize
13
+ FileUtils.mkdir_p(dir)
14
+ end
15
+
16
+ def self.dir
17
+ File.join(Dir.pwd, ".ralph")
18
+ end
19
+
20
+ def dir = self.class.dir
21
+
22
+ def self.path
23
+ File.join(dir, "ralph-tasks.md")
24
+ end
25
+
26
+ def path = self.class.path
27
+
28
+ # --- Tasks Management ---
29
+ def load_tasks
30
+ if File.exist?(path)
31
+ content = File.read(path)
32
+ TasksCollection.parse(content)
33
+ end
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def save_tasks(tasks)
39
+ content = "# Ralph Tasks\n\n"
40
+ tasks.each do |task|
41
+ content << task.to_s << "\n"
42
+ task.subtasks.each do |subtask|
43
+ content << " #{subtask}\n"
44
+ end
45
+ end
46
+ File.write(path, content)
47
+ end
48
+
49
+ def clear_tasks
50
+ File.delete(path) if File.exist?(path)
51
+ rescue StandardError
52
+ # ignore
53
+ end
54
+
55
+ def tasks_exist?
56
+ File.exist?(path)
57
+ end
58
+
59
+ # --- Single Task Operations ---
60
+
61
+ # Add a new task by description string.
62
+ # Creates the tasks file if it doesn't exist.
63
+ def add_task(description)
64
+ tasks = load_tasks || TasksCollection.new
65
+ task = Task.new(text: description, status: :todo)
66
+ tasks.add(task)
67
+ save_tasks(tasks)
68
+ task
69
+ end
70
+
71
+ # Remove a task by 1-based index.
72
+ # Returns the removed Task.
73
+ # Raises IndexError if index is out of range.
74
+ # Raises RuntimeError if no tasks file exists.
75
+ def remove_task(index)
76
+ raise "No tasks file found" unless tasks_exist?
77
+
78
+ tasks = load_tasks
79
+ removed = tasks.remove_at(index)
80
+ save_tasks(tasks)
81
+ removed
82
+ end
83
+
84
+ # --- Task Initialization ---
85
+ def initialize_tasks_file
86
+ content = "# Ralph Tasks\n\nAdd your tasks below using: `ralph --add-task \"description\"`\n"
87
+ File.write(path, content)
88
+ path
89
+ end
90
+ end
91
+
92
+ # --- Task Models ---
93
+
94
+ # Individual task with status and subtasks
95
+ class Task
96
+ attr_accessor :text, :status, :subtasks, :original_line
97
+
98
+ def initialize(text:, status: :todo, subtasks: [], original_line: nil)
99
+ @text = text
100
+ @status = status
101
+ @subtasks = subtasks
102
+ @original_line = original_line
103
+ end
104
+
105
+ def status_char
106
+ case status
107
+ when :complete then "x"
108
+ when :in_progress then "/"
109
+ else " "
110
+ end
111
+ end
112
+
113
+ def to_s
114
+ "- [#{status_char}] #{text}"
115
+ end
116
+
117
+ def todo? = status == :todo
118
+ def in_progress? = status == :in_progress
119
+ def complete? = status == :complete
120
+
121
+ def toggle_status
122
+ case status
123
+ when :todo then @status = :in_progress
124
+ when :in_progress then @status = :complete
125
+ when :complete then @status = :todo
126
+ end
127
+ end
128
+
129
+ def mark_complete! = @status = :complete
130
+ def mark_in_progress! = @status = :in_progress
131
+ def mark_todo! = @status = :todo
132
+ end
133
+
134
+ # Collection of tasks with enumerable interface
135
+ class TasksCollection
136
+ include Enumerable
137
+
138
+ def initialize(tasks = [])
139
+ @tasks = tasks
140
+ end
141
+
142
+ def each(&block) = @tasks.each(&block)
143
+ def count(&block) = @tasks.count(&block)
144
+
145
+ def empty? = @tasks.empty?
146
+ def length = @tasks.length
147
+ def any? = @tasks.any?
148
+
149
+ # Add a task to the collection
150
+ def add(task)
151
+ @tasks << task
152
+ self
153
+ end
154
+
155
+ # Remove a task (and its subtasks) by 1-based index.
156
+ # Returns the removed Task, or raises IndexError if out of range.
157
+ def remove_at(index)
158
+ if index < 1 || index > @tasks.length
159
+ raise IndexError, "Task index #{index} is out of range (1-#{@tasks.length})"
160
+ end
161
+
162
+ @tasks.delete_at(index - 1)
163
+ end
164
+
165
+ def self.parse(content)
166
+ tasks = []
167
+ current_task = nil
168
+
169
+ content.each_line do |line|
170
+ # Top-level task: starts with "- [" at beginning (no leading whitespace)
171
+ if (match = line.match(/^- \[([ x\/])\]\s*(.+)/))
172
+ tasks << current_task if current_task
173
+ status_char = match[1]
174
+ text = match[2]
175
+ status = case status_char
176
+ when "x" then :complete
177
+ when "/" then :in_progress
178
+ else :todo
179
+ end
180
+ current_task = Task.new(text: text, status: status, subtasks: [], original_line: line.chomp)
181
+ next
182
+ end
183
+
184
+ # Subtask: starts with whitespace followed by "- ["
185
+ if (match = line.match(/^\s+- \[([ x\/])\]\s*(.+)/)) && current_task
186
+ status_char = match[1]
187
+ text = match[2]
188
+ status = case status_char
189
+ when "x" then :complete
190
+ when "/" then :in_progress
191
+ else :todo
192
+ end
193
+ current_task.subtasks << Task.new(text: text, status: status, subtasks: [], original_line: line.chomp)
194
+ end
195
+ end
196
+
197
+ tasks << current_task if current_task
198
+ new(tasks)
199
+ end
200
+
201
+ # Display tasks with numbering for CLI
202
+ def display_with_indices
203
+ if empty?
204
+ puts "No tasks found."
205
+ return
206
+ end
207
+
208
+ puts "Current tasks:"
209
+ each_with_index do |task, i|
210
+ icon = status_icon(task.status)
211
+ puts "#{i + 1}. #{icon} #{task.text}"
212
+
213
+ task.subtasks.each do |subtask|
214
+ sub_icon = status_icon(subtask.status)
215
+ puts " #{sub_icon} #{subtask.text}"
216
+ end
217
+ end
218
+ end
219
+
220
+ def current = find { |t| t.status == :in_progress }
221
+ def next = find { |t| t.status == :todo }
222
+
223
+ def all_complete?
224
+ !empty? && all? { |t| t.status == :complete }
225
+ end
226
+
227
+ def status_icon(status)
228
+ case status
229
+ when :complete then "✅"
230
+ when :in_progress then "🔄"
231
+ else "⏸️"
232
+ end
233
+ end
234
+
235
+ def self.status_icon(status)
236
+ case status
237
+ when :complete then "✅"
238
+ when :in_progress then "🔄"
239
+ else "⏸️"
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Threads
5
+ class Heartbeat
6
+ include ::Ralph::Helpers
7
+
8
+ def initialize(iteration_start, heartbeat_interval_ms, timing, mutex)
9
+ @iteration_start = iteration_start
10
+ @heartbeat_interval_ms = heartbeat_interval_ms
11
+ @timing = timing
12
+ @mutex = mutex
13
+
14
+ @thread = Thread.new { run_loop }
15
+ end
16
+
17
+ def stop
18
+ @thread&.kill
19
+ end
20
+
21
+ def join
22
+ @thread&.join
23
+ end
24
+
25
+ private
26
+
27
+ def run_loop
28
+ loop do
29
+ sleep(@heartbeat_interval_ms / 1000.0)
30
+ now_ms.then do |now|
31
+ if now - @mutex.synchronize { @timing[:last_printed_at] } >= @heartbeat_interval_ms
32
+ @mutex.synchronize do
33
+ puts "⏳ working... elapsed #{format_duration(now - @iteration_start)} · last activity #{format_duration(now - @timing[:last_activity_at])} ago"
34
+ @timing[:last_printed_at] = now_ms
35
+ end
36
+ end
37
+ end
38
+ end
39
+ rescue StandardError
40
+ # thread cleanup
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ module Threads
5
+ class StreamReader
6
+ def initialize(io, text_buffer, mutex, tool_counts, on_line, is_error, tool_parser)
7
+ @io = io
8
+ @text_buffer = text_buffer
9
+ @mutex = mutex
10
+ @tool_counts = tool_counts
11
+ @on_line = on_line
12
+ @is_error = is_error
13
+ @tool_parser = tool_parser
14
+
15
+ @thread = Thread.new { run_loop }
16
+ end
17
+
18
+ def stop
19
+ @thread&.kill
20
+ end
21
+
22
+ def join
23
+ @thread&.join
24
+ end
25
+
26
+ private
27
+
28
+ def run_loop
29
+ buffer = +""
30
+ while (chunk = @io.read(4096))
31
+ @text_buffer << chunk
32
+ buffer << chunk
33
+ while (index = buffer.index("\n"))
34
+ line = buffer.slice!(0, index + 1).chomp
35
+ tool = @tool_parser.call(line)
36
+ @mutex.synchronize { @tool_counts[tool] += 1 } if tool
37
+ @on_line.call(line, @is_error, tool) if @on_line
38
+ end
39
+ end
40
+ unless buffer.empty?
41
+ tool = @tool_parser.call(buffer)
42
+ @mutex.synchronize { @tool_counts[tool] += 1 } if tool
43
+ @on_line.call(buffer, @is_error, tool) if @on_line
44
+ end
45
+ rescue IOError
46
+ # stream closed
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ralph
4
+ VERSION = "1.2.435535439"
5
+ end
data/lib/ralph.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "optparse"
6
+ require "open3"
7
+
8
+ module Ralph
9
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠋⠉⠁⠀⡀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
10
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⢁⡠⠀⡀⠀⣠⡾⠟⣡⡶⢃⣴⡟⣰⣶⠀⣦⠰⣄⠠⣄⠀⠈⠙⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
11
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⢀⡴⢋⣴⠟⢡⣾⠋⣠⡾⠋⣴⣿⠟⢠⣿⡏⢸⣿⡄⢻⣧⠘⣷⣄⠀⢀⠀⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
12
+ # ⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⢀⡴⠋⣠⣿⠃⣴⡿⢃⣼⣿⠁⣼⣿⡏⠀⣿⣿⡇⣼⣿⣷⠀⣿⣷⡈⢻⣧⠘⣷⡀⢦⣄⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
13
+ # ⣿⣿⣿⣿⣿⣿⠏⣤⠂⣠⠞⢁⣴⡿⠁⣼⡿⠁⣼⣿⡇⣼⣿⣿⣇⣴⣿⣿⣇⣿⣿⣿⡄⢹⣿⣇⠀⣿⣧⡈⣷⡈⢻⣦⠀⢌⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
14
+ # ⣿⣿⣿⣿⡿⢁⡼⠃⣴⠏⢀⣾⡿⠀⣸⣿⡇⣸⣿⣿⣧⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣷⣿⣷⣾⣿⣷⠈⢧⣌⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
15
+ # ⣿⣿⣿⡿⠁⡾⠁⣸⡟⢀⣿⣿⡇⢠⣿⣿⣇⣿⣿⣿⣿⣿⡿⠋⣁⣀⣀⣀⣉⠹⢿⣿⣿⣿⣿⣿⣿⠟⢋⣁⣀⣤⣈⡙⢿⡇⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
16
+ # ⣿⣿⣿⣇⣤⡇⢠⣿⡇⣾⣿⣿⡁⣾⣿⣿⣿⣿⣿⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣷⡄⢻⣿⣿⣿⣿⠋⣶⣿⣿⣿⣿⣿⣿⡌⢷⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
17
+ # ⣿⣿⣿⣿⣿⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⠉⠈⣻⣿⣿⣿⡷⠘⣿⣿⣿⡷⠀⣿⣿⣿⡁⠀⣿⣿⡇⣸⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
18
+ # ⣿⣿⣿⣿⠏⣀⣀⣀⣩⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠙⣿⣦⣶⣿⣿⣿⣿⠇⢸⣿⣿⣿⣿⣆⠈⢿⣿⣿⣿⣿⡿⢡⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
19
+ # ⣿⣿⣿⡁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡈⠛⠿⠿⠟⠛⢁⣠⣿⣿⣤⣭⣉⡉⠑⢤⣈⠉⢋⣉⣴⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
20
+ # ⣿⣿⣿⣇⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣾⣿⡿⠿⢿⣿⣿⣿⣿⣦⠀⣿⣿⣿⣿⣿⣿⣿⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
21
+ # ⣿⣿⣿⣿⣷⣌⠙⠛⠛⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠉⣀⣤⣄⠈⠻⣿⠟⠋⢀⣿⣿⣿⣿⣿⣿⣿⣿⣄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
22
+ # ⣿⣿⣿⣿⣿⣿⣿⣶⡈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⡿⠋⣠⣤⣤⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
23
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠀⣿⣿⠟⢠⣿⣿⣿⡟⠁⠈⣉⡉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⢁⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
24
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⡈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⣀⠀⣿⣿⣿⣿⣷⣶⣾⣿⡿⠂⠈⠻⠿⠟⠛⠛⠛⢉⣉⣠⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
25
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⡿⢀⣾⣿⣶⣄⡈⠙⠻⢿⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣧⣤⣶⣶⣆⠀⣶⣶⠂⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
26
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⠃⠸⣿⣿⣿⣿⣿⣷⣦⣄⣀⡉⠛⠛⠛⠛⠛⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢰⠟⠁⠘⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
27
+ # ⣿⣿⣿⣿⣿⡿⠛⠁⣀⠀⠉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⡷⠀⣰⣶⣶⣦⣀⠉⠙⢿⣿⣿⣿⣿⠀⢀⣀⡀⢠⣾⡷⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
28
+ # ⣿⣿⣿⣿⡟⢀⣶⣿⣿⣿⣿⣦⡀⠙⢿⣿⣿⣿⣿⣿⠏⢀⣴⣿⣿⣿⣿⣿⣷⣦⡀⠈⢻⣿⣿⠀⢸⣿⣿⠀⠉⠀⠰⣶⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
29
+ # ⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣷⣄⣀⡉⠻⠿⠁⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠻⠟⠀⣿⣿⠇⢀⣴⣷⣄⠈⠄⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
30
+ # ⣿⣿⣿⣿⣇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢂⣀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⣦⡀⢁⣴⣿⣿⣿⣿⣆⠀⢸⣄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
31
+ # ⣿⣿⣿⣿⡿⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
32
+ # ⣿⣿⣿⠋⢠⣦⡀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿
33
+ # ⣿⣿⠃⣰⣿⣿⣧⡀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿
34
+ # ⣿⣿⠀⣿⣿⣿⣿⣿⣦⡈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⢁⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿
35
+ # ⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣦⣄⠉⠛⠻⢿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠉⢁⣠⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⡄⠘⣿⣿⣿⣿⣿⣿⣿
36
+ # ⣿⠇⠈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣤⣀⣀⣀⣀⣀⣀⣀⣠⣤⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢹⠀⠹⣿⣿⣿⣿⣿⣿
37
+ # ⡟⠀⣴⣤⣤⣀⡈⠉⠛⠛⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠈⡆⠀⢹⣿⣿⣿⣿⣿
38
+ # ⣷⠀⠈⠻⢿⣿⣿⣷⣶⣤⣄⣀⠀⠀⠉⠉⠉⠛⠻⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⠿⠛⠛⠛⠛⠉⠉⠀⠀⢿⡄⠘⣿⣿⣿⣿⣿
39
+ # ⣿⡄⢠⣤⣀⡈⠉⠛⠻⠿⢿⣿⣿⣿⣶⣶⣶⣤⣤⣤⣄⣀⣀⣀⠀⠀⠉⠉⠉⠉⠉⠉⠉⠀⠀⠀⠀⠀⢠⣤⣤⣤⣶⣶⣶⡄⠀⣶⣶⣶⣿⠀⢸⣇⠀⣿⣿⣿⣿⣿
40
+ # ⣿⣷⠀⣿⣿⣿⣿⣶⣦⣤⣀⡀⠈⠉⠉⠙⠛⠿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⣸⣿⣿⣿⣿⣿⣿⠃⠀⠟⠋⠉⠉⠀⣸⣿⠀⣿⣿⣿⣿⣿
41
+ # ⣿⣿⡄⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣦⣤⣤⣄⣀⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠛⠛⠛⠋⣁⣠⣴⣶⣶⣾⡇⠀⠉⠁⠀⣿⣿⣿⣿⣿
42
+ # ⣿⣿⣧⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣧⣀⠙⢿⣿⣿⣿
43
+ # ⣿⣿⣿⣧⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⣾⡿⣿⣿⣦⠈⣿⣿⣿
44
+ # ⣿⣿⣿⣿⣦⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⣴⣿⣄⠈⠉⢁⣴⣿⣿⣿
45
+ # ⣿⣿⣿⣿⣿⣷⡀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⡜⢹⣿⣿⠰⣿⣿⣿⣿⣿⣿
46
+ # ⣿⣿⣿⣿⣿⣿⡇⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⢸⡇⠘⠿⠇⢀⣿⣿⣿⣿⣿⣿
47
+ # ⣿⣿⣿⣿⣿⣿⣧⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⣀⣀⣴⣶⣶⣿⣿⣿⣿⣿⣿⣿
48
+ # ⣿⣿⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
49
+ # ⣿⣿⣿⣿⣿⣿⣿⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⢿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⢛⣿⣿⣿⣿⣿⣿⣿⣿⡇⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
50
+ # ⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⣈⡉⣉⣉⣉⠀⢀⣤⣤⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
51
+ # ⣿⣿⣿⣿⣿⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⢸⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
52
+ # ⣿⣿⣿⣿⣿⣿⡏⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
53
+ # ⣿⣿⣿⣿⣿⣿⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⢸⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
54
+ # ⣿⣿⣿⣿⣿⣿⠁⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠘⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠷⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
55
+ # ⣿⣿⣿⣿⣿⣧⡀⠹⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠇⠀⢹⣿⣿⣿⡿⠀⣀⠈⠛⠛⠛⠛⠿⠿⠿⠿⠟⠛⠛⠉⢀⣀⣀⡀⠉⠙⠛⠿⠿⣿⣿⣿⣿⣿
56
+ # ⣿⣿⣿⣿⣿⡟⠁⣤⣄⡀⠈⠉⠙⠛⠛⠛⠛⠛⠛⠛⠉⢉⣀⣀⣀⣀⣀⠉⠻⠿⠇⢸⣿⣿⣶⣶⣦⣤⣤⣤⣤⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣷⣶⣄⣈⠙⠻⣿⣿
57
+ # ⣿⣿⣿⣿⣿⠁⠸⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⡀⠈⠙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠈⢻
58
+ # ⣿⣿⣿⣿⣿⣶⣤⣈⡉⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣤⠀⠈⠉⠉⠛⠛⠛⠛⠻⠿⠿⠿⠿⢿⣿⣿⣿⣿⣿⠿⠿⠿⠟⠋⢀⣸
59
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⣄⣈⠉⠛⠛⠿⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠘⣿⣿⣿⣿⣿⣶⣶⣦⣤⣤⣤⣤⣀⣀⣀⣤⣤⣤⣤⣴⣾⣿⣿
60
+ # ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣤⣤⣄⣀⣀⣈⣉⠉⠉⠉⠉⠙⠛⠛⠛⠛⠛⠉⠉⣀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
61
+ end
62
+
63
+ # Helpers must load first since other modules extend it at parse time
64
+ require_relative "ralph/helpers"
65
+
66
+ # Require everything else by globbing, because I'm too lazy to do anything else
67
+ Dir[File.join(__dir__, "ralph", "**", "*.rb")].sort.each { |f| require f }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync } = require("node:child_process");
3
+ const { resolve } = require("node:path");
4
+
5
+ const scriptPath = resolve(__dirname, "..", "ralph.ts");
6
+ const result = spawnSync("bun", [scriptPath, ...process.argv.slice(2)], { stdio: "inherit" });
7
+
8
+ if (result.error) {
9
+ console.error("Error: Bun is required to run ralph. Install Bun: https://bun.sh");
10
+ process.exit(1);
11
+ }
12
+
13
+ process.exit(result.status ?? 1);