rralph 0.1.3 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +31 -4
- 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 +34 -18
- data/lib/rralph/runner.rb +128 -77
- 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: ea4f06957671b88207938c10e6bb9a00e0a7524cea7d679b950dde54d4c99c85
|
|
4
|
+
data.tar.gz: d3182ee683101ee606d781093a6aba536dd25c811513520464146f60a93064bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bf09138e994351d3c374ce4f1a11505ad12138138227cb7d28774b2f49ca0a257a2f0d33f1e2f2730d185284a632545fd86336d54d07dd33a4dfc06095373ff
|
|
7
|
+
data.tar.gz: dc175e3a19ce97b1f8fd43f1420a7e556cd99da7c7e92ae7fc2ef4fd5e390341024fca79e3eb1a9d20a6e37a2cee7ea5b772416e2fd10ec0a1d8bcf00b1c85a0
|
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
|
|
@@ -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
|
|
@@ -67,11 +67,26 @@ module Rralph
|
|
|
67
67
|
# Match various learning patterns like:
|
|
68
68
|
# "Learning: xyz", "**Learning to add:** xyz", "- Learning: xyz", etc.
|
|
69
69
|
response.lines.each do |line|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
learning_pattern = /
|
|
71
|
+
(?:^|\s) # Start of line or whitespace
|
|
72
|
+
(?:\*\*)? # Optional bold markdown
|
|
73
|
+
(?:Learning|Insight|Note|Tip|Discovered|Found|Realized) # Learning keywords
|
|
74
|
+
[\s*]* # Optional whitespace or asterisks
|
|
75
|
+
[:\s]* # Optional colons or whitespace
|
|
76
|
+
(?:to\sadd)? # Optional "to add"
|
|
77
|
+
[:\s]* # Optional colons or whitespace
|
|
78
|
+
(.+?) # Capture the actual learning content
|
|
79
|
+
(?:\*\*)? # Optional closing bold markdown
|
|
80
|
+
(?:\s*$) # Optional whitespace to end of line
|
|
81
|
+
/ix
|
|
82
|
+
|
|
83
|
+
unless match = line.match(learning_pattern)
|
|
84
|
+
next
|
|
74
85
|
end
|
|
86
|
+
|
|
87
|
+
learning = match[1].strip
|
|
88
|
+
learning = learning.gsub(/^\*\*|\*\*$/, '').strip
|
|
89
|
+
learnings << learning unless learning.empty?
|
|
75
90
|
end
|
|
76
91
|
|
|
77
92
|
# Also extract from ## Learnings sections
|
|
@@ -81,7 +96,8 @@ module Rralph
|
|
|
81
96
|
section.lines.each do |line|
|
|
82
97
|
stripped = line.strip
|
|
83
98
|
next if stripped.empty?
|
|
84
|
-
|
|
99
|
+
|
|
100
|
+
stripped = stripped.sub(/^[-*]\s*/, '')
|
|
85
101
|
learnings << stripped unless stripped.empty?
|
|
86
102
|
end
|
|
87
103
|
end
|
|
@@ -92,10 +108,10 @@ module Rralph
|
|
|
92
108
|
def build_prompt(current_task: nil)
|
|
93
109
|
<<~PROMPT
|
|
94
110
|
You are in an iterative development loop. There is a todo list with tasks.
|
|
95
|
-
|
|
111
|
+
|
|
96
112
|
YOUR CURRENT TASK (the first unchecked item in todo.md):
|
|
97
113
|
> #{current_task}
|
|
98
|
-
|
|
114
|
+
|
|
99
115
|
INSTRUCTIONS - Follow these steps in order:
|
|
100
116
|
1. Implement ONLY the task shown above
|
|
101
117
|
2. Write a unit test for it
|
|
@@ -104,14 +120,14 @@ module Rralph
|
|
|
104
120
|
- "DONE" if the task is complete and test passes
|
|
105
121
|
- "FAILURE" if the test fails after your best effort
|
|
106
122
|
5. Optionally add learnings as: "Learning: <insight>"
|
|
107
|
-
|
|
123
|
+
|
|
108
124
|
IMPORTANT RULES:
|
|
109
125
|
- Work on ONE task only - the one shown above
|
|
110
126
|
- Do NOT implement other tasks from the todo list
|
|
111
127
|
- Do NOT mark tasks as done yourself
|
|
112
128
|
- After you respond "DONE", the system will mark this task complete
|
|
113
129
|
- Then you will receive the next task
|
|
114
|
-
|
|
130
|
+
|
|
115
131
|
--- plan.md (context) ---
|
|
116
132
|
#{@plan_content}
|
|
117
133
|
|
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
|
|
|
@@ -118,14 +117,12 @@ module Rralph
|
|
|
118
117
|
@file_updater.append_learnings(new_learnings) if new_learnings.any?
|
|
119
118
|
|
|
120
119
|
if @skip_commit
|
|
121
|
-
log("⏭️
|
|
120
|
+
log("⏭️ [Cycle #{@cycle_count}] Skipping commit (skip_commit enabled)")
|
|
122
121
|
else
|
|
123
122
|
commit_message = "rralph: #{current_task[:text]} [cycle #{@cycle_count}]"
|
|
124
123
|
sha = @git.commit_changes(commit_message)
|
|
125
124
|
|
|
126
|
-
if sha
|
|
127
|
-
log(" Git commit: #{sha}")
|
|
128
|
-
end
|
|
125
|
+
log(" Git commit: #{sha}") if sha
|
|
129
126
|
end
|
|
130
127
|
|
|
131
128
|
true
|
|
@@ -143,55 +140,99 @@ module Rralph
|
|
|
143
140
|
if @failure_count >= @max_failures
|
|
144
141
|
exit 1
|
|
145
142
|
elsif !@parser.has_pending_tasks?
|
|
146
|
-
log(
|
|
143
|
+
log('All tasks completed! Well done!')
|
|
147
144
|
exit 0
|
|
148
145
|
end
|
|
149
146
|
end
|
|
150
147
|
|
|
151
148
|
def execute_ai_command(prompt)
|
|
152
|
-
|
|
149
|
+
full_response = ''
|
|
153
150
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
begin
|
|
152
|
+
log('Executing AI command...')
|
|
153
|
+
|
|
154
|
+
# Use stream-json mode for real-time logging
|
|
155
|
+
cmd = "#{@ai_command} -y -s -o stream-json"
|
|
156
|
+
|
|
157
|
+
Open3.popen3(cmd) do |stdin, stdout, _stderr, wait_thr|
|
|
158
|
+
# Write prompt to stdin and close
|
|
159
|
+
stdin.write(prompt)
|
|
160
|
+
stdin.close
|
|
161
|
+
|
|
162
|
+
# Process stdout line by line for real-time streaming
|
|
163
|
+
stdout.each_line do |line|
|
|
164
|
+
event = JSON.parse(line)
|
|
165
|
+
|
|
166
|
+
case event['type']
|
|
167
|
+
when 'assistant'
|
|
168
|
+
content = event.dig('message', 'content')
|
|
169
|
+
content&.each do |item|
|
|
170
|
+
case item['type']
|
|
171
|
+
when 'thinking'
|
|
172
|
+
log("[Thinking] #{item['thinking']}") if @verbose
|
|
173
|
+
when 'text'
|
|
174
|
+
text = item['text']
|
|
175
|
+
full_response += text
|
|
176
|
+
log("[text] #{text}") if @verbose
|
|
177
|
+
when 'tool_use'
|
|
178
|
+
tool_name = item['name']
|
|
179
|
+
tool_input = item['input']
|
|
180
|
+
log("[tool_use] #{tool_name}: #{tool_input.to_json}") if @verbose
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
when 'user'
|
|
184
|
+
if @verbose
|
|
185
|
+
content = event.dig('message', 'content')
|
|
186
|
+
content&.each do |item|
|
|
187
|
+
if item['type'] == 'tool_result'
|
|
188
|
+
tool_name = item.dig('tool_use_id')&.split('_')&.first || 'tool'
|
|
189
|
+
result = item['content']
|
|
190
|
+
log("[#{tool_name}] #{result}")
|
|
191
|
+
elsif item['type'] == 'text'
|
|
192
|
+
log("[text] #{item['text']}")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
when 'result'
|
|
197
|
+
duration = event['duration_ms']
|
|
198
|
+
tokens = event.dig('usage', 'total_tokens')
|
|
199
|
+
is_error = event['is_error']
|
|
200
|
+
if is_error
|
|
201
|
+
log('AI command failed')
|
|
202
|
+
else
|
|
203
|
+
log("Completed in #{duration}ms (#{tokens} tokens)")
|
|
204
|
+
end
|
|
205
|
+
else
|
|
206
|
+
log(" [event: #{event['type']}]") if @verbose
|
|
207
|
+
end
|
|
208
|
+
rescue JSON::ParserError
|
|
209
|
+
# Skip non-JSON lines
|
|
210
|
+
log(" #{line.chomp}") if @verbose
|
|
211
|
+
end
|
|
158
212
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
response_file.close
|
|
213
|
+
log(" Command exit status: #{wait_thr.value.exitstatus}")
|
|
214
|
+
end
|
|
162
215
|
|
|
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
|
|
216
|
+
full_response.strip.empty? ? nil : full_response
|
|
217
|
+
rescue Errno::ENOENT => e
|
|
218
|
+
log("Error: AI command '#{@ai_command}' not found: #{e.message}")
|
|
219
|
+
nil
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
log("Error executing AI command: #{e.message}")
|
|
222
|
+
nil
|
|
176
223
|
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
224
|
end
|
|
184
225
|
|
|
185
226
|
def save_ai_log(prompt, response)
|
|
186
|
-
logs_dir =
|
|
227
|
+
logs_dir = 'logs'
|
|
187
228
|
Dir.mkdir(logs_dir) unless Dir.exist?(logs_dir)
|
|
188
229
|
|
|
189
|
-
timestamp = Time.now.
|
|
230
|
+
timestamp = Time.now.iso8601
|
|
190
231
|
filename = "#{logs_dir}/cycle_#{@cycle_count}_#{timestamp}.md"
|
|
191
232
|
|
|
192
233
|
# Extract just the current task for the header
|
|
193
234
|
task_match = prompt.match(/YOUR CURRENT TASK.*?\n>\s*(.+?)\n/)
|
|
194
|
-
task_text = task_match ? task_match[1].strip :
|
|
235
|
+
task_text = task_match ? task_match[1].strip : 'Unknown'
|
|
195
236
|
|
|
196
237
|
content = <<~LOG
|
|
197
238
|
# Cycle #{@cycle_count} - #{timestamp}
|
|
@@ -201,7 +242,7 @@ module Rralph
|
|
|
201
242
|
|
|
202
243
|
## AI Response
|
|
203
244
|
|
|
204
|
-
#{response ||
|
|
245
|
+
#{response || '(no response)'}
|
|
205
246
|
LOG
|
|
206
247
|
|
|
207
248
|
File.write(filename, content)
|
|
@@ -209,18 +250,31 @@ module Rralph
|
|
|
209
250
|
end
|
|
210
251
|
|
|
211
252
|
def log(message)
|
|
212
|
-
|
|
253
|
+
warn(message)
|
|
213
254
|
end
|
|
214
255
|
|
|
215
256
|
def todo_empty_or_missing?
|
|
216
257
|
return true unless File.exist?(@todo_path)
|
|
217
258
|
|
|
218
259
|
content = File.read(@todo_path)
|
|
219
|
-
content.strip.empty?
|
|
260
|
+
content.strip.empty?
|
|
220
261
|
end
|
|
221
262
|
|
|
222
263
|
def generate_todo_from_plan
|
|
223
|
-
prompt =
|
|
264
|
+
prompt = build_todo_generation_prompt
|
|
265
|
+
response = execute_ai_command(prompt)
|
|
266
|
+
|
|
267
|
+
return log('❌ AI command failed when generating todo') unless response
|
|
268
|
+
|
|
269
|
+
todo_items = extract_todo_items(response)
|
|
270
|
+
return log('❌ Could not parse tasks from AI response') if todo_items.empty?
|
|
271
|
+
|
|
272
|
+
save_todo_file(todo_items)
|
|
273
|
+
handle_todo_commit(todo_items.size)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def build_todo_generation_prompt
|
|
277
|
+
<<~PROMPT
|
|
224
278
|
Based on the following plan, generate a todo list with actionable tasks.
|
|
225
279
|
Format each task as a markdown checkbox like: - [ ] Task description
|
|
226
280
|
Keep tasks specific and actionable.
|
|
@@ -228,32 +282,29 @@ module Rralph
|
|
|
228
282
|
--- plan.md ---
|
|
229
283
|
#{@parser.plan_content}
|
|
230
284
|
PROMPT
|
|
285
|
+
end
|
|
231
286
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
todo_items = response.scan(/^- \[ \] .+$/).uniq
|
|
287
|
+
def extract_todo_items(response)
|
|
288
|
+
response.scan(/^- \[ \] .+$/).uniq
|
|
289
|
+
end
|
|
236
290
|
|
|
237
|
-
|
|
238
|
-
|
|
291
|
+
def save_todo_file(todo_items)
|
|
292
|
+
todo_content = <<~TODO
|
|
293
|
+
# Todo List
|
|
239
294
|
|
|
240
|
-
|
|
295
|
+
#{todo_items.join("\n")}
|
|
296
|
+
TODO
|
|
241
297
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
else
|
|
245
|
-
commit_message = "rralph: generated todo from plan.md"
|
|
246
|
-
sha = @git.commit_changes(commit_message)
|
|
298
|
+
File.write(@todo_path, todo_content)
|
|
299
|
+
end
|
|
247
300
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
end
|
|
252
|
-
else
|
|
253
|
-
log("❌ Could not parse tasks from AI response")
|
|
254
|
-
end
|
|
301
|
+
def handle_todo_commit(task_count)
|
|
302
|
+
if @skip_commit
|
|
303
|
+
log("⏭️ Generated #{task_count} tasks (skip_commit enabled, no commit)")
|
|
255
304
|
else
|
|
256
|
-
|
|
305
|
+
commit_message = 'rralph: generated todo from plan.md'
|
|
306
|
+
sha = @git.commit_changes(commit_message)
|
|
307
|
+
log("✅ Generated #{task_count} tasks. Git commit: #{sha}") if sha
|
|
257
308
|
end
|
|
258
309
|
end
|
|
259
310
|
end
|
data/lib/rralph.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Rralph
|
|
2
|
-
VERSION =
|
|
2
|
+
VERSION = '0.2.0'
|
|
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'
|