rralph 0.1.3 → 0.2.1
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 +4 -4
- data/README.md +33 -6
- data/bin/rralph +1 -1
- data/lib/rralph/cli.rb +43 -37
- data/lib/rralph/file_updater.rb +8 -8
- data/lib/rralph/git.rb +6 -12
- data/lib/rralph/parser.rb +42 -22
- data/lib/rralph/runner.rb +135 -78
- data/lib/rralph.rb +6 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e6f657f4d4105579bcc44c846bb182d9f6637fc6a530fdf01490c51b994cf0e
|
|
4
|
+
data.tar.gz: b4ad22a14bb0de01d4ba50399fe2e4ed867a40ecc274c9a865ae8c948e8dcfa0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 597dbd3a4bd200b39ee9bcc7ae8cbe7872826dd90e345b7c24a8b7a92623ddfeeb96d225ea8341ee68f8ca948264ef0dc206c61d66225cabdf54020d41f5e28a
|
|
7
|
+
data.tar.gz: 7915ff47830f6cf13a54d0b2d8eb9086c1fb773964eb47dcbc493bda21416a6da3e986f4b985486c833892f3f6c7d3224caf95a73354e6523d032024e0f57447
|
data/README.md
CHANGED
|
@@ -111,6 +111,8 @@ Options:
|
|
|
111
111
|
# Default: todo.md
|
|
112
112
|
-s, [--skip-commit], [--no-skip-commit] # Skip git commits between tasks
|
|
113
113
|
# Default: false
|
|
114
|
+
-v, [--verbose], [--no-verbose] # Enable verbose logging with AI thinking and real-time output
|
|
115
|
+
# Default: false
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
### Examples
|
|
@@ -153,7 +155,7 @@ Learnings: 6 lines
|
|
|
153
155
|
1. **Read** — `rralph` reads `plan.md`, `learnings.md`, and `todo.md`
|
|
154
156
|
2. **Prompt** — Builds a prompt with file contents and sends to LLM
|
|
155
157
|
3. **Parse** — Analyzes AI response for:
|
|
156
|
-
- `
|
|
158
|
+
- `TASK_FAILURE` keyword (case-sensitive, whole word)
|
|
157
159
|
- New learnings to extract
|
|
158
160
|
4. **Update** — On success:
|
|
159
161
|
- Marks current task as complete in `todo.md`
|
|
@@ -163,7 +165,7 @@ Learnings: 6 lines
|
|
|
163
165
|
|
|
164
166
|
### Failure Handling
|
|
165
167
|
|
|
166
|
-
- Each `
|
|
168
|
+
- Each `TASK_FAILURE` response increments a counter
|
|
167
169
|
- Non-failure responses reset the counter to 0
|
|
168
170
|
- When max failures reached, `rralph` exits with error:
|
|
169
171
|
- ```
|
|
@@ -172,14 +174,39 @@ Learnings: 6 lines
|
|
|
172
174
|
|
|
173
175
|
### Logging
|
|
174
176
|
|
|
175
|
-
|
|
177
|
+
By default, `rralph` outputs concise progress logs to stderr:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
Starting rralph with max_failures=3, ai_command='qwen-code -y -s'
|
|
181
|
+
Cycle 1: Processing task: Create odd_even.sh file with bash shebang
|
|
182
|
+
Executing AI command...
|
|
183
|
+
Completed in 2341ms (13407 tokens)
|
|
184
|
+
[Cycle 1] Task completed. 0 failures. Git commit: abc123
|
|
185
|
+
Log saved: logs/cycle_1_20260302_145738.md
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Use `--verbose` mode for detailed real-time logging including AI thinking:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
rralph start --verbose
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Verbose output shows:
|
|
195
|
+
- Thinking: AI's thought process as it thinks
|
|
196
|
+
- Real-time text output from the AI
|
|
197
|
+
- Completion metrics (duration, token usage)
|
|
198
|
+
|
|
199
|
+
Example verbose output:
|
|
176
200
|
|
|
177
201
|
```
|
|
178
|
-
|
|
179
|
-
|
|
202
|
+
Executing AI command...
|
|
203
|
+
Thinking: The user wants me to create a bash script for checking even/odd numbers
|
|
204
|
+
Thinking: I need to start with the basic file structure and shebang
|
|
205
|
+
I'll create the odd_even.sh file with a proper bash shebang.
|
|
206
|
+
Completed in 1847ms (8234 tokens)
|
|
180
207
|
```
|
|
181
208
|
|
|
182
|
-
In `--watch` mode, AI responses are saved to `logs/` for audit trail.
|
|
209
|
+
In `--watch` mode, full AI responses are saved to `logs/` for audit trail.
|
|
183
210
|
|
|
184
211
|
## License
|
|
185
212
|
|
data/bin/rralph
CHANGED
data/lib/rralph/cli.rb
CHANGED
|
@@ -1,44 +1,49 @@
|
|
|
1
|
-
require
|
|
2
|
-
require_relative
|
|
1
|
+
require 'thor'
|
|
2
|
+
require_relative 'runner'
|
|
3
3
|
|
|
4
4
|
module Rralph
|
|
5
5
|
class CLI < Thor
|
|
6
|
-
desc
|
|
6
|
+
desc 'start', 'Run the rralph orchestrator'
|
|
7
7
|
method_option :max_failures,
|
|
8
8
|
type: :numeric,
|
|
9
9
|
default: 3,
|
|
10
|
-
aliases:
|
|
11
|
-
desc:
|
|
10
|
+
aliases: '-m',
|
|
11
|
+
desc: 'Maximum allowed failures before stopping'
|
|
12
12
|
method_option :ai_command,
|
|
13
13
|
type: :string,
|
|
14
|
-
default:
|
|
15
|
-
aliases:
|
|
16
|
-
desc:
|
|
14
|
+
default: 'qwen-code -y -s',
|
|
15
|
+
aliases: '-a',
|
|
16
|
+
desc: 'AI command to invoke'
|
|
17
17
|
method_option :watch,
|
|
18
18
|
type: :boolean,
|
|
19
19
|
default: false,
|
|
20
|
-
aliases:
|
|
21
|
-
desc:
|
|
20
|
+
aliases: '-w',
|
|
21
|
+
desc: 'Run in continuous loop until completion or max failures'
|
|
22
22
|
method_option :plan_path,
|
|
23
23
|
type: :string,
|
|
24
|
-
default:
|
|
25
|
-
aliases:
|
|
26
|
-
desc:
|
|
24
|
+
default: 'plan.md',
|
|
25
|
+
aliases: '-p',
|
|
26
|
+
desc: 'Path to plan.md file'
|
|
27
27
|
method_option :learnings_path,
|
|
28
28
|
type: :string,
|
|
29
|
-
default:
|
|
30
|
-
aliases:
|
|
31
|
-
desc:
|
|
29
|
+
default: 'learnings.md',
|
|
30
|
+
aliases: '-l',
|
|
31
|
+
desc: 'Path to learnings.md file'
|
|
32
32
|
method_option :todo_path,
|
|
33
33
|
type: :string,
|
|
34
|
-
default:
|
|
35
|
-
aliases:
|
|
36
|
-
desc:
|
|
34
|
+
default: 'todo.md',
|
|
35
|
+
aliases: '-t',
|
|
36
|
+
desc: 'Path to todo.md file'
|
|
37
37
|
method_option :skip_commit,
|
|
38
38
|
type: :boolean,
|
|
39
39
|
default: false,
|
|
40
|
-
aliases:
|
|
41
|
-
desc:
|
|
40
|
+
aliases: '-s',
|
|
41
|
+
desc: 'Skip git commits between tasks'
|
|
42
|
+
method_option :verbose,
|
|
43
|
+
type: :boolean,
|
|
44
|
+
default: false,
|
|
45
|
+
aliases: '-v',
|
|
46
|
+
desc: 'Enable verbose logging with AI thinking and real-time output'
|
|
42
47
|
|
|
43
48
|
def start
|
|
44
49
|
runner = Runner.new(
|
|
@@ -48,41 +53,42 @@ module Rralph
|
|
|
48
53
|
plan_path: options[:plan_path],
|
|
49
54
|
learnings_path: options[:learnings_path],
|
|
50
55
|
todo_path: options[:todo_path],
|
|
51
|
-
skip_commit: options[:skip_commit]
|
|
56
|
+
skip_commit: options[:skip_commit],
|
|
57
|
+
verbose: options[:verbose]
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
runner.run
|
|
55
61
|
rescue Rralph::FileNotFound => e
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
warn "Error: #{e.message}"
|
|
63
|
+
warn 'Please ensure plan.md, learnings.md, and todo.md exist in the current directory.'
|
|
58
64
|
exit 1
|
|
59
65
|
rescue Rralph::GitError => e
|
|
60
|
-
|
|
66
|
+
warn "Git Error: #{e.message}"
|
|
61
67
|
exit 1
|
|
62
|
-
rescue => e
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
warn "Unexpected error: #{e.message}"
|
|
70
|
+
warn e.backtrace.first(5)
|
|
65
71
|
exit 1
|
|
66
72
|
end
|
|
67
73
|
|
|
68
|
-
desc
|
|
74
|
+
desc 'version', 'Show rralph version'
|
|
69
75
|
def version
|
|
70
76
|
puts "rralph v#{Rralph::VERSION}"
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
desc
|
|
79
|
+
desc 'stats', 'Show progress statistics'
|
|
74
80
|
method_option :plan_path,
|
|
75
81
|
type: :string,
|
|
76
|
-
default:
|
|
77
|
-
aliases:
|
|
82
|
+
default: 'plan.md',
|
|
83
|
+
aliases: '-p'
|
|
78
84
|
method_option :learnings_path,
|
|
79
85
|
type: :string,
|
|
80
|
-
default:
|
|
81
|
-
aliases:
|
|
86
|
+
default: 'learnings.md',
|
|
87
|
+
aliases: '-l'
|
|
82
88
|
method_option :todo_path,
|
|
83
89
|
type: :string,
|
|
84
|
-
default:
|
|
85
|
-
aliases:
|
|
90
|
+
default: 'todo.md',
|
|
91
|
+
aliases: '-t'
|
|
86
92
|
|
|
87
93
|
def stats
|
|
88
94
|
parser = Parser.new(
|
|
@@ -101,7 +107,7 @@ module Rralph
|
|
|
101
107
|
puts "Pending: #{pending.size}"
|
|
102
108
|
puts "Learnings: #{parser.learnings_content.lines.size} lines"
|
|
103
109
|
rescue Rralph::FileNotFound => e
|
|
104
|
-
|
|
110
|
+
warn "Error: #{e.message}"
|
|
105
111
|
exit 1
|
|
106
112
|
end
|
|
107
113
|
|
data/lib/rralph/file_updater.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Rralph
|
|
2
2
|
class FileUpdater
|
|
3
|
-
def initialize(todo_path:
|
|
3
|
+
def initialize(todo_path: 'todo.md', learnings_path: 'learnings.md')
|
|
4
4
|
@todo_path = todo_path
|
|
5
5
|
@learnings_path = learnings_path
|
|
6
6
|
end
|
|
@@ -10,17 +10,17 @@ module Rralph
|
|
|
10
10
|
lines = content.lines
|
|
11
11
|
|
|
12
12
|
line = lines[task_index]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
return unless line
|
|
14
|
+
|
|
15
|
+
updated_line = line.gsub(/^([-*]) \[ \]/, '\1 [x]')
|
|
16
|
+
lines[task_index] = updated_line
|
|
17
|
+
File.write(@todo_path, lines.join)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def append_learnings(new_learnings)
|
|
21
21
|
return if new_learnings.empty?
|
|
22
22
|
|
|
23
|
-
existing_content = File.exist?(@learnings_path) ? File.read(@learnings_path) :
|
|
23
|
+
existing_content = File.exist?(@learnings_path) ? File.read(@learnings_path) : ''
|
|
24
24
|
existing_learnings = existing_content.lines.map(&:strip).reject(&:empty?)
|
|
25
25
|
|
|
26
26
|
unique_learnings = new_learnings.reject do |learning|
|
|
@@ -29,7 +29,7 @@ module Rralph
|
|
|
29
29
|
|
|
30
30
|
return if unique_learnings.empty?
|
|
31
31
|
|
|
32
|
-
timestamp = Time.now.
|
|
32
|
+
timestamp = Time.now.iso8601
|
|
33
33
|
new_section = "\n\n## Learnings - #{timestamp}\n\n"
|
|
34
34
|
unique_learnings.each do |learning|
|
|
35
35
|
new_section += "- #{learning}\n"
|
data/lib/rralph/git.rb
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
|
-
require
|
|
1
|
+
require 'shellwords'
|
|
2
2
|
|
|
3
3
|
module Rralph
|
|
4
4
|
class Git
|
|
5
|
-
def initialize
|
|
6
|
-
end
|
|
5
|
+
def initialize; end
|
|
7
6
|
|
|
8
7
|
def in_git_repo?
|
|
9
|
-
|
|
8
|
+
`git rev-parse --git-dir 2>/dev/null`
|
|
10
9
|
$?.success?
|
|
11
10
|
end
|
|
12
11
|
|
|
13
12
|
def commit_changes(message)
|
|
14
13
|
add_result = `git add . 2>&1`
|
|
15
|
-
unless $?.success?
|
|
16
|
-
raise GitError, "Failed to stage files: #{add_result}"
|
|
17
|
-
end
|
|
14
|
+
raise GitError, "Failed to stage files: #{add_result}" unless $?.success?
|
|
18
15
|
|
|
19
16
|
commit_result = `git commit -m #{message.shellescape} 2>&1`
|
|
20
|
-
unless $?.success?
|
|
21
|
-
raise GitError, "Failed to commit: #{commit_result}"
|
|
22
|
-
end
|
|
17
|
+
raise GitError, "Failed to commit: #{commit_result}" unless $?.success?
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
sha
|
|
19
|
+
`git rev-parse --short HEAD 2>/dev/null`.strip
|
|
26
20
|
end
|
|
27
21
|
|
|
28
22
|
def status
|
data/lib/rralph/parser.rb
CHANGED
|
@@ -2,18 +2,18 @@ module Rralph
|
|
|
2
2
|
class Parser
|
|
3
3
|
attr_reader :plan_content, :learnings_content, :todo_content
|
|
4
4
|
|
|
5
|
-
def initialize(plan_path:
|
|
5
|
+
def initialize(plan_path: 'plan.md', learnings_path: 'learnings.md', todo_path: 'todo.md')
|
|
6
6
|
@plan_path = plan_path
|
|
7
7
|
@learnings_path = learnings_path
|
|
8
8
|
@todo_path = todo_path
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def load_files
|
|
12
|
-
raise FileNotFound,
|
|
12
|
+
raise FileNotFound, 'plan.md not found' unless File.exist?(@plan_path)
|
|
13
13
|
|
|
14
14
|
@plan_content = File.read(@plan_path)
|
|
15
|
-
@learnings_content = File.exist?(@learnings_path) ? File.read(@learnings_path) :
|
|
16
|
-
@todo_content = File.exist?(@todo_path) ? File.read(@todo_path) :
|
|
15
|
+
@learnings_content = File.exist?(@learnings_path) ? File.read(@learnings_path) : ''
|
|
16
|
+
@todo_content = File.exist?(@todo_path) ? File.read(@todo_path) : ''
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def pending_tasks
|
|
@@ -21,9 +21,9 @@ module Rralph
|
|
|
21
21
|
|
|
22
22
|
@todo_content.lines.map.with_index do |line, index|
|
|
23
23
|
stripped = line.strip
|
|
24
|
-
next unless stripped.start_with?(
|
|
24
|
+
next unless stripped.start_with?('- [ ]') || stripped.start_with?('* [ ]')
|
|
25
25
|
|
|
26
|
-
task_text = stripped.sub(/^[-*] \[ \] /,
|
|
26
|
+
task_text = stripped.sub(/^[-*] \[ \] /, '').strip
|
|
27
27
|
{ index: index, line: line, text: task_text, raw: stripped }
|
|
28
28
|
end.compact
|
|
29
29
|
end
|
|
@@ -33,9 +33,9 @@ module Rralph
|
|
|
33
33
|
|
|
34
34
|
@todo_content.lines.map.with_index do |line, index|
|
|
35
35
|
stripped = line.strip
|
|
36
|
-
next unless stripped.start_with?(
|
|
36
|
+
next unless stripped.start_with?('- [x]') || stripped.start_with?('* [x]')
|
|
37
37
|
|
|
38
|
-
task_text = stripped.sub(/^[-*] \[x\] /i,
|
|
38
|
+
task_text = stripped.sub(/^[-*] \[x\] /i, '').strip
|
|
39
39
|
{ index: index, line: line, text: task_text, raw: stripped }
|
|
40
40
|
end.compact
|
|
41
41
|
end
|
|
@@ -48,7 +48,7 @@ module Rralph
|
|
|
48
48
|
next unless stripped.match?(/^[-*] \[[ x]\]/i)
|
|
49
49
|
|
|
50
50
|
completed = stripped.match?(/^[-*] \[x\]/i)
|
|
51
|
-
task_text = stripped.sub(/^[-*] \[[ x]\] /i,
|
|
51
|
+
task_text = stripped.sub(/^[-*] \[[ x]\] /i, '').strip
|
|
52
52
|
{ index: index, line: line, text: task_text, raw: stripped, completed: completed }
|
|
53
53
|
end.compact
|
|
54
54
|
end
|
|
@@ -58,7 +58,11 @@ module Rralph
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def failure_detected?(response)
|
|
61
|
-
response.match?(/\
|
|
61
|
+
response.match?(/\bTASK_FAILURE\b/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def task_completed?(response)
|
|
65
|
+
response.match?(/\bTASK_DONE\b/)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def extract_learnings(response)
|
|
@@ -67,11 +71,26 @@ module Rralph
|
|
|
67
71
|
# Match various learning patterns like:
|
|
68
72
|
# "Learning: xyz", "**Learning to add:** xyz", "- Learning: xyz", etc.
|
|
69
73
|
response.lines.each do |line|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
learning_pattern = /
|
|
75
|
+
(?:^|\s) # Start of line or whitespace
|
|
76
|
+
(?:\*\*)? # Optional bold markdown
|
|
77
|
+
(?:Learning|Insight|Note|Tip|Discovered|Found|Realized) # Learning keywords
|
|
78
|
+
[\s*]* # Optional whitespace or asterisks
|
|
79
|
+
[:\s]* # Optional colons or whitespace
|
|
80
|
+
(?:to\sadd)? # Optional "to add"
|
|
81
|
+
[:\s]* # Optional colons or whitespace
|
|
82
|
+
(.+?) # Capture the actual learning content
|
|
83
|
+
(?:\*\*)? # Optional closing bold markdown
|
|
84
|
+
(?:\s*$) # Optional whitespace to end of line
|
|
85
|
+
/ix
|
|
86
|
+
|
|
87
|
+
unless match = line.match(learning_pattern)
|
|
88
|
+
next
|
|
74
89
|
end
|
|
90
|
+
|
|
91
|
+
learning = match[1].strip
|
|
92
|
+
learning = learning.gsub(/^\*\*|\*\*$/, '').strip
|
|
93
|
+
learnings << learning unless learning.empty?
|
|
75
94
|
end
|
|
76
95
|
|
|
77
96
|
# Also extract from ## Learnings sections
|
|
@@ -81,7 +100,8 @@ module Rralph
|
|
|
81
100
|
section.lines.each do |line|
|
|
82
101
|
stripped = line.strip
|
|
83
102
|
next if stripped.empty?
|
|
84
|
-
|
|
103
|
+
|
|
104
|
+
stripped = stripped.sub(/^[-*]\s*/, '')
|
|
85
105
|
learnings << stripped unless stripped.empty?
|
|
86
106
|
end
|
|
87
107
|
end
|
|
@@ -92,26 +112,26 @@ module Rralph
|
|
|
92
112
|
def build_prompt(current_task: nil)
|
|
93
113
|
<<~PROMPT
|
|
94
114
|
You are in an iterative development loop. There is a todo list with tasks.
|
|
95
|
-
|
|
115
|
+
|
|
96
116
|
YOUR CURRENT TASK (the first unchecked item in todo.md):
|
|
97
117
|
> #{current_task}
|
|
98
|
-
|
|
118
|
+
|
|
99
119
|
INSTRUCTIONS - Follow these steps in order:
|
|
100
120
|
1. Implement ONLY the task shown above
|
|
101
121
|
2. Write a unit test for it
|
|
102
122
|
3. Run the test
|
|
103
123
|
4. Respond with exactly one of:
|
|
104
|
-
- "
|
|
105
|
-
- "
|
|
124
|
+
- "TASK_DONE" if the task is complete and test passes
|
|
125
|
+
- "TASK_FAILURE" if the test fails after your best effort
|
|
106
126
|
5. Optionally add learnings as: "Learning: <insight>"
|
|
107
|
-
|
|
127
|
+
|
|
108
128
|
IMPORTANT RULES:
|
|
109
129
|
- Work on ONE task only - the one shown above
|
|
110
130
|
- Do NOT implement other tasks from the todo list
|
|
111
131
|
- Do NOT mark tasks as done yourself
|
|
112
|
-
- After you respond "
|
|
132
|
+
- After you respond "TASK_DONE", the system will mark this task complete
|
|
113
133
|
- Then you will receive the next task
|
|
114
|
-
|
|
134
|
+
|
|
115
135
|
--- plan.md (context) ---
|
|
116
136
|
#{@plan_content}
|
|
117
137
|
|
data/lib/rralph/runner.rb
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
require 'amazing_print'
|
|
1
4
|
module Rralph
|
|
2
5
|
class Runner
|
|
3
6
|
attr_reader :cycle_count, :failure_count, :max_failures
|
|
4
7
|
|
|
5
8
|
def initialize(
|
|
6
9
|
max_failures: 3,
|
|
7
|
-
ai_command:
|
|
10
|
+
ai_command: 'qwen-code -y -s',
|
|
8
11
|
watch: false,
|
|
9
|
-
plan_path:
|
|
10
|
-
learnings_path:
|
|
11
|
-
todo_path:
|
|
12
|
-
skip_commit: false
|
|
12
|
+
plan_path: 'plan.md',
|
|
13
|
+
learnings_path: 'learnings.md',
|
|
14
|
+
todo_path: 'todo.md',
|
|
15
|
+
skip_commit: false,
|
|
16
|
+
verbose: false
|
|
13
17
|
)
|
|
14
18
|
@max_failures = max_failures
|
|
15
19
|
@ai_command = ai_command
|
|
@@ -18,6 +22,7 @@ module Rralph
|
|
|
18
22
|
@learnings_path = learnings_path
|
|
19
23
|
@todo_path = todo_path
|
|
20
24
|
@skip_commit = skip_commit
|
|
25
|
+
@verbose = verbose
|
|
21
26
|
|
|
22
27
|
@cycle_count = 0
|
|
23
28
|
@failure_count = 0
|
|
@@ -37,28 +42,24 @@ module Rralph
|
|
|
37
42
|
def run
|
|
38
43
|
log("Starting rralph with max_failures=#{@max_failures}, ai_command='#{@ai_command}'")
|
|
39
44
|
|
|
40
|
-
unless @git.in_git_repo?
|
|
41
|
-
raise GitError, "Not in a git repository. Please initialize git first."
|
|
42
|
-
end
|
|
45
|
+
raise GitError, 'Not in a git repository. Please initialize git first.' unless @git.in_git_repo?
|
|
43
46
|
|
|
44
47
|
@parser.load_files
|
|
45
48
|
|
|
46
49
|
if todo_empty_or_missing?
|
|
47
|
-
log(
|
|
50
|
+
log('todo.md is empty or missing. Generating todo list from plan...')
|
|
48
51
|
generate_todo_from_plan
|
|
49
52
|
return true
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
unless @parser.has_pending_tasks?
|
|
53
|
-
log(
|
|
56
|
+
log('All tasks completed! Well done!')
|
|
54
57
|
return true
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
if @watch
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
run_single_cycle
|
|
61
|
-
end
|
|
60
|
+
return run_watch_loop if @watch
|
|
61
|
+
|
|
62
|
+
run_single_cycle
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
private
|
|
@@ -69,8 +70,6 @@ module Rralph
|
|
|
69
70
|
break unless success
|
|
70
71
|
break unless @parser.has_pending_tasks?
|
|
71
72
|
break if @failure_count >= @max_failures
|
|
72
|
-
|
|
73
|
-
sleep 1
|
|
74
73
|
end
|
|
75
74
|
|
|
76
75
|
check_final_state
|
|
@@ -82,7 +81,7 @@ module Rralph
|
|
|
82
81
|
@parser.load_files
|
|
83
82
|
|
|
84
83
|
unless @parser.has_pending_tasks?
|
|
85
|
-
log(
|
|
84
|
+
log('All tasks completed! Well done!')
|
|
86
85
|
return false
|
|
87
86
|
end
|
|
88
87
|
|
|
@@ -105,7 +104,13 @@ module Rralph
|
|
|
105
104
|
|
|
106
105
|
if @parser.failure_detected?(response)
|
|
107
106
|
@failure_count += 1
|
|
108
|
-
log("❌ [Cycle #{@cycle_count}]
|
|
107
|
+
log("❌ [Cycle #{@cycle_count}] TASK_FAILURE detected. Failures: #{@failure_count}/#{@max_failures}")
|
|
108
|
+
return handle_failure
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless @parser.task_completed?(response)
|
|
112
|
+
@failure_count += 1
|
|
113
|
+
log("❌ [Cycle #{@cycle_count}] Neither TASK_DONE nor TASK_FAILURE found. Failures: #{@failure_count}/#{@max_failures}")
|
|
109
114
|
return handle_failure
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -118,14 +123,12 @@ module Rralph
|
|
|
118
123
|
@file_updater.append_learnings(new_learnings) if new_learnings.any?
|
|
119
124
|
|
|
120
125
|
if @skip_commit
|
|
121
|
-
log("⏭️
|
|
126
|
+
log("⏭️ [Cycle #{@cycle_count}] Skipping commit (skip_commit enabled)")
|
|
122
127
|
else
|
|
123
128
|
commit_message = "rralph: #{current_task[:text]} [cycle #{@cycle_count}]"
|
|
124
129
|
sha = @git.commit_changes(commit_message)
|
|
125
130
|
|
|
126
|
-
if sha
|
|
127
|
-
log(" Git commit: #{sha}")
|
|
128
|
-
end
|
|
131
|
+
log(" Git commit: #{sha}") if sha
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
true
|
|
@@ -143,55 +146,99 @@ module Rralph
|
|
|
143
146
|
if @failure_count >= @max_failures
|
|
144
147
|
exit 1
|
|
145
148
|
elsif !@parser.has_pending_tasks?
|
|
146
|
-
log(
|
|
149
|
+
log('All tasks completed! Well done!')
|
|
147
150
|
exit 0
|
|
148
151
|
end
|
|
149
152
|
end
|
|
150
153
|
|
|
151
154
|
def execute_ai_command(prompt)
|
|
152
|
-
|
|
155
|
+
full_response = ''
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
begin
|
|
158
|
+
log('Executing AI command...')
|
|
159
|
+
|
|
160
|
+
# Use stream-json mode for real-time logging
|
|
161
|
+
cmd = "#{@ai_command} -y -s -o stream-json"
|
|
162
|
+
|
|
163
|
+
Open3.popen3(cmd) do |stdin, stdout, _stderr, wait_thr|
|
|
164
|
+
# Write prompt to stdin and close
|
|
165
|
+
stdin.write(prompt)
|
|
166
|
+
stdin.close
|
|
167
|
+
|
|
168
|
+
# Process stdout line by line for real-time streaming
|
|
169
|
+
stdout.each_line do |line|
|
|
170
|
+
event = JSON.parse(line)
|
|
171
|
+
|
|
172
|
+
case event['type']
|
|
173
|
+
when 'assistant'
|
|
174
|
+
content = event.dig('message', 'content')
|
|
175
|
+
content&.each do |item|
|
|
176
|
+
case item['type']
|
|
177
|
+
when 'thinking'
|
|
178
|
+
log("[Thinking] #{item['thinking']}") if @verbose
|
|
179
|
+
when 'text'
|
|
180
|
+
text = item['text']
|
|
181
|
+
full_response += text
|
|
182
|
+
log("[text] #{text}") if @verbose
|
|
183
|
+
when 'tool_use'
|
|
184
|
+
tool_name = item['name']
|
|
185
|
+
tool_input = item['input']
|
|
186
|
+
log("[tool_use] #{tool_name}: #{tool_input.to_json}") if @verbose
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
when 'user'
|
|
190
|
+
if @verbose
|
|
191
|
+
content = event.dig('message', 'content')
|
|
192
|
+
content&.each do |item|
|
|
193
|
+
if item['type'] == 'tool_result'
|
|
194
|
+
tool_name = item.dig('tool_use_id')&.split('_')&.first || 'tool'
|
|
195
|
+
result = item['content']
|
|
196
|
+
log("[#{tool_name}] #{result}")
|
|
197
|
+
elsif item['type'] == 'text'
|
|
198
|
+
log("[text] #{item['text']}")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
when 'result'
|
|
203
|
+
duration = event['duration_ms']
|
|
204
|
+
tokens = event.dig('usage', 'total_tokens')
|
|
205
|
+
is_error = event['is_error']
|
|
206
|
+
if is_error
|
|
207
|
+
log('AI command failed')
|
|
208
|
+
else
|
|
209
|
+
log("Completed in #{duration}ms (#{tokens} tokens)")
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
log(" [event: #{event['type']}]") if @verbose
|
|
213
|
+
end
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
# Skip non-JSON lines
|
|
216
|
+
log(" #{line.chomp}") if @verbose
|
|
217
|
+
end
|
|
158
218
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
response_file.close
|
|
219
|
+
log(" Command exit status: #{wait_thr.value.exitstatus}")
|
|
220
|
+
end
|
|
162
221
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
response = File.read(response_file.path)
|
|
172
|
-
response.strip.empty? ? nil : response
|
|
173
|
-
ensure
|
|
174
|
-
prompt_file.unlink
|
|
175
|
-
response_file.unlink
|
|
222
|
+
full_response.strip.empty? ? nil : full_response
|
|
223
|
+
rescue Errno::ENOENT => e
|
|
224
|
+
log("Error: AI command '#{@ai_command}' not found: #{e.message}")
|
|
225
|
+
nil
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
log("Error executing AI command: #{e.message}")
|
|
228
|
+
nil
|
|
176
229
|
end
|
|
177
|
-
rescue Errno::ENOENT => e
|
|
178
|
-
log("Error: AI command '#{@ai_command}' not found: #{e.message}")
|
|
179
|
-
nil
|
|
180
|
-
rescue => e
|
|
181
|
-
log("Error executing AI command: #{e.message}")
|
|
182
|
-
nil
|
|
183
230
|
end
|
|
184
231
|
|
|
185
232
|
def save_ai_log(prompt, response)
|
|
186
|
-
logs_dir =
|
|
233
|
+
logs_dir = 'logs'
|
|
187
234
|
Dir.mkdir(logs_dir) unless Dir.exist?(logs_dir)
|
|
188
235
|
|
|
189
|
-
timestamp = Time.now.
|
|
236
|
+
timestamp = Time.now.iso8601
|
|
190
237
|
filename = "#{logs_dir}/cycle_#{@cycle_count}_#{timestamp}.md"
|
|
191
238
|
|
|
192
239
|
# Extract just the current task for the header
|
|
193
240
|
task_match = prompt.match(/YOUR CURRENT TASK.*?\n>\s*(.+?)\n/)
|
|
194
|
-
task_text = task_match ? task_match[1].strip :
|
|
241
|
+
task_text = task_match ? task_match[1].strip : 'Unknown'
|
|
195
242
|
|
|
196
243
|
content = <<~LOG
|
|
197
244
|
# Cycle #{@cycle_count} - #{timestamp}
|
|
@@ -201,7 +248,7 @@ module Rralph
|
|
|
201
248
|
|
|
202
249
|
## AI Response
|
|
203
250
|
|
|
204
|
-
#{response ||
|
|
251
|
+
#{response || '(no response)'}
|
|
205
252
|
LOG
|
|
206
253
|
|
|
207
254
|
File.write(filename, content)
|
|
@@ -209,18 +256,31 @@ module Rralph
|
|
|
209
256
|
end
|
|
210
257
|
|
|
211
258
|
def log(message)
|
|
212
|
-
|
|
259
|
+
warn(message)
|
|
213
260
|
end
|
|
214
261
|
|
|
215
262
|
def todo_empty_or_missing?
|
|
216
263
|
return true unless File.exist?(@todo_path)
|
|
217
264
|
|
|
218
265
|
content = File.read(@todo_path)
|
|
219
|
-
content.strip.empty?
|
|
266
|
+
content.strip.empty?
|
|
220
267
|
end
|
|
221
268
|
|
|
222
269
|
def generate_todo_from_plan
|
|
223
|
-
prompt =
|
|
270
|
+
prompt = build_todo_generation_prompt
|
|
271
|
+
response = execute_ai_command(prompt)
|
|
272
|
+
|
|
273
|
+
return log('❌ AI command failed when generating todo') unless response
|
|
274
|
+
|
|
275
|
+
todo_items = extract_todo_items(response)
|
|
276
|
+
return log('❌ Could not parse tasks from AI response') if todo_items.empty?
|
|
277
|
+
|
|
278
|
+
save_todo_file(todo_items)
|
|
279
|
+
handle_todo_commit(todo_items.size)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def build_todo_generation_prompt
|
|
283
|
+
<<~PROMPT
|
|
224
284
|
Based on the following plan, generate a todo list with actionable tasks.
|
|
225
285
|
Format each task as a markdown checkbox like: - [ ] Task description
|
|
226
286
|
Keep tasks specific and actionable.
|
|
@@ -228,32 +288,29 @@ module Rralph
|
|
|
228
288
|
--- plan.md ---
|
|
229
289
|
#{@parser.plan_content}
|
|
230
290
|
PROMPT
|
|
291
|
+
end
|
|
231
292
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
todo_items = response.scan(/^- \[ \] .+$/).uniq
|
|
293
|
+
def extract_todo_items(response)
|
|
294
|
+
response.scan(/^- \[ \] .+$/).uniq
|
|
295
|
+
end
|
|
236
296
|
|
|
237
|
-
|
|
238
|
-
|
|
297
|
+
def save_todo_file(todo_items)
|
|
298
|
+
todo_content = <<~TODO
|
|
299
|
+
# Todo List
|
|
239
300
|
|
|
240
|
-
|
|
301
|
+
#{todo_items.join("\n")}
|
|
302
|
+
TODO
|
|
241
303
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
else
|
|
245
|
-
commit_message = "rralph: generated todo from plan.md"
|
|
246
|
-
sha = @git.commit_changes(commit_message)
|
|
304
|
+
File.write(@todo_path, todo_content)
|
|
305
|
+
end
|
|
247
306
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
end
|
|
252
|
-
else
|
|
253
|
-
log("❌ Could not parse tasks from AI response")
|
|
254
|
-
end
|
|
307
|
+
def handle_todo_commit(task_count)
|
|
308
|
+
if @skip_commit
|
|
309
|
+
log("⏭️ Generated #{task_count} tasks (skip_commit enabled, no commit)")
|
|
255
310
|
else
|
|
256
|
-
|
|
311
|
+
commit_message = 'rralph: generated todo from plan.md'
|
|
312
|
+
sha = @git.commit_changes(commit_message)
|
|
313
|
+
log("✅ Generated #{task_count} tasks. Git commit: #{sha}") if sha
|
|
257
314
|
end
|
|
258
315
|
end
|
|
259
316
|
end
|
data/lib/rralph.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Rralph
|
|
2
|
-
VERSION =
|
|
2
|
+
VERSION = '0.2.1'
|
|
3
3
|
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
class FileNotFound < Error; end
|
|
@@ -7,8 +7,8 @@ module Rralph
|
|
|
7
7
|
class AICommandError < Error; end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
10
|
+
require_relative 'rralph/parser'
|
|
11
|
+
require_relative 'rralph/file_updater'
|
|
12
|
+
require_relative 'rralph/git'
|
|
13
|
+
require_relative 'rralph/runner'
|
|
14
|
+
require_relative 'rralph/cli'
|