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.
- checksums.yaml +7 -0
- data/.github/workflows/gem-push.yml +47 -0
- data/.gitignore +79 -0
- data/.rubocop.yml +6018 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +113 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +656 -0
- data/bin/rubocop +8 -0
- data/bin/test +5 -0
- data/exe/ralph +8 -0
- data/lib/ralph/agents/base.rb +132 -0
- data/lib/ralph/agents/claude_code.rb +24 -0
- data/lib/ralph/agents/codex.rb +25 -0
- data/lib/ralph/agents/open_code.rb +30 -0
- data/lib/ralph/agents.rb +24 -0
- data/lib/ralph/cli.rb +222 -0
- data/lib/ralph/config.rb +40 -0
- data/lib/ralph/git/file_snapshot.rb +60 -0
- data/lib/ralph/helpers.rb +76 -0
- data/lib/ralph/iteration.rb +220 -0
- data/lib/ralph/loop.rb +196 -0
- data/lib/ralph/output/active_loop_error.rb +13 -0
- data/lib/ralph/output/banner.rb +29 -0
- data/lib/ralph/output/completion_deferred.rb +12 -0
- data/lib/ralph/output/completion_detected.rb +17 -0
- data/lib/ralph/output/config_summary.rb +31 -0
- data/lib/ralph/output/context_consumed.rb +11 -0
- data/lib/ralph/output/iteration.rb +45 -0
- data/lib/ralph/output/max_iterations_reached.rb +16 -0
- data/lib/ralph/output/no_plugin_warning.rb +14 -0
- data/lib/ralph/output/nonzero_exit_warning.rb +11 -0
- data/lib/ralph/output/plugin_error.rb +12 -0
- data/lib/ralph/output/status.rb +176 -0
- data/lib/ralph/output/struggle_warning.rb +18 -0
- data/lib/ralph/output/task_completion.rb +12 -0
- data/lib/ralph/output/tasks_file_created.rb +11 -0
- data/lib/ralph/prompt_template.rb +183 -0
- data/lib/ralph/storage/context.rb +58 -0
- data/lib/ralph/storage/history.rb +117 -0
- data/lib/ralph/storage/state.rb +178 -0
- data/lib/ralph/storage/tasks.rb +244 -0
- data/lib/ralph/threads/heartbeat.rb +44 -0
- data/lib/ralph/threads/stream_reader.rb +50 -0
- data/lib/ralph/version.rb +5 -0
- data/lib/ralph.rb +67 -0
- data/original/bin/ralph.js +13 -0
- data/original/ralph.ts +1706 -0
- data/ralph.gemspec +35 -0
- data/ralph2.gemspec +35 -0
- data/screenshot.webp +0 -0
- data/specs/README.md +46 -0
- data/specs/agents.md +172 -0
- data/specs/cli.md +223 -0
- data/specs/iteration.md +173 -0
- data/specs/output.md +104 -0
- data/specs/storage/local-data-structure.md +246 -0
- data/specs/tasks.md +295 -0
- metadata +150 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ralph
|
|
6
|
+
module Agents
|
|
7
|
+
class Base
|
|
8
|
+
include ::Ralph::Helpers
|
|
9
|
+
|
|
10
|
+
# Result of executing an agent process
|
|
11
|
+
ExecutionResult = Struct.new(:stdout_text, :stderr_text, :tool_counts, :exit_code, keyword_init: true) do
|
|
12
|
+
def combined_output
|
|
13
|
+
"#{stdout_text}\n#{stderr_text}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def type = raise NotImplementedError
|
|
18
|
+
def command = raise NotImplementedError
|
|
19
|
+
def config_name = raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def parse_tool_output(_line) = raise NotImplementedError
|
|
22
|
+
def build_args(_prompt, _model, _options) = raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
def build_env(_options) = ENV.to_h.dup
|
|
25
|
+
|
|
26
|
+
def execute(prompt, options = {})
|
|
27
|
+
command_args = build_args(prompt, options[:model],
|
|
28
|
+
{ allow_all_permissions: options[:allow_all_permissions] })
|
|
29
|
+
environment = build_env(
|
|
30
|
+
filter_plugins: options[:disable_plugins],
|
|
31
|
+
allow_all_permissions: options[:allow_all_permissions]
|
|
32
|
+
)
|
|
33
|
+
full_command = [command] + command_args
|
|
34
|
+
|
|
35
|
+
if options[:stream_output]
|
|
36
|
+
execute_streaming(environment, full_command, options[:on_line])
|
|
37
|
+
else
|
|
38
|
+
execute_captured(environment, full_command)
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => agent_error
|
|
41
|
+
Agents::Base::ExecutionResult.new(
|
|
42
|
+
stdout_text: "", stderr_text: agent_error.to_s, tool_counts: {}, exit_code: -1
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Collects tool usage counts from output text.
|
|
47
|
+
# Delegates to parse_tool_output for each line.
|
|
48
|
+
def collect_tool_counts(text)
|
|
49
|
+
Hash.new(0).tap do |counts|
|
|
50
|
+
text.each_line do |line|
|
|
51
|
+
tool = parse_tool_output(line)
|
|
52
|
+
counts[tool] += 1 if tool
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns a fatal error message if one is detected in the output,
|
|
58
|
+
# or nil if no fatal error is found. Subclasses may override.
|
|
59
|
+
def detect_fatal_error(_output) = nil
|
|
60
|
+
|
|
61
|
+
# Extracts error patterns from agent output. Subclasses may override
|
|
62
|
+
# for agent-specific error formats.
|
|
63
|
+
def extract_errors(output)
|
|
64
|
+
errors = []
|
|
65
|
+
output.each_line do |line|
|
|
66
|
+
lower = line.downcase
|
|
67
|
+
if lower.include?("error:") ||
|
|
68
|
+
lower.include?("failed:") ||
|
|
69
|
+
lower.include?("exception:") ||
|
|
70
|
+
lower.include?("typeerror") ||
|
|
71
|
+
lower.include?("syntaxerror") ||
|
|
72
|
+
lower.include?("referenceerror") ||
|
|
73
|
+
(lower.include?("test") && lower.include?("fail"))
|
|
74
|
+
cleaned = line.strip[0, 200]
|
|
75
|
+
errors << cleaned if cleaned && !cleaned.empty? && !errors.include?(cleaned)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
errors.first(10)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate!
|
|
82
|
+
path = which(command)
|
|
83
|
+
unless path
|
|
84
|
+
$stderr.puts "Error: #{config_name} CLI ('#{command}') not found."
|
|
85
|
+
exit 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def execute_streaming(environment, full_command, on_line)
|
|
92
|
+
tool_counts = Hash.new(0)
|
|
93
|
+
stdout_text = +""
|
|
94
|
+
stderr_text = +""
|
|
95
|
+
mutex = Mutex.new
|
|
96
|
+
|
|
97
|
+
stdin, stdout, stderr, wait_thread = Open3.popen3(environment, *full_command)
|
|
98
|
+
stdin.close
|
|
99
|
+
|
|
100
|
+
stdout_reader = Threads::StreamReader.new(
|
|
101
|
+
stdout, stdout_text, mutex, tool_counts, on_line, false, method(:parse_tool_output)
|
|
102
|
+
)
|
|
103
|
+
stderr_reader = Threads::StreamReader.new(
|
|
104
|
+
stderr, stderr_text, mutex, tool_counts, on_line, true, method(:parse_tool_output)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
stdout_reader.join
|
|
108
|
+
stderr_reader.join
|
|
109
|
+
exit_status = wait_thread.value
|
|
110
|
+
|
|
111
|
+
ExecutionResult.new(
|
|
112
|
+
stdout_text: stdout_text,
|
|
113
|
+
stderr_text: stderr_text,
|
|
114
|
+
tool_counts: tool_counts,
|
|
115
|
+
exit_code: exit_status.exitstatus || 1
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def execute_captured(environment, full_command)
|
|
120
|
+
stdout, stderr, status = Open3.capture3(environment, *full_command, stdin_data: "")
|
|
121
|
+
tool_counts = collect_tool_counts("#{stdout}\n#{stderr}")
|
|
122
|
+
|
|
123
|
+
ExecutionResult.new(
|
|
124
|
+
stdout_text: stdout,
|
|
125
|
+
stderr_text: stderr,
|
|
126
|
+
tool_counts: tool_counts,
|
|
127
|
+
exit_code: status.exitstatus || 1
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
module Agents
|
|
5
|
+
class ClaudeCode < Base
|
|
6
|
+
|
|
7
|
+
def type = :claude_code
|
|
8
|
+
def command = "claude"
|
|
9
|
+
def config_name = "Claude Code"
|
|
10
|
+
|
|
11
|
+
def build_args(prompt, model, options)
|
|
12
|
+
args = ["-p", prompt]
|
|
13
|
+
args.push("--model", model) if model && !model.empty?
|
|
14
|
+
args.push("--dangerously-skip-permissions") if options && options[:allow_all_permissions]
|
|
15
|
+
args
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_tool_output(line)
|
|
19
|
+
match = strip_ansi(line).match(/(?:Using|Called|Tool:)\s+([A-Za-z0-9_-]+)/i)
|
|
20
|
+
match ? match[1] : nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
module Agents
|
|
5
|
+
class Codex < Base
|
|
6
|
+
|
|
7
|
+
def type = :codex
|
|
8
|
+
def command = "codex"
|
|
9
|
+
def config_name = "Codex"
|
|
10
|
+
|
|
11
|
+
def build_args(prompt, model, options)
|
|
12
|
+
args = ["exec"]
|
|
13
|
+
args.push("--model", model) if model && !model.empty?
|
|
14
|
+
args.push("--full-auto") if options && options[:allow_all_permissions]
|
|
15
|
+
args.push(prompt)
|
|
16
|
+
args
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parse_tool_output(line)
|
|
20
|
+
match = strip_ansi(line).match(/(?:Tool:|Using|Calling|Running)\s+([A-Za-z0-9_-]+)/i)
|
|
21
|
+
match ? match[1] : nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
module Agents
|
|
5
|
+
class OpenCode < Base
|
|
6
|
+
|
|
7
|
+
def type = :opencode
|
|
8
|
+
def command = "opencode"
|
|
9
|
+
def config_name = "OpenCode"
|
|
10
|
+
|
|
11
|
+
def build_args(prompt, model, _options)
|
|
12
|
+
args = ["run"]
|
|
13
|
+
args.push("-m", model) if model && !model.empty?
|
|
14
|
+
args.push(prompt)
|
|
15
|
+
args
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_tool_output(line)
|
|
19
|
+
match = strip_ansi(line).match(/^\|\s{2}([A-Za-z0-9_-]+)/)
|
|
20
|
+
match ? match[1] : nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def detect_fatal_error(output)
|
|
24
|
+
if output.include?("ralph-wiggum is not yet ready for use. This is a placeholder package.")
|
|
25
|
+
"Placeholder plugin error detected"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/ralph/agents.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Ralph
|
|
2
|
+
module Agents
|
|
3
|
+
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
AGENT_NAME_MAP = {
|
|
7
|
+
"opencode" => :opencode,
|
|
8
|
+
"claude-code" => :claude_code,
|
|
9
|
+
"codex" => :codex
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def valid_agent_names = AGENT_NAME_MAP.keys
|
|
13
|
+
|
|
14
|
+
def resolve(name_str)
|
|
15
|
+
AGENT_NAME_MAP[name_str].then do |sym|
|
|
16
|
+
case sym
|
|
17
|
+
when :opencode then OpenCode.new
|
|
18
|
+
when :claude_code then ClaudeCode.new
|
|
19
|
+
when :codex then Codex.new
|
|
20
|
+
else nil end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/ralph/cli.rb
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
class CLI
|
|
5
|
+
def initialize(argv = ARGV)
|
|
6
|
+
@config = Config.new
|
|
7
|
+
|
|
8
|
+
@parser = OptionParser.new do |o|
|
|
9
|
+
o.banner = <<~BANNER
|
|
10
|
+
Ralph Wiggum Loop - Iterative AI development with AI agents
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
ralph "<prompt>" [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
--status Show current Ralph loop status and history
|
|
17
|
+
--add-context TEXT Add context for the next iteration
|
|
18
|
+
--clear-context Clear any pending context
|
|
19
|
+
--list-tasks Display the current task list with indices
|
|
20
|
+
--add-task "desc" Add a new task to the list
|
|
21
|
+
--remove-task N Remove task at index N (including subtasks)
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
BANNER
|
|
25
|
+
|
|
26
|
+
o.on("--agent AGENT", Agents.valid_agent_names, "AI agent: #{Agents.valid_agent_names.join(', ')} (default: opencode)") do |v|
|
|
27
|
+
@config.chosen_agent = v
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
o.on("--min-iterations N", Integer, "Minimum iterations before completion (default: 1)") do |v|
|
|
31
|
+
@config.min_iterations = v
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
o.on("--max-iterations N", Integer, "Maximum iterations before stopping (default: unlimited)") do |v|
|
|
35
|
+
@config.max_iterations = v
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
o.on("--completion-promise TEXT", "Phrase that signals completion (default: COMPLETE)") do |v|
|
|
39
|
+
@config.completion_promise = v
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
o.on("-t", "--tasks", "Enable Tasks Mode for structured task tracking") do
|
|
43
|
+
@config.tasks_mode = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
o.on("--task-promise TEXT", "Phrase that signals task completion (default: READY_FOR_NEXT_TASK)") do |v|
|
|
47
|
+
@config.task_promise = v
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
o.on("--model MODEL", "Model to use (agent-specific)") do |v|
|
|
51
|
+
@config.model = v
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
o.on("--[no-]stream", "Stream agent output in real-time (default: on)") do |v|
|
|
55
|
+
@config.stream_output = v
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
o.on("--verbose-tools", "Print every tool line (disable compact summary)") do
|
|
59
|
+
@config.verbose_tools = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
o.on("--no-plugins", "Disable non-auth OpenCode plugins (opencode only)") do
|
|
63
|
+
@config.disable_plugins = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
o.on("--[no-]allow-all", "Auto-approve all tool permissions (default: on)") do |v|
|
|
67
|
+
@config.allow_all_permissions = v
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Subcommands -- these set a command to dispatch after parsing
|
|
71
|
+
o.on("-v", "--version", "Show version") do
|
|
72
|
+
puts "ralph #{VERSION}"
|
|
73
|
+
exit 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
o.on("--status", "Show current loop status and history") do
|
|
77
|
+
Output::Status.call(options: @config.to_h)
|
|
78
|
+
exit 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
o.on("--add-context TEXT", "Add context for the next iteration") do |context_text|
|
|
82
|
+
|
|
83
|
+
Storage::Context.new.append(
|
|
84
|
+
"\n## Context added at #{Time.now.utc.iso8601}\n#{context_text}\n"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
puts "✅ Context added for next iteration"
|
|
88
|
+
puts " File: #{Storage::Context.new.path}"
|
|
89
|
+
|
|
90
|
+
Storage::State.load.then do |state|
|
|
91
|
+
if state&.active
|
|
92
|
+
puts " Will be picked up in iteration #{state.iteration + 1}"
|
|
93
|
+
else
|
|
94
|
+
puts " Will be used when loop starts"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
exit 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
o.on("--clear-context", "Clear any pending context") do
|
|
102
|
+
Storage::Context.new.then do |context|
|
|
103
|
+
if context.present?
|
|
104
|
+
context.clear
|
|
105
|
+
puts "✅ Context cleared"
|
|
106
|
+
else
|
|
107
|
+
puts "ℹ️ No pending context to clear"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
exit 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
o.on("--list-tasks", "Display the current task list") do
|
|
115
|
+
begin
|
|
116
|
+
Storage::Tasks.new.load_tasks.then do |tasks|
|
|
117
|
+
if tasks
|
|
118
|
+
tasks.display_with_indices
|
|
119
|
+
else
|
|
120
|
+
puts "No tasks file found. Use --add-task to create your first task."
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
$stderr.puts "Error reading tasks file: #{e}"
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
exit 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
o.on("--add-task DESC", "Add a new task to the list") do |description|
|
|
132
|
+
begin
|
|
133
|
+
Storage::Tasks.new.add_task(description)
|
|
134
|
+
puts "✅ Task added: \"#{description}\""
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
$stderr.puts "Error adding task: #{e}"
|
|
137
|
+
exit 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
exit 0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
o.on("--remove-task N", Integer, "Remove task at index N") do |task_index|
|
|
144
|
+
begin
|
|
145
|
+
Storage::Tasks.new.remove_task(task_index)
|
|
146
|
+
puts "✅ Removed task #{task_index} and its subtasks"
|
|
147
|
+
rescue IndexError => e
|
|
148
|
+
$stderr.puts "Error: #{e.message}"
|
|
149
|
+
exit 1
|
|
150
|
+
rescue RuntimeError => e
|
|
151
|
+
$stderr.puts "Error: #{e.message}"
|
|
152
|
+
exit 1
|
|
153
|
+
rescue StandardError => e
|
|
154
|
+
$stderr.puts "Error removing task: #{e}"
|
|
155
|
+
exit 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
exit 0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
o.separator ""
|
|
162
|
+
o.separator "Examples:"
|
|
163
|
+
o.separator ' ralph "Build a REST API for todos"'
|
|
164
|
+
o.separator ' ralph "Fix the auth bug" --max-iterations 10'
|
|
165
|
+
o.separator ' ralph "Add tests" --completion-promise "ALL TESTS PASS" --model openai/gpt-5.1'
|
|
166
|
+
o.separator ' ralph --status'
|
|
167
|
+
o.separator ' ralph --add-context "Focus on the auth module first"'
|
|
168
|
+
o.separator ""
|
|
169
|
+
o.separator "How it works:"
|
|
170
|
+
o.separator " 1. Sends your prompt to the selected AI agent"
|
|
171
|
+
o.separator " 2. AI agent works on the task"
|
|
172
|
+
o.separator " 3. Checks output for completion promise"
|
|
173
|
+
o.separator " 4. If not complete, repeats with same prompt"
|
|
174
|
+
o.separator " 5. AI sees its previous work in files"
|
|
175
|
+
o.separator " 6. Continues until promise detected or max iterations"
|
|
176
|
+
o.separator ""
|
|
177
|
+
o.separator "To stop manually: Ctrl+C"
|
|
178
|
+
o.separator "Learn more: https://ghuntley.com/ralph/"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def run(argv = ARGV.dup)
|
|
184
|
+
@user_prompt = @parser.parse(argv).then do |remaining_args|
|
|
185
|
+
if $stdin.tty?
|
|
186
|
+
remaining_args.join(" ").strip
|
|
187
|
+
else
|
|
188
|
+
[$stdin.read, remaining_args.join(" ")].join("\n").strip
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if @user_prompt.empty?
|
|
193
|
+
abort "
|
|
194
|
+
Error: No prompt provided
|
|
195
|
+
Usage: ralph 'Your task description' [options]
|
|
196
|
+
Run 'ralph --help' for more information
|
|
197
|
+
"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
tasks = Storage::Tasks.new
|
|
201
|
+
context = Storage::Context.new
|
|
202
|
+
|
|
203
|
+
PromptTemplate.inject(@user_prompt, context: context, tasks: tasks).then do |prompt|
|
|
204
|
+
@config.prompt = prompt
|
|
205
|
+
|
|
206
|
+
if @config.max_iterations > 0 && @config.min_iterations > @config.max_iterations
|
|
207
|
+
abort "Error: --min-iterations (#{@config.min_iterations}) cannot be greater than --max-iterations (#{@config.max_iterations})"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
state = Storage::State.from_config(@config, prompt: prompt)
|
|
211
|
+
history = Storage::History.new
|
|
212
|
+
|
|
213
|
+
Ralph::Loop.new(@config, state, history, context, tasks).run
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
$stderr.puts "Fatal error: #{e}"
|
|
218
|
+
Storage::State.clear
|
|
219
|
+
exit 1
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/ralph/config.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Ralph
|
|
2
|
+
class Config
|
|
3
|
+
# Single source of truth for config option names and their defaults.
|
|
4
|
+
# Use a lambda for mutable defaults to avoid shared state.
|
|
5
|
+
OPTIONS = {
|
|
6
|
+
prompt: -> { PromptTemplate.new("") },
|
|
7
|
+
min_iterations: 1,
|
|
8
|
+
max_iterations: 0,
|
|
9
|
+
completion_promise: "COMPLETE",
|
|
10
|
+
tasks_mode: false,
|
|
11
|
+
task_promise: "READY_FOR_NEXT_TASK",
|
|
12
|
+
model: "",
|
|
13
|
+
chosen_agent: "opencode",
|
|
14
|
+
disable_plugins: false,
|
|
15
|
+
allow_all_permissions: true,
|
|
16
|
+
stream_output: true,
|
|
17
|
+
verbose_tools: false,
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_accessor(*OPTIONS.keys)
|
|
21
|
+
|
|
22
|
+
# Runtime state (not constructor args, not in to_h).
|
|
23
|
+
attr_accessor :current_pid, :stopping
|
|
24
|
+
|
|
25
|
+
def initialize(**opts)
|
|
26
|
+
OPTIONS.each do |key, default|
|
|
27
|
+
value = opts.fetch(key) { default.respond_to?(:call) ? default.call : default }
|
|
28
|
+
instance_variable_set(:"@#{key}", value)
|
|
29
|
+
end
|
|
30
|
+
@current_pid = nil
|
|
31
|
+
@stopping = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns a hash of options suitable for passing to Loop#call.
|
|
35
|
+
# Excludes runtime state (current_pid, stopping).
|
|
36
|
+
def to_h
|
|
37
|
+
OPTIONS.keys.map { |k| [k, send(k)] }.to_h
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ralph
|
|
6
|
+
module Git
|
|
7
|
+
# Captures and compares file state via git for change detection
|
|
8
|
+
class FileSnapshot
|
|
9
|
+
attr_reader :files
|
|
10
|
+
|
|
11
|
+
def initialize(files)
|
|
12
|
+
@files = files
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Capture a snapshot of all tracked/modified files and their git hashes
|
|
16
|
+
def self.capture
|
|
17
|
+
files = {}
|
|
18
|
+
begin
|
|
19
|
+
status, _, _ = Open3.capture3("git", "status", "--porcelain")
|
|
20
|
+
tracked, _, _ = Open3.capture3("git", "ls-files")
|
|
21
|
+
|
|
22
|
+
all_files = Set.new
|
|
23
|
+
status.strip.each_line do |line|
|
|
24
|
+
name = line[3..]&.strip
|
|
25
|
+
all_files.add(name) if name && !name.empty?
|
|
26
|
+
end
|
|
27
|
+
tracked.strip.each_line do |file|
|
|
28
|
+
f = file.strip
|
|
29
|
+
all_files.add(f) unless f.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
all_files.each do |file|
|
|
33
|
+
begin
|
|
34
|
+
hash, _, _ = Open3.capture3("git", "hash-object", file)
|
|
35
|
+
files[file] = hash.strip unless hash.strip.empty?
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# skip
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError
|
|
41
|
+
# git not available
|
|
42
|
+
end
|
|
43
|
+
new(files)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Return list of files that changed between this snapshot and a later one
|
|
47
|
+
def modified_since(other)
|
|
48
|
+
Array.new.tap do |changed|
|
|
49
|
+
other.files.each do |file, hash|
|
|
50
|
+
changed << file if files[file] != hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
files.each_key do |file|
|
|
54
|
+
changed << file unless other.files.key?(file)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ralph
|
|
4
|
+
module Helpers
|
|
5
|
+
def strip_ansi(input)
|
|
6
|
+
input.gsub(/\x1B\[[0-9;]*m/, "")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def escape_regex(str)
|
|
10
|
+
Regexp.escape(str)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format_duration(ms)
|
|
14
|
+
total_seconds = [0, (ms / 1000).floor].max
|
|
15
|
+
hours = total_seconds / 3600
|
|
16
|
+
minutes = (total_seconds % 3600) / 60
|
|
17
|
+
seconds = total_seconds % 60
|
|
18
|
+
|
|
19
|
+
if hours > 0
|
|
20
|
+
"#{hours}:#{minutes.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}"
|
|
21
|
+
else
|
|
22
|
+
"#{minutes}:#{seconds.to_s.rjust(2, '0')}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def format_duration_long(ms)
|
|
27
|
+
total_seconds = [0, (ms / 1000).floor].max
|
|
28
|
+
hours = total_seconds / 3600
|
|
29
|
+
minutes = (total_seconds % 3600) / 60
|
|
30
|
+
seconds = total_seconds % 60
|
|
31
|
+
|
|
32
|
+
if hours > 0
|
|
33
|
+
"#{hours}h #{minutes}m #{seconds}s"
|
|
34
|
+
elsif minutes > 0
|
|
35
|
+
"#{minutes}m #{seconds}s"
|
|
36
|
+
else
|
|
37
|
+
"#{seconds}s"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def format_tool_summary(tool_counts, max_items = 6)
|
|
42
|
+
if tool_counts.empty?
|
|
43
|
+
""
|
|
44
|
+
else
|
|
45
|
+
entries = tool_counts.sort_by { |_, v| -v }
|
|
46
|
+
shown = entries.first(max_items)
|
|
47
|
+
remaining = entries.length - shown.length
|
|
48
|
+
|
|
49
|
+
shown.map { |name, count| "#{name} #{count}" }
|
|
50
|
+
.tap { |parts| parts << "+#{remaining} more" if remaining > 0 }
|
|
51
|
+
.then { |parts| parts.join(" • ") }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_completion(output, promise)
|
|
56
|
+
pattern = /<promise>\s*#{escape_regex(promise)}\s*<\/promise>/i
|
|
57
|
+
output.match?(pattern)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Cross-platform which
|
|
61
|
+
def which(cmd)
|
|
62
|
+
exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
|
|
63
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
|
|
64
|
+
exts.each do |ext|
|
|
65
|
+
exe = File.join(path, "#{cmd}#{ext}")
|
|
66
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def now_ms
|
|
73
|
+
(Time.now.to_f * 1000).to_i
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|