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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ralph.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4355354345
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-02 00:00:00.000000000 Z
11
+ date: 2026-02-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -68,58 +68,42 @@ files:
68
68
  - ".ruby-version"
69
69
  - AGENTS.md
70
70
  - Gemfile
71
+ - Gemfile.lock
71
72
  - LICENSE
72
73
  - README.md
73
74
  - bin/rubocop
74
75
  - bin/test
75
76
  - exe/ralph
76
77
  - lib/ralph.rb
77
- - lib/ralph/agents.rb
78
- - lib/ralph/agents/base.rb
79
- - lib/ralph/agents/claude_code.rb
80
- - lib/ralph/agents/codex.rb
81
- - lib/ralph/agents/open_code.rb
82
78
  - lib/ralph/cli.rb
83
- - lib/ralph/config.rb
84
- - lib/ralph/git/file_snapshot.rb
85
- - lib/ralph/helpers.rb
86
- - lib/ralph/iteration.rb
79
+ - lib/ralph/display.rb
80
+ - lib/ralph/events.rb
87
81
  - lib/ralph/loop.rb
88
- - lib/ralph/output/active_loop_error.rb
89
- - lib/ralph/output/banner.rb
90
- - lib/ralph/output/completion_deferred.rb
91
- - lib/ralph/output/completion_detected.rb
92
- - lib/ralph/output/config_summary.rb
93
- - lib/ralph/output/context_consumed.rb
94
- - lib/ralph/output/iteration.rb
95
- - lib/ralph/output/max_iterations_reached.rb
96
- - lib/ralph/output/no_plugin_warning.rb
97
- - lib/ralph/output/nonzero_exit_warning.rb
98
- - lib/ralph/output/plugin_error.rb
99
- - lib/ralph/output/status.rb
100
- - lib/ralph/output/struggle_warning.rb
101
- - lib/ralph/output/task_completion.rb
102
- - lib/ralph/output/tasks_file_created.rb
103
- - lib/ralph/prompt_template.rb
104
- - lib/ralph/storage/context.rb
105
- - lib/ralph/storage/history.rb
106
- - lib/ralph/storage/state.rb
107
- - lib/ralph/storage/tasks.rb
108
- - lib/ralph/threads/heartbeat.rb
109
- - lib/ralph/threads/stream_reader.rb
82
+ - lib/ralph/metrics.rb
83
+ - lib/ralph/opencode.rb
110
84
  - lib/ralph/version.rb
111
- - original/bin/ralph.js
112
- - original/ralph.ts
85
+ - plans/00-complete-implementation.md
86
+ - plans/01-cli-implementation.md
87
+ - plans/02-loop-implementation.md
88
+ - plans/03-agents-implementation.md
89
+ - plans/04-metrics-implementation.md
90
+ - plans/README.md
113
91
  - ralph.gemspec
114
92
  - ralph2.gemspec
115
93
  - screenshot.webp
116
94
  - specs/README.md
95
+ - specs/__templates__/API_TEMPLATE.md
96
+ - specs/__templates__/AUTOMATION_ACTION_TEMPLATE.md
97
+ - specs/__templates__/AUTOMATION_TRIGGER_TEMPLATE.md
98
+ - specs/__templates__/CONTROLLER_TEMPLATE.md
99
+ - specs/__templates__/INTEGRATION_TEMPLATE.md
100
+ - specs/__templates__/MODEL_TEMPLATE.md
117
101
  - specs/agents.md
118
102
  - specs/cli.md
119
- - specs/iteration.md
120
- - specs/output.md
121
- - specs/storage/local-data-structure.md
122
- - specs/tasks.md
103
+ - specs/lib/todo_item.rb
104
+ - specs/log
105
+ - specs/loop.md
106
+ - specs/metrics.md
123
107
  homepage: https://github.com/n-at-han-k/ralph.rb
124
108
  licenses:
125
109
  - MIT
@@ -1,132 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,24 +0,0 @@
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/config.rb DELETED
@@ -1,40 +0,0 @@
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
@@ -1,60 +0,0 @@
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
data/lib/ralph/helpers.rb DELETED
@@ -1,76 +0,0 @@
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