rralph 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +144 -0
- data/bin/rralph +5 -0
- data/lib/rralph/cli.rb +104 -0
- data/lib/rralph/file_updater.rb +48 -0
- data/lib/rralph/git.rb +37 -0
- data/lib/rralph/parser.rb +126 -0
- data/lib/rralph/runner.rb +250 -0
- data/lib/rralph.rb +14 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 879adb96bb31b3269ef4f60a200a8689cf4283f694f2aca5e0f9a95c24bef7ff
|
|
4
|
+
data.tar.gz: 909224ae6b25857bff64c7c789d66a16025c74601ef397d9f40c8d066707ba94
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6411d979dddce3cd5de21d510d5af092c4aa65f0d6af5371c96628320b3658d1b2f5a70495e861c15f77044fc1486fe1de29eb9fb0383a6a6637fe61c4cb11e8
|
|
7
|
+
data.tar.gz: '07018c95c8ec9029893b4548ede3e5ef1a1e601bc1243c665ba053c882fdca83ac7523c71df6005c179a85af3f775ef42f9d6a4b997ea6490dc4753918f0748b'
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Hagege
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# rralph
|
|
2
|
+
|
|
3
|
+
A self-improving task orchestrator for AI-assisted development. Based on Ralph Wiggum concept.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`rralph` automates an iterative, AI-assisted development workflow. It reads your project's plan, learnings, and todo list, then orchestrates AI tool invocations to complete tasks one by one — learning, testing, and committing along the way.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
gem install rralph
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Ralph by default uses `qwen-code` as the AI agent. You can override this with the `--ai-command` flag.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Prerequisites
|
|
20
|
+
|
|
21
|
+
Before running `rralph`, ensure you have:
|
|
22
|
+
|
|
23
|
+
1. A Git repository initialized
|
|
24
|
+
2. Three Markdown files in your working directory:
|
|
25
|
+
- `plan.md` — Your high-level project plan
|
|
26
|
+
- `learnings.md` — Accumulated insights (can start empty)
|
|
27
|
+
- `todo.md` — Task list with checkboxes (one task per line, can be empty first, rralph will generate the tasks)
|
|
28
|
+
|
|
29
|
+
Example `plan.md`:
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
Plan: Bash script that prints "IS EVEN" or "IS ODD" based on input.
|
|
33
|
+
|
|
34
|
+
Input: One integer as command-line argument.
|
|
35
|
+
Output: "IS EVEN" if divisible by 2, "IS ODD" otherwise.
|
|
36
|
+
Error: If no argument or non-integer, print error and exit 1.
|
|
37
|
+
|
|
38
|
+
Logic: Use % to check remainder. Validate input with regex: ^-?[0-9]+$.
|
|
39
|
+
|
|
40
|
+
File: odd_even.sh
|
|
41
|
+
Requirements:
|
|
42
|
+
|
|
43
|
+
- Need to be in bash
|
|
44
|
+
- Validate input
|
|
45
|
+
- Use conditional with (( ))
|
|
46
|
+
- No external tools
|
|
47
|
+
|
|
48
|
+
Test: 0→EVEN, 7→ODD, -4→EVEN, abc→error, no arg→error
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Basic Usage
|
|
52
|
+
|
|
53
|
+
Run `rralph` with default settings:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
rralph
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or with options:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rralph --max-failures 2 --watch
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Command-Line Options
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
rralph run [OPTIONS]
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
-m, --max-failures N Maximum allowed failures before stopping (default: 3)
|
|
72
|
+
-a, --ai-command "CMD" AI command to invoke (default: "qwen-code -y -s")
|
|
73
|
+
-w, --watch Run in continuous loop until completion or max failures
|
|
74
|
+
-p, --plan-path PATH Path to plan.md file (default: "plan.md")
|
|
75
|
+
-l, --learnings-path PATH Path to learnings.md file (default: "learnings.md")
|
|
76
|
+
-t, --todo-path PATH Path to todo.md file (default: "todo.md")
|
|
77
|
+
-h, --help Show help message
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Examples
|
|
81
|
+
|
|
82
|
+
Run a single cycle:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
rralph
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Run continuously until all tasks are done or max failures reached:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
rralph --watch --max-failures 5
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use a custom AI command:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
rralph --ai-command "claude --prompt"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
View progress statistics:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
$> rralph stats
|
|
104
|
+
Tasks: 5/6 done
|
|
105
|
+
Pending: 1
|
|
106
|
+
Learnings: 6 lines
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## How It Works
|
|
110
|
+
|
|
111
|
+
1. **Read** — `rralph` reads `plan.md`, `learnings.md`, and `todo.md`
|
|
112
|
+
2. **Prompt** — Builds a prompt with file contents and sends to LLM
|
|
113
|
+
3. **Parse** — Analyzes AI response for:
|
|
114
|
+
- `FAILURE` keyword (case-insensitive, whole word)
|
|
115
|
+
- New learnings to extract
|
|
116
|
+
4. **Update** — On success:
|
|
117
|
+
- Marks current task as complete in `todo.md`
|
|
118
|
+
- Appends new learnings to `learnings.md`
|
|
119
|
+
- Commits all changes to Git
|
|
120
|
+
5. **Repeat** — In `--watch` mode, continues until done or max failures
|
|
121
|
+
|
|
122
|
+
### Failure Handling
|
|
123
|
+
|
|
124
|
+
- Each `FAILURE` response increments a counter
|
|
125
|
+
- Non-failure responses reset the counter to 0
|
|
126
|
+
- When max failures reached, `rralph` exits with error:
|
|
127
|
+
- ```
|
|
128
|
+
Max failures reached (N). Stopping to avoid infinite loops. Review learnings.md and todo.md.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Logging
|
|
132
|
+
|
|
133
|
+
Human-readable logs are output to stderr:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
✅ [Cycle 4] Task completed. 0 failures. Git commit: abc123
|
|
137
|
+
❌ [Cycle 5] FAILURE detected. Failures: 2/3
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
In `--watch` mode, AI responses are saved to `logs/` for audit trail.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/bin/rralph
ADDED
data/lib/rralph/cli.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "thor"
|
|
2
|
+
require_relative "runner"
|
|
3
|
+
|
|
4
|
+
module Rralph
|
|
5
|
+
class CLI < Thor
|
|
6
|
+
desc "start", "Run the rralph orchestrator"
|
|
7
|
+
method_option :max_failures,
|
|
8
|
+
type: :numeric,
|
|
9
|
+
default: 3,
|
|
10
|
+
aliases: "-m",
|
|
11
|
+
desc: "Maximum allowed failures before stopping"
|
|
12
|
+
method_option :ai_command,
|
|
13
|
+
type: :string,
|
|
14
|
+
default: "qwen-code -y -s",
|
|
15
|
+
aliases: "-a",
|
|
16
|
+
desc: "AI command to invoke"
|
|
17
|
+
method_option :watch,
|
|
18
|
+
type: :boolean,
|
|
19
|
+
default: false,
|
|
20
|
+
aliases: "-w",
|
|
21
|
+
desc: "Run in continuous loop until completion or max failures"
|
|
22
|
+
method_option :plan_path,
|
|
23
|
+
type: :string,
|
|
24
|
+
default: "plan.md",
|
|
25
|
+
aliases: "-p",
|
|
26
|
+
desc: "Path to plan.md file"
|
|
27
|
+
method_option :learnings_path,
|
|
28
|
+
type: :string,
|
|
29
|
+
default: "learnings.md",
|
|
30
|
+
aliases: "-l",
|
|
31
|
+
desc: "Path to learnings.md file"
|
|
32
|
+
method_option :todo_path,
|
|
33
|
+
type: :string,
|
|
34
|
+
default: "todo.md",
|
|
35
|
+
aliases: "-t",
|
|
36
|
+
desc: "Path to todo.md file"
|
|
37
|
+
|
|
38
|
+
def start
|
|
39
|
+
runner = Runner.new(
|
|
40
|
+
max_failures: options[:max_failures],
|
|
41
|
+
ai_command: options[:ai_command],
|
|
42
|
+
watch: options[:watch],
|
|
43
|
+
plan_path: options[:plan_path],
|
|
44
|
+
learnings_path: options[:learnings_path],
|
|
45
|
+
todo_path: options[:todo_path]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
runner.run
|
|
49
|
+
rescue Rralph::FileNotFound => e
|
|
50
|
+
$stderr.puts "Error: #{e.message}"
|
|
51
|
+
$stderr.puts "Please ensure plan.md, learnings.md, and todo.md exist in the current directory."
|
|
52
|
+
exit 1
|
|
53
|
+
rescue Rralph::GitError => e
|
|
54
|
+
$stderr.puts "Git Error: #{e.message}"
|
|
55
|
+
exit 1
|
|
56
|
+
rescue => e
|
|
57
|
+
$stderr.puts "Unexpected error: #{e.message}"
|
|
58
|
+
$stderr.puts e.backtrace.first(5)
|
|
59
|
+
exit 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
desc "version", "Show rralph version"
|
|
63
|
+
def version
|
|
64
|
+
puts "rralph v#{Rralph::VERSION}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
desc "stats", "Show progress statistics"
|
|
68
|
+
method_option :plan_path,
|
|
69
|
+
type: :string,
|
|
70
|
+
default: "plan.md",
|
|
71
|
+
aliases: "-p"
|
|
72
|
+
method_option :learnings_path,
|
|
73
|
+
type: :string,
|
|
74
|
+
default: "learnings.md",
|
|
75
|
+
aliases: "-l"
|
|
76
|
+
method_option :todo_path,
|
|
77
|
+
type: :string,
|
|
78
|
+
default: "todo.md",
|
|
79
|
+
aliases: "-t"
|
|
80
|
+
|
|
81
|
+
def stats
|
|
82
|
+
parser = Parser.new(
|
|
83
|
+
plan_path: options[:plan_path],
|
|
84
|
+
learnings_path: options[:learnings_path],
|
|
85
|
+
todo_path: options[:todo_path]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parser.load_files
|
|
89
|
+
|
|
90
|
+
all_tasks = parser.all_tasks
|
|
91
|
+
completed = parser.completed_tasks
|
|
92
|
+
pending = parser.pending_tasks
|
|
93
|
+
|
|
94
|
+
puts "Tasks: #{completed.size}/#{all_tasks.size} done"
|
|
95
|
+
puts "Pending: #{pending.size}"
|
|
96
|
+
puts "Learnings: #{parser.learnings_content.lines.size} lines"
|
|
97
|
+
rescue Rralph::FileNotFound => e
|
|
98
|
+
$stderr.puts "Error: #{e.message}"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
default_task :start
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Rralph
|
|
2
|
+
class FileUpdater
|
|
3
|
+
def initialize(todo_path: "todo.md", learnings_path: "learnings.md")
|
|
4
|
+
@todo_path = todo_path
|
|
5
|
+
@learnings_path = learnings_path
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def mark_task_completed(task_index)
|
|
9
|
+
content = File.read(@todo_path)
|
|
10
|
+
lines = content.lines
|
|
11
|
+
|
|
12
|
+
line = lines[task_index]
|
|
13
|
+
if line
|
|
14
|
+
updated_line = line.gsub(/^([-*]) \[ \]/, '\1 [x]')
|
|
15
|
+
lines[task_index] = updated_line
|
|
16
|
+
File.write(@todo_path, lines.join)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def append_learnings(new_learnings)
|
|
21
|
+
return if new_learnings.empty?
|
|
22
|
+
|
|
23
|
+
existing_content = File.exist?(@learnings_path) ? File.read(@learnings_path) : ""
|
|
24
|
+
existing_learnings = existing_content.lines.map(&:strip).reject(&:empty?)
|
|
25
|
+
|
|
26
|
+
unique_learnings = new_learnings.reject do |learning|
|
|
27
|
+
existing_learnings.any? { |existing| existing.include?(learning) || learning.include?(existing) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return if unique_learnings.empty?
|
|
31
|
+
|
|
32
|
+
timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
33
|
+
new_section = "\n\n## Learnings - #{timestamp}\n\n"
|
|
34
|
+
unique_learnings.each do |learning|
|
|
35
|
+
new_section += "- #{learning}\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
File.write(@learnings_path, existing_content.rstrip + new_section + "\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def todo_empty?
|
|
42
|
+
return true unless File.exist?(@todo_path)
|
|
43
|
+
|
|
44
|
+
content = File.read(@todo_path)
|
|
45
|
+
content.lines.none? { |line| line.match?(/^[-*] \[ \]/i) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/rralph/git.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
3
|
+
module Rralph
|
|
4
|
+
class Git
|
|
5
|
+
def initialize
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def in_git_repo?
|
|
9
|
+
result = `git rev-parse --git-dir 2>/dev/null`
|
|
10
|
+
$?.success?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def commit_changes(message)
|
|
14
|
+
add_result = `git add . 2>&1`
|
|
15
|
+
unless $?.success?
|
|
16
|
+
raise GitError, "Failed to stage files: #{add_result}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
commit_result = `git commit -m #{message.shellescape} 2>&1`
|
|
20
|
+
unless $?.success?
|
|
21
|
+
raise GitError, "Failed to commit: #{commit_result}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sha = `git rev-parse --short HEAD 2>/dev/null`.strip
|
|
25
|
+
sha
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status
|
|
29
|
+
`git status --short 2>/dev/null`
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def has_changes?
|
|
33
|
+
status = `git status --porcelain 2>/dev/null`
|
|
34
|
+
!status.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module Rralph
|
|
2
|
+
class Parser
|
|
3
|
+
attr_reader :plan_content, :learnings_content, :todo_content
|
|
4
|
+
|
|
5
|
+
def initialize(plan_path: "plan.md", learnings_path: "learnings.md", todo_path: "todo.md")
|
|
6
|
+
@plan_path = plan_path
|
|
7
|
+
@learnings_path = learnings_path
|
|
8
|
+
@todo_path = todo_path
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def load_files
|
|
12
|
+
raise FileNotFound, "plan.md not found" unless File.exist?(@plan_path)
|
|
13
|
+
|
|
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) : ""
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pending_tasks
|
|
20
|
+
return [] unless @todo_content
|
|
21
|
+
|
|
22
|
+
@todo_content.lines.map.with_index do |line, index|
|
|
23
|
+
stripped = line.strip
|
|
24
|
+
next unless stripped.start_with?("- [ ]") || stripped.start_with?("* [ ]")
|
|
25
|
+
|
|
26
|
+
task_text = stripped.sub(/^[-*] \[ \] /, "").strip
|
|
27
|
+
{ index: index, line: line, text: task_text, raw: stripped }
|
|
28
|
+
end.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def completed_tasks
|
|
32
|
+
return [] unless @todo_content
|
|
33
|
+
|
|
34
|
+
@todo_content.lines.map.with_index do |line, index|
|
|
35
|
+
stripped = line.strip
|
|
36
|
+
next unless stripped.start_with?("- [x]") || stripped.start_with?("* [x]")
|
|
37
|
+
|
|
38
|
+
task_text = stripped.sub(/^[-*] \[x\] /i, "").strip
|
|
39
|
+
{ index: index, line: line, text: task_text, raw: stripped }
|
|
40
|
+
end.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def all_tasks
|
|
44
|
+
return [] unless @todo_content
|
|
45
|
+
|
|
46
|
+
@todo_content.lines.map.with_index do |line, index|
|
|
47
|
+
stripped = line.strip
|
|
48
|
+
next unless stripped.match?(/^[-*] \[[ x]\]/i)
|
|
49
|
+
|
|
50
|
+
completed = stripped.match?(/^[-*] \[x\]/i)
|
|
51
|
+
task_text = stripped.sub(/^[-*] \[[ x]\] /i, "").strip
|
|
52
|
+
{ index: index, line: line, text: task_text, raw: stripped, completed: completed }
|
|
53
|
+
end.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def has_pending_tasks?
|
|
57
|
+
pending_tasks.any?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def failure_detected?(response)
|
|
61
|
+
response.match?(/\bFAILURE\b/i)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_learnings(response)
|
|
65
|
+
learnings = []
|
|
66
|
+
|
|
67
|
+
# Match various learning patterns like:
|
|
68
|
+
# "Learning: xyz", "**Learning to add:** xyz", "- Learning: xyz", etc.
|
|
69
|
+
response.lines.each do |line|
|
|
70
|
+
if match = line.match(/(?:^|\s)(?:\*\*)?(?:Learning|Insight|Note|Tip|Discovered|Found|Realized)[\s*]*[:\s]*(?:to add)?[:\s]*(.+?)(?:\*\*)?(?:\s*$)/i)
|
|
71
|
+
learning = match[1].strip
|
|
72
|
+
learning = learning.gsub(/^\*\*|\*\*$/, "").strip
|
|
73
|
+
learnings << learning unless learning.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Also extract from ## Learnings sections
|
|
78
|
+
if response.match?(/##?\s+Learnings/i)
|
|
79
|
+
section = response.split(/##?\s+Learnings/i)[1]
|
|
80
|
+
section = section.split(/##?\s+/)[0] if section.match?(/##?\s+/)
|
|
81
|
+
section.lines.each do |line|
|
|
82
|
+
stripped = line.strip
|
|
83
|
+
next if stripped.empty?
|
|
84
|
+
stripped = stripped.sub(/^[-*]\s*/, "")
|
|
85
|
+
learnings << stripped unless stripped.empty?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
learnings.uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_prompt(current_task: nil)
|
|
93
|
+
<<~PROMPT
|
|
94
|
+
You are in an iterative development loop. There is a todo list with tasks.
|
|
95
|
+
|
|
96
|
+
YOUR CURRENT TASK (the first unchecked item in todo.md):
|
|
97
|
+
> #{current_task}
|
|
98
|
+
|
|
99
|
+
INSTRUCTIONS - Follow these steps in order:
|
|
100
|
+
1. Implement ONLY the task shown above
|
|
101
|
+
2. Write a unit test for it
|
|
102
|
+
3. Run the test
|
|
103
|
+
4. Respond with exactly one of:
|
|
104
|
+
- "DONE" if the task is complete and test passes
|
|
105
|
+
- "FAILURE" if the test fails after your best effort
|
|
106
|
+
5. Optionally add learnings as: "Learning: <insight>"
|
|
107
|
+
|
|
108
|
+
IMPORTANT RULES:
|
|
109
|
+
- Work on ONE task only - the one shown above
|
|
110
|
+
- Do NOT implement other tasks from the todo list
|
|
111
|
+
- Do NOT mark tasks as done yourself
|
|
112
|
+
- After you respond "DONE", the system will mark this task complete
|
|
113
|
+
- Then you will receive the next task
|
|
114
|
+
|
|
115
|
+
--- plan.md (context) ---
|
|
116
|
+
#{@plan_content}
|
|
117
|
+
|
|
118
|
+
--- learnings.md (prior knowledge) ---
|
|
119
|
+
#{@learnings_content}
|
|
120
|
+
|
|
121
|
+
--- todo.md (full list - work on first unchecked only) ---
|
|
122
|
+
#{@todo_content}
|
|
123
|
+
PROMPT
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
module Rralph
|
|
2
|
+
class Runner
|
|
3
|
+
attr_reader :cycle_count, :failure_count, :max_failures
|
|
4
|
+
|
|
5
|
+
def initialize(
|
|
6
|
+
max_failures: 3,
|
|
7
|
+
ai_command: "qwen-code -y -s",
|
|
8
|
+
watch: false,
|
|
9
|
+
plan_path: "plan.md",
|
|
10
|
+
learnings_path: "learnings.md",
|
|
11
|
+
todo_path: "todo.md"
|
|
12
|
+
)
|
|
13
|
+
@max_failures = max_failures
|
|
14
|
+
@ai_command = ai_command
|
|
15
|
+
@watch = watch
|
|
16
|
+
@plan_path = plan_path
|
|
17
|
+
@learnings_path = learnings_path
|
|
18
|
+
@todo_path = todo_path
|
|
19
|
+
|
|
20
|
+
@cycle_count = 0
|
|
21
|
+
@failure_count = 0
|
|
22
|
+
|
|
23
|
+
@parser = Parser.new(
|
|
24
|
+
plan_path: @plan_path,
|
|
25
|
+
learnings_path: @learnings_path,
|
|
26
|
+
todo_path: @todo_path
|
|
27
|
+
)
|
|
28
|
+
@file_updater = FileUpdater.new(
|
|
29
|
+
todo_path: @todo_path,
|
|
30
|
+
learnings_path: @learnings_path
|
|
31
|
+
)
|
|
32
|
+
@git = Git.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
log("Starting rralph with max_failures=#{@max_failures}, ai_command='#{@ai_command}'")
|
|
37
|
+
|
|
38
|
+
unless @git.in_git_repo?
|
|
39
|
+
raise GitError, "Not in a git repository. Please initialize git first."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@parser.load_files
|
|
43
|
+
|
|
44
|
+
if todo_empty_or_missing?
|
|
45
|
+
log("todo.md is empty or missing. Generating todo list from plan...")
|
|
46
|
+
generate_todo_from_plan
|
|
47
|
+
return true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
unless @parser.has_pending_tasks?
|
|
51
|
+
log("All tasks completed! Well done!")
|
|
52
|
+
return true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if @watch
|
|
56
|
+
run_watch_loop
|
|
57
|
+
else
|
|
58
|
+
run_single_cycle
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def run_watch_loop
|
|
65
|
+
loop do
|
|
66
|
+
success = run_single_cycle
|
|
67
|
+
break unless success
|
|
68
|
+
break unless @parser.has_pending_tasks?
|
|
69
|
+
break if @failure_count >= @max_failures
|
|
70
|
+
|
|
71
|
+
sleep 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
check_final_state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_single_cycle
|
|
78
|
+
@cycle_count += 1
|
|
79
|
+
|
|
80
|
+
@parser.load_files
|
|
81
|
+
|
|
82
|
+
unless @parser.has_pending_tasks?
|
|
83
|
+
log("All tasks completed! Well done!")
|
|
84
|
+
return false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
pending = @parser.pending_tasks
|
|
88
|
+
current_task = pending.first
|
|
89
|
+
|
|
90
|
+
log("Cycle #{@cycle_count}: Processing task: #{current_task[:text]}")
|
|
91
|
+
|
|
92
|
+
prompt = @parser.build_prompt(current_task: current_task[:text])
|
|
93
|
+
response = execute_ai_command(prompt)
|
|
94
|
+
|
|
95
|
+
# Always save AI request/response to logs for debugging
|
|
96
|
+
save_ai_log(prompt, response)
|
|
97
|
+
|
|
98
|
+
if response.nil?
|
|
99
|
+
@failure_count += 1
|
|
100
|
+
log("❌ [Cycle #{@cycle_count}] AI command failed. Failures: #{@failure_count}/#{@max_failures}")
|
|
101
|
+
return handle_failure
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if @parser.failure_detected?(response)
|
|
105
|
+
@failure_count += 1
|
|
106
|
+
log("❌ [Cycle #{@cycle_count}] FAILURE detected. Failures: #{@failure_count}/#{@max_failures}")
|
|
107
|
+
return handle_failure
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@failure_count = 0
|
|
111
|
+
log("✅ [Cycle #{@cycle_count}] Task completed. #{@failure_count} failures.")
|
|
112
|
+
|
|
113
|
+
@file_updater.mark_task_completed(current_task[:index])
|
|
114
|
+
|
|
115
|
+
new_learnings = @parser.extract_learnings(response)
|
|
116
|
+
@file_updater.append_learnings(new_learnings) if new_learnings.any?
|
|
117
|
+
|
|
118
|
+
commit_message = "rralph: completed task and updated artifacts [cycle #{@cycle_count}]"
|
|
119
|
+
sha = @git.commit_changes(commit_message)
|
|
120
|
+
|
|
121
|
+
if sha
|
|
122
|
+
log(" Git commit: #{sha}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_failure
|
|
129
|
+
if @failure_count >= @max_failures
|
|
130
|
+
log("Max failures reached (#{@failure_count}). Stopping to avoid infinite loops. Review learnings.md and todo.md.")
|
|
131
|
+
exit 1
|
|
132
|
+
end
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check_final_state
|
|
137
|
+
if @failure_count >= @max_failures
|
|
138
|
+
exit 1
|
|
139
|
+
elsif !@parser.has_pending_tasks?
|
|
140
|
+
log("All tasks completed! Well done!")
|
|
141
|
+
exit 0
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execute_ai_command(prompt)
|
|
146
|
+
require "tempfile"
|
|
147
|
+
|
|
148
|
+
# Write prompt to a temporary file
|
|
149
|
+
prompt_file = Tempfile.new(["rralph_prompt", ".txt"])
|
|
150
|
+
prompt_file.write(prompt)
|
|
151
|
+
prompt_file.close
|
|
152
|
+
|
|
153
|
+
# Read response from a temporary file
|
|
154
|
+
response_file = Tempfile.new(["rralph_response", ".txt"])
|
|
155
|
+
response_file.close
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
# Use bash -c with proper stdin redirection
|
|
159
|
+
cmd = "bash -c #{@ai_command.shellescape} < #{prompt_file.path} > #{response_file.path} 2>&1"
|
|
160
|
+
log(" Executing: #{@ai_command}")
|
|
161
|
+
system(cmd)
|
|
162
|
+
|
|
163
|
+
log(" Command exit status: #{$?.exitstatus}")
|
|
164
|
+
|
|
165
|
+
response = File.read(response_file.path)
|
|
166
|
+
response.strip.empty? ? nil : response
|
|
167
|
+
ensure
|
|
168
|
+
prompt_file.unlink
|
|
169
|
+
response_file.unlink
|
|
170
|
+
end
|
|
171
|
+
rescue Errno::ENOENT => e
|
|
172
|
+
log("Error: AI command '#{@ai_command}' not found: #{e.message}")
|
|
173
|
+
nil
|
|
174
|
+
rescue => e
|
|
175
|
+
log("Error executing AI command: #{e.message}")
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def save_ai_log(prompt, response)
|
|
180
|
+
logs_dir = "logs"
|
|
181
|
+
Dir.mkdir(logs_dir) unless Dir.exist?(logs_dir)
|
|
182
|
+
|
|
183
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
184
|
+
filename = "#{logs_dir}/cycle_#{@cycle_count}_#{timestamp}.md"
|
|
185
|
+
|
|
186
|
+
# Extract just the current task for the header
|
|
187
|
+
task_match = prompt.match(/YOUR CURRENT TASK.*?>\s*(.+?)\n/)
|
|
188
|
+
task_text = task_match ? task_match[1].strip : "Unknown"
|
|
189
|
+
|
|
190
|
+
content = <<~LOG
|
|
191
|
+
# Cycle #{@cycle_count} - #{timestamp}
|
|
192
|
+
|
|
193
|
+
## Current Task
|
|
194
|
+
#{task_text}
|
|
195
|
+
|
|
196
|
+
## AI Response
|
|
197
|
+
|
|
198
|
+
#{response || "(no response)"}
|
|
199
|
+
LOG
|
|
200
|
+
|
|
201
|
+
File.write(filename, content)
|
|
202
|
+
log(" Log saved: #{filename}")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def log(message)
|
|
206
|
+
$stderr.puts(message)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def todo_empty_or_missing?
|
|
210
|
+
return true unless File.exist?(@todo_path)
|
|
211
|
+
|
|
212
|
+
content = File.read(@todo_path)
|
|
213
|
+
content.strip.empty? || !content.match?(/^- \[ \]/m)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def generate_todo_from_plan
|
|
217
|
+
prompt = <<~PROMPT
|
|
218
|
+
Based on the following plan, generate a todo list with actionable tasks.
|
|
219
|
+
Format each task as a markdown checkbox like: - [ ] Task description
|
|
220
|
+
Keep tasks specific and actionable.
|
|
221
|
+
|
|
222
|
+
--- plan.md ---
|
|
223
|
+
#{@parser.plan_content}
|
|
224
|
+
PROMPT
|
|
225
|
+
|
|
226
|
+
response = execute_ai_command(prompt)
|
|
227
|
+
|
|
228
|
+
if response
|
|
229
|
+
todo_items = response.scan(/^- \[ \] .+$/).uniq
|
|
230
|
+
|
|
231
|
+
if todo_items.any?
|
|
232
|
+
todo_content = "# Todo List\n\n" + todo_items.join("\n") + "\n"
|
|
233
|
+
|
|
234
|
+
File.write(@todo_path, todo_content)
|
|
235
|
+
|
|
236
|
+
commit_message = "rralph: generated todo from plan.md"
|
|
237
|
+
sha = @git.commit_changes(commit_message)
|
|
238
|
+
|
|
239
|
+
if sha
|
|
240
|
+
log("✅ Generated #{todo_items.size} tasks. Git commit: #{sha}")
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
log("❌ Could not parse tasks from AI response")
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
log("❌ AI command failed when generating todo")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/lib/rralph.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Rralph
|
|
2
|
+
VERSION = "0.1.0"
|
|
3
|
+
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class FileNotFound < Error; end
|
|
6
|
+
class GitError < Error; end
|
|
7
|
+
class AICommandError < Error; end
|
|
8
|
+
end
|
|
9
|
+
|
|
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"
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rralph
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- rralph
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.5'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.5'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.13'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.13'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: solargraph
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.58'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.58'
|
|
68
|
+
description: rralph automates an iterative, AI-assisted development workflow by reading
|
|
69
|
+
plan.md, learnings.md, and todo.md, then orchestrating AI tool invocations to complete
|
|
70
|
+
tasks.
|
|
71
|
+
email:
|
|
72
|
+
- david@joynetiks.com
|
|
73
|
+
executables:
|
|
74
|
+
- rralph
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- LICENSE
|
|
79
|
+
- README.md
|
|
80
|
+
- bin/rralph
|
|
81
|
+
- lib/rralph.rb
|
|
82
|
+
- lib/rralph/cli.rb
|
|
83
|
+
- lib/rralph/file_updater.rb
|
|
84
|
+
- lib/rralph/git.rb
|
|
85
|
+
- lib/rralph/parser.rb
|
|
86
|
+
- lib/rralph/runner.rb
|
|
87
|
+
homepage: https://github.com/pcboy/rralph
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: 3.4.0
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 3.7.2
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: A self-improving task orchestrator for AI-assisted development
|
|
109
|
+
test_files: []
|