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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fee5ec363c3f2bef533634cc0959815995c58ab6427496d0a14c381371a6ced
4
- data.tar.gz: c505d1dbe57ba55bbd905130713de18927fa54528a24609baf253a39a8ac7823
3
+ metadata.gz: 2e6f657f4d4105579bcc44c846bb182d9f6637fc6a530fdf01490c51b994cf0e
4
+ data.tar.gz: b4ad22a14bb0de01d4ba50399fe2e4ed867a40ecc274c9a865ae8c948e8dcfa0
5
5
  SHA512:
6
- metadata.gz: efea6825e59d661ff2e9023a4cbab82a6d10b0f713e5a63bfd070ab8ae12dff087091485b5b2ecfbd95d16b830278a0f153d59a01ccebfa763ed3b75d699103d
7
- data.tar.gz: c1fdb135c8599692a362c16832ce68096d8ca064098dcd17fae1d6d227a7c2eccecd40ac8de42944898ce71b2e1ef2d8d12c7487eedacaa52521f1b58b1d8dda
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
- - `FAILURE` keyword (case-insensitive, whole word)
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 `FAILURE` response increments a counter
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
- Human-readable logs are output to stderr:
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
- [Cycle 4] Task completed. 0 failures. Git commit: abc123
179
- [Cycle 5] FAILURE detected. Failures: 2/3
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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require_relative "../lib/rralph"
3
+ require_relative '../lib/rralph'
4
4
 
5
5
  Rralph::CLI.start(ARGV)
data/lib/rralph/cli.rb CHANGED
@@ -1,44 +1,49 @@
1
- require "thor"
2
- require_relative "runner"
1
+ require 'thor'
2
+ require_relative 'runner'
3
3
 
4
4
  module Rralph
5
5
  class CLI < Thor
6
- desc "start", "Run the rralph orchestrator"
6
+ desc 'start', 'Run the rralph orchestrator'
7
7
  method_option :max_failures,
8
8
  type: :numeric,
9
9
  default: 3,
10
- aliases: "-m",
11
- desc: "Maximum allowed failures before stopping"
10
+ aliases: '-m',
11
+ desc: 'Maximum allowed failures before stopping'
12
12
  method_option :ai_command,
13
13
  type: :string,
14
- default: "qwen-code -y -s",
15
- aliases: "-a",
16
- desc: "AI command to invoke"
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: "-w",
21
- desc: "Run in continuous loop until completion or max failures"
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: "plan.md",
25
- aliases: "-p",
26
- desc: "Path to plan.md file"
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: "learnings.md",
30
- aliases: "-l",
31
- desc: "Path to learnings.md file"
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: "todo.md",
35
- aliases: "-t",
36
- desc: "Path to todo.md file"
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: "-s",
41
- desc: "Skip git commits between tasks"
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
- $stderr.puts "Error: #{e.message}"
57
- $stderr.puts "Please ensure plan.md, learnings.md, and todo.md exist in the current directory."
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
- $stderr.puts "Git Error: #{e.message}"
66
+ warn "Git Error: #{e.message}"
61
67
  exit 1
62
- rescue => e
63
- $stderr.puts "Unexpected error: #{e.message}"
64
- $stderr.puts e.backtrace.first(5)
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 "version", "Show rralph version"
74
+ desc 'version', 'Show rralph version'
69
75
  def version
70
76
  puts "rralph v#{Rralph::VERSION}"
71
77
  end
72
78
 
73
- desc "stats", "Show progress statistics"
79
+ desc 'stats', 'Show progress statistics'
74
80
  method_option :plan_path,
75
81
  type: :string,
76
- default: "plan.md",
77
- aliases: "-p"
82
+ default: 'plan.md',
83
+ aliases: '-p'
78
84
  method_option :learnings_path,
79
85
  type: :string,
80
- default: "learnings.md",
81
- aliases: "-l"
86
+ default: 'learnings.md',
87
+ aliases: '-l'
82
88
  method_option :todo_path,
83
89
  type: :string,
84
- default: "todo.md",
85
- aliases: "-t"
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
- $stderr.puts "Error: #{e.message}"
110
+ warn "Error: #{e.message}"
105
111
  exit 1
106
112
  end
107
113
 
@@ -1,6 +1,6 @@
1
1
  module Rralph
2
2
  class FileUpdater
3
- def initialize(todo_path: "todo.md", learnings_path: "learnings.md")
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
- if line
14
- updated_line = line.gsub(/^([-*]) \[ \]/, '\1 [x]')
15
- lines[task_index] = updated_line
16
- File.write(@todo_path, lines.join)
17
- end
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.strftime("%Y-%m-%d %H:%M:%S")
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 "shellwords"
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
- result = `git rev-parse --git-dir 2>/dev/null`
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
- sha = `git rev-parse --short HEAD 2>/dev/null`.strip
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: "plan.md", learnings_path: "learnings.md", todo_path: "todo.md")
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, "plan.md not found" unless File.exist?(@plan_path)
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?("- [ ]") || stripped.start_with?("* [ ]")
24
+ next unless stripped.start_with?('- [ ]') || stripped.start_with?('* [ ]')
25
25
 
26
- task_text = stripped.sub(/^[-*] \[ \] /, "").strip
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?("- [x]") || stripped.start_with?("* [x]")
36
+ next unless stripped.start_with?('- [x]') || stripped.start_with?('* [x]')
37
37
 
38
- task_text = stripped.sub(/^[-*] \[x\] /i, "").strip
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, "").strip
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?(/\bFAILURE\b/i)
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
- 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
+ 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
- stripped = stripped.sub(/^[-*]\s*/, "")
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
- - "DONE" if the task is complete and test passes
105
- - "FAILURE" if the test fails after your best effort
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 "DONE", the system will mark this task complete
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: "qwen-code -y -s",
10
+ ai_command: 'qwen-code -y -s',
8
11
  watch: false,
9
- plan_path: "plan.md",
10
- learnings_path: "learnings.md",
11
- todo_path: "todo.md",
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("todo.md is empty or missing. Generating todo list from plan...")
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("All tasks completed! Well done!")
56
+ log('All tasks completed! Well done!')
54
57
  return true
55
58
  end
56
59
 
57
- if @watch
58
- run_watch_loop
59
- else
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("All tasks completed! Well done!")
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}] FAILURE detected. Failures: #{@failure_count}/#{@max_failures}")
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("⏭️ [Cycle #{@cycle_count}] Skipping commit (skip_commit enabled)")
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("All tasks completed! Well done!")
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
- require "tempfile"
155
+ full_response = ''
153
156
 
154
- # Write prompt to a temporary file
155
- prompt_file = Tempfile.new(["rralph_prompt", ".txt"])
156
- prompt_file.write(prompt)
157
- prompt_file.close
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
- # Read response from a temporary file
160
- response_file = Tempfile.new(["rralph_response", ".txt"])
161
- response_file.close
219
+ log(" Command exit status: #{wait_thr.value.exitstatus}")
220
+ end
162
221
 
163
- begin
164
- # Use bash -c with proper stdin redirection
165
- cmd = "bash -c #{@ai_command.shellescape} < #{prompt_file.path} > #{response_file.path} 2>&1"
166
- log(" Executing: #{@ai_command}")
167
- system(cmd)
168
-
169
- log(" Command exit status: #{$?.exitstatus}")
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 = "logs"
233
+ logs_dir = 'logs'
187
234
  Dir.mkdir(logs_dir) unless Dir.exist?(logs_dir)
188
235
 
189
- timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
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 : "Unknown"
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 || "(no 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
- $stderr.puts(message)
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? || !content.match?(/^- \[ \]/m)
266
+ content.strip.empty?
220
267
  end
221
268
 
222
269
  def generate_todo_from_plan
223
- prompt = <<~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
- response = execute_ai_command(prompt)
233
-
234
- if response
235
- todo_items = response.scan(/^- \[ \] .+$/).uniq
293
+ def extract_todo_items(response)
294
+ response.scan(/^- \[ \] .+$/).uniq
295
+ end
236
296
 
237
- if todo_items.any?
238
- todo_content = "# Todo List\n\n" + todo_items.join("\n") + "\n"
297
+ def save_todo_file(todo_items)
298
+ todo_content = <<~TODO
299
+ # Todo List
239
300
 
240
- File.write(@todo_path, todo_content)
301
+ #{todo_items.join("\n")}
302
+ TODO
241
303
 
242
- if @skip_commit
243
- log("⏭️ Generated #{todo_items.size} tasks (skip_commit enabled, no commit)")
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
- if sha
249
- log("✅ Generated #{todo_items.size} tasks. Git commit: #{sha}")
250
- end
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
- log("❌ AI command failed when generating todo")
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 = "0.1.3"
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 "rralph/parser"
11
- require_relative "rralph/file_updater"
12
- require_relative "rralph/git"
13
- require_relative "rralph/runner"
14
- require_relative "rralph/cli"
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 CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rralph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rralph