ares-runtime 2.0.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +110 -0
- data/config/models.yml +25 -0
- data/config/ollama.yml +4 -0
- data/config/workspaces.yml +6 -0
- data/exe/ares +75 -0
- data/lib/ares/cli.rb +37 -0
- data/lib/ares/runtime/adapters/base_adapter.rb +68 -0
- data/lib/ares/runtime/adapters/claude_adapter.rb +35 -0
- data/lib/ares/runtime/adapters/codex_adapter.rb +35 -0
- data/lib/ares/runtime/adapters/cursor_adapter.rb +32 -0
- data/lib/ares/runtime/adapters/ollama_adapter.rb +37 -0
- data/lib/ares/runtime/config_cli.rb +18 -0
- data/lib/ares/runtime/config_manager.rb +137 -0
- data/lib/ares/runtime/context_loader.rb +45 -0
- data/lib/ares/runtime/core_subsystem.rb +36 -0
- data/lib/ares/runtime/diagnostic_parser.rb +159 -0
- data/lib/ares/runtime/doctor.rb +34 -0
- data/lib/ares/runtime/engine_chain.rb +108 -0
- data/lib/ares/runtime/git_manager.rb +26 -0
- data/lib/ares/runtime/initializer.rb +30 -0
- data/lib/ares/runtime/logs_cli.rb +35 -0
- data/lib/ares/runtime/model_selector.rb +36 -0
- data/lib/ares/runtime/ollama_client_factory.rb +43 -0
- data/lib/ares/runtime/planner/ollama_planner.rb +51 -0
- data/lib/ares/runtime/planner/tiny_task_processor.rb +129 -0
- data/lib/ares/runtime/prompt_builder.rb +52 -0
- data/lib/ares/runtime/quota_manager.rb +48 -0
- data/lib/ares/runtime/router.rb +285 -0
- data/lib/ares/runtime/task_logger.rb +37 -0
- data/lib/ares/runtime/task_manager.rb +9 -0
- data/lib/ares/runtime/terminal_runner.rb +37 -0
- data/lib/ares/runtime/tui.rb +211 -0
- data/lib/ares/runtime/version.rb +7 -0
- data/lib/ares/runtime.rb +5 -0
- data/lib/ares_runtime.rb +63 -0
- metadata +240 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ollama_client'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require_relative '../config_manager'
|
|
6
|
+
require_relative '../ollama_client_factory'
|
|
7
|
+
|
|
8
|
+
module Ares
|
|
9
|
+
module Runtime
|
|
10
|
+
class TinyTaskProcessor
|
|
11
|
+
MAX_SUMMARY_INPUT = 5_000
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
HARD_TIMEOUT = 35
|
|
14
|
+
|
|
15
|
+
def initialize(healthy: true)
|
|
16
|
+
@healthy = healthy
|
|
17
|
+
@client = healthy ? OllamaClientFactory.build(timeout_seconds: DEFAULT_TIMEOUT) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def summarize_output(output, type: :test)
|
|
21
|
+
return safe_summary_fallback('Safe Mode: Ollama skipped') unless @healthy
|
|
22
|
+
|
|
23
|
+
truncated = filter_and_truncate(output, type)
|
|
24
|
+
prompt = build_summary_prompt(truncated, type)
|
|
25
|
+
|
|
26
|
+
OllamaClientFactory.with_resilience(
|
|
27
|
+
hard_timeout: HARD_TIMEOUT,
|
|
28
|
+
fallback_value: safe_summary_fallback('Summary failed')
|
|
29
|
+
) do
|
|
30
|
+
@client.generate(prompt: prompt, schema: summary_schema)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summarize_diff(diff)
|
|
35
|
+
return safe_diff_fallback('Safe Mode: Ollama skipped') unless @healthy
|
|
36
|
+
|
|
37
|
+
OllamaClientFactory.with_resilience(
|
|
38
|
+
hard_timeout: HARD_TIMEOUT,
|
|
39
|
+
fallback_value: safe_diff_fallback('Diff summary failed')
|
|
40
|
+
) do
|
|
41
|
+
@client.generate(prompt: build_diff_prompt(diff), schema: diff_schema)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def filter_and_truncate(output, type)
|
|
48
|
+
filtered = filter_output(output, type)
|
|
49
|
+
return filtered if filtered.length <= MAX_SUMMARY_INPUT
|
|
50
|
+
|
|
51
|
+
"#{filtered[0, MAX_SUMMARY_INPUT]}\n\n[... output truncated ...]"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_summary_prompt(truncated, type)
|
|
55
|
+
instruction = case type
|
|
56
|
+
when :lint then 'Summarize RuboCop offenses. Extract file paths and line numbers:'
|
|
57
|
+
when :syntax then 'Summarize Ruby syntax errors. Extract file paths and line numbers:'
|
|
58
|
+
else 'Summarize RSpec failures. Extract file paths and line numbers:'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
PromptBuilder.new
|
|
62
|
+
.add_instruction(instruction)
|
|
63
|
+
.add_instruction(truncated)
|
|
64
|
+
.build
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_diff_prompt(diff)
|
|
68
|
+
PromptBuilder.new
|
|
69
|
+
.add_instruction('Summarize the following git diff. Identify modified files, describe the core changes, and assess the risk level.')
|
|
70
|
+
.add_instruction(diff)
|
|
71
|
+
.build
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def summary_schema
|
|
75
|
+
{
|
|
76
|
+
'type' => 'object',
|
|
77
|
+
'required' => %w[failed_items error_summary files],
|
|
78
|
+
'properties' => {
|
|
79
|
+
'failed_items' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
|
|
80
|
+
'error_summary' => { 'type' => 'string' },
|
|
81
|
+
'files' => {
|
|
82
|
+
'type' => 'array',
|
|
83
|
+
'items' => {
|
|
84
|
+
'type' => 'object',
|
|
85
|
+
'required' => %w[path line],
|
|
86
|
+
'properties' => { 'path' => { 'type' => 'string' }, 'line' => { 'type' => 'integer' } }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def diff_schema
|
|
94
|
+
{
|
|
95
|
+
'type' => 'object',
|
|
96
|
+
'required' => %w[modified_files change_summary risk_level],
|
|
97
|
+
'properties' => {
|
|
98
|
+
'modified_files' => { 'type' => 'array', 'items' => { 'type' => 'string' } },
|
|
99
|
+
'change_summary' => { 'type' => 'string' },
|
|
100
|
+
'risk_level' => { 'type' => 'string', 'enum' => %w[low medium high] }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def safe_summary_fallback(reason)
|
|
106
|
+
{ 'failed_items' => [], 'error_summary' => "Safe Mode: #{reason}", 'files' => [] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def safe_diff_fallback(reason)
|
|
110
|
+
{ 'modified_files' => [], 'change_summary' => "Safe Mode: #{reason}", 'risk_level' => 'medium' }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def filter_output(output, type)
|
|
114
|
+
lines = output.split("\n")
|
|
115
|
+
case type
|
|
116
|
+
when :lint
|
|
117
|
+
lines.grep(/:\d+:\d+: [CW]: /).first(20).join("\n")
|
|
118
|
+
when :test
|
|
119
|
+
lines.select do |l|
|
|
120
|
+
l.match?(/\d+\)\s/) || l.match?(%r{\s+Failure/Error:}) ||
|
|
121
|
+
l.match?(/\s+expected:/) || l.match?(/\s+got:/) || l.match?(/\.rb:\d+/)
|
|
122
|
+
end.first(50).join("\n")
|
|
123
|
+
else
|
|
124
|
+
lines.first(100).join("\n")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ares
|
|
4
|
+
module Runtime
|
|
5
|
+
# Implements the Builder Pattern for constructing complex LLM prompts
|
|
6
|
+
class PromptBuilder
|
|
7
|
+
def initialize
|
|
8
|
+
@sections = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add_context(context)
|
|
12
|
+
@sections << context unless context.to_s.strip.empty?
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_task(task_description)
|
|
17
|
+
@sections << "TASK:\n#{task_description}"
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_diagnostic(type, failed_items, error_summary)
|
|
22
|
+
@sections << <<~DIAG.strip
|
|
23
|
+
DIAGNOSTIC SUMMARY (#{type.to_s.upcase}):
|
|
24
|
+
Failed Items: #{Array(failed_items).join(', ')}
|
|
25
|
+
Error: #{error_summary}
|
|
26
|
+
DIAG
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add_files(files)
|
|
31
|
+
return self if files.nil? || files.empty?
|
|
32
|
+
|
|
33
|
+
files_content = Array(files).filter_map do |f|
|
|
34
|
+
path = File.expand_path(f['path'], Dir.pwd)
|
|
35
|
+
"--- FILE: #{f['path']} ---\n#{File.read(path)}" if File.exist?(path)
|
|
36
|
+
end.join("\n\n")
|
|
37
|
+
|
|
38
|
+
@sections << "FAILING FILE CONTENTS:\n#{files_content}" unless files_content.empty?
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_instruction(instruction)
|
|
43
|
+
@sections << instruction unless instruction.to_s.strip.empty?
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build
|
|
48
|
+
@sections.join("\n\n")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ares
|
|
4
|
+
module Runtime
|
|
5
|
+
class QuotaManager
|
|
6
|
+
GLOBAL_DIR = File.expand_path('~/.ares')
|
|
7
|
+
QUOTA_FILE = File.expand_path("#{GLOBAL_DIR}/.quota.json", __dir__)
|
|
8
|
+
LIMITS = { claude: 50, codex: 100 }.freeze
|
|
9
|
+
|
|
10
|
+
def self.usage
|
|
11
|
+
data = load_data
|
|
12
|
+
today = Date.today.to_s
|
|
13
|
+
{
|
|
14
|
+
claude: data[today] || 0,
|
|
15
|
+
codex: 0 # Tracked separately or placeholder
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.increment_usage(engine)
|
|
20
|
+
return unless engine == :claude
|
|
21
|
+
|
|
22
|
+
data = load_data
|
|
23
|
+
today = Date.today.to_s
|
|
24
|
+
|
|
25
|
+
data[today] ||= 0
|
|
26
|
+
data[today] += 1
|
|
27
|
+
|
|
28
|
+
File.write(QUOTA_FILE, JSON.pretty_generate(data))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.remaining_quota
|
|
32
|
+
LIMITS[:claude] - usage[:claude]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.quota_exceeded?
|
|
36
|
+
remaining_quota <= 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.load_data
|
|
40
|
+
return {} unless File.exist?(QUOTA_FILE)
|
|
41
|
+
|
|
42
|
+
JSON.parse(File.read(QUOTA_FILE))
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ares
|
|
4
|
+
module Runtime
|
|
5
|
+
class Router
|
|
6
|
+
def initialize
|
|
7
|
+
@core = CoreSubsystem.new
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run(task, options = {})
|
|
11
|
+
puts "Task ID: #{@core.logger.task_id}"
|
|
12
|
+
check_quota!
|
|
13
|
+
|
|
14
|
+
@spinner = TTY::Spinner.new('[:spinner] :title', format: :dots)
|
|
15
|
+
|
|
16
|
+
shortcut_result = match_shortcut_task(task, options)
|
|
17
|
+
return shortcut_result if shortcut_result
|
|
18
|
+
|
|
19
|
+
plan = plan_task(task)
|
|
20
|
+
selection = select_model_for_plan(plan)
|
|
21
|
+
selection = handle_low_confidence(selection, plan)
|
|
22
|
+
|
|
23
|
+
return if selection.nil? # User aborted
|
|
24
|
+
|
|
25
|
+
execute_engine_task(task, plan, selection, options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_test_diagnostic(options)
|
|
31
|
+
run_diagnostic_loop('bundle exec rspec', options.merge(type: :test, title: 'Running tests'))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_syntax_check(options)
|
|
35
|
+
# Check syntax for all ruby files in lib, bin, spec individually to catch all errors
|
|
36
|
+
cmd = "ruby -e 'Dir.glob(\"{lib,bin,exe,spec}/**/*.rb\").each { |f| (puts \"Checking \#{f}\"; system(\"ruby -c \#{f}\")) or exit(1) }'"
|
|
37
|
+
run_diagnostic_loop(cmd, options.merge(type: :syntax, title: 'Checking syntax'))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_diagnostic_loop(command, options)
|
|
41
|
+
title = options[:title] || 'Running verification'
|
|
42
|
+
@spinner.update(title: "#{title}...")
|
|
43
|
+
result = nil
|
|
44
|
+
@spinner.run { result = TerminalRunner.run(command) }
|
|
45
|
+
|
|
46
|
+
if result[:exit_status].zero?
|
|
47
|
+
puts "#{title} passed! ✅"
|
|
48
|
+
return true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
type = options[:type] || :test
|
|
52
|
+
@spinner.update(title: "#{title} failed. Summarizing diagnostic output...")
|
|
53
|
+
summary = nil
|
|
54
|
+
|
|
55
|
+
@spinner.run do
|
|
56
|
+
# Try fast-path regex/JSON parsing first
|
|
57
|
+
summary = DiagnosticParser.parse(result[:output], type: type)
|
|
58
|
+
|
|
59
|
+
# Fallback to LLM only if fast-path failed to identify files
|
|
60
|
+
if summary['files'].empty? || summary['failed_items'].empty?
|
|
61
|
+
@spinner.update(title: "#{title} failed. LLM Fallback (Slow)...")
|
|
62
|
+
summary = @core.tiny_processor.summarize_output(result[:output], type: type)
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@spinner.update(title: "#{title} failed. Error in fast-path: #{e.message}. LLM Fallback...")
|
|
66
|
+
summary = @core.tiny_processor.summarize_output(result[:output], type: type)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts "\n--- Diagnostic Summary (#{type.to_s.upcase}) ---"
|
|
70
|
+
table = TTY::Table.new(header: %w[Attribute Value])
|
|
71
|
+
table << ['Failed Items', Array(summary['failed_items'] || summary['failed_tests']).join("\n")]
|
|
72
|
+
table << ['Error Summary', summary['error_summary']]
|
|
73
|
+
puts table.render(:unicode, multiline: true)
|
|
74
|
+
|
|
75
|
+
if options[:dry_run]
|
|
76
|
+
puts 'Dry run: skipping escalation.'
|
|
77
|
+
return false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Escalate to executor for fix
|
|
81
|
+
if type == :lint
|
|
82
|
+
escalate_lint_one_at_a_time(summary, options.merge(verify_command: command))
|
|
83
|
+
else
|
|
84
|
+
escalate_to_executor(summary, options.merge(verify_command: command))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def escalate_lint_one_at_a_time(summary, options)
|
|
89
|
+
max_iterations = 20
|
|
90
|
+
current_summary = summary
|
|
91
|
+
|
|
92
|
+
max_iterations.times do |iteration|
|
|
93
|
+
puts "\n--- Fix iteration #{iteration + 1}/#{max_iterations} ---" if iteration.positive?
|
|
94
|
+
|
|
95
|
+
success = escalate_to_executor(current_summary, options.merge(fix_first_only: true))
|
|
96
|
+
return true if success
|
|
97
|
+
|
|
98
|
+
@spinner.update(title: 'Re-running RuboCop...')
|
|
99
|
+
verify_result = nil
|
|
100
|
+
@spinner.run { verify_result = TerminalRunner.run(options[:verify_command]) }
|
|
101
|
+
|
|
102
|
+
return false if verify_result[:exit_status].zero?
|
|
103
|
+
|
|
104
|
+
@spinner.update(title: 'Summarizing remaining offenses...')
|
|
105
|
+
current_summary = nil
|
|
106
|
+
@spinner.run { current_summary = @core.tiny_processor.summarize_output(verify_result[:output], type: :lint) }
|
|
107
|
+
|
|
108
|
+
puts "\n--- Remaining offenses ---"
|
|
109
|
+
table = TTY::Table.new(header: %w[Attribute Value])
|
|
110
|
+
table << ['Failed Items', Array(current_summary['failed_items']).join("\n")]
|
|
111
|
+
table << ['Error Summary', current_summary['error_summary']]
|
|
112
|
+
puts table.render(:unicode, multiline: true)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
puts "\nReached max iterations (#{max_iterations}). Some offenses may remain."
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def escalate_to_executor(summary, options)
|
|
120
|
+
type = options[:type] || :test
|
|
121
|
+
verify_command = options[:verify_command] || 'bundle exec rspec'
|
|
122
|
+
fix_prompt = generate_fix_prompt(summary, options)
|
|
123
|
+
|
|
124
|
+
selection = ModelSelector.select({ 'task_type' => 'refactor', 'risk_level' => 'medium' })
|
|
125
|
+
puts "Selected Engine for fix: #{selection[:engine]} (#{selection[:model] || 'default'})"
|
|
126
|
+
|
|
127
|
+
result = apply_fix_with_fallbacks(fix_prompt, selection)
|
|
128
|
+
return false unless result
|
|
129
|
+
|
|
130
|
+
apply_patches(result) if result['patches']&.any?
|
|
131
|
+
|
|
132
|
+
@spinner.update(title: 'Verifying fix...')
|
|
133
|
+
verify_result = nil
|
|
134
|
+
@spinner.run { verify_result = TerminalRunner.run(verify_command) }
|
|
135
|
+
|
|
136
|
+
handle_verification_result(verify_result, type)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def check_quota!
|
|
140
|
+
return unless QuotaManager.quota_exceeded?
|
|
141
|
+
|
|
142
|
+
puts '❌ Quota exceeded for Claude. Please try again later or use a different engine.'
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def match_shortcut_task(task, options)
|
|
147
|
+
if task.match?(/\A(run\s+|check\s+)?(test|rspec|fix|diagnostic)(s|ing)?\s*\z/i)
|
|
148
|
+
return run_test_diagnostic(options)
|
|
149
|
+
end
|
|
150
|
+
return run_syntax_check(options) if task.match?(/\A(run\s+|check\s+)?(syntax|compile)(\s+check)?\s*\z/i)
|
|
151
|
+
return run_lint(options) if task.match?(/\A(run\s+|check\s+)?(lint|format|style)(ting|ing|s)?\s*\z/i)
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def plan_task(task)
|
|
157
|
+
plan = nil
|
|
158
|
+
@spinner.update(title: 'Planning task...')
|
|
159
|
+
@spinner.run { plan = @core.planner.plan(task) }
|
|
160
|
+
plan
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def select_model_for_plan(plan)
|
|
164
|
+
selection = nil
|
|
165
|
+
@spinner.update(title: 'Selecting optimal model...')
|
|
166
|
+
@spinner.run { selection = ModelSelector.select(plan) }
|
|
167
|
+
selection
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def handle_low_confidence(selection, plan)
|
|
171
|
+
return selection if plan['confidence'].to_f >= 0.7
|
|
172
|
+
|
|
173
|
+
prompt = TTY::Prompt.new
|
|
174
|
+
choice = prompt.select('Low confidence detected. How should we proceed?',
|
|
175
|
+
"Execute with suggested #{selection[:engine]} (#{selection[:model] || 'default'})",
|
|
176
|
+
'Override and use Claude Opus', 'Abort task')
|
|
177
|
+
|
|
178
|
+
case choice
|
|
179
|
+
when /Override/
|
|
180
|
+
puts 'Overridden: Using Claude Opus.'
|
|
181
|
+
{ engine: :claude, model: 'opus' }
|
|
182
|
+
when /Abort/
|
|
183
|
+
puts 'Task aborted by user.'
|
|
184
|
+
nil
|
|
185
|
+
else selection
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def execute_engine_task(task, plan, selection, options)
|
|
190
|
+
puts "Engine Selected: #{selection[:engine]} (#{selection[:model] || 'default'})"
|
|
191
|
+
return if options[:dry_run] && puts('--- DRY RUN MODE ---')
|
|
192
|
+
|
|
193
|
+
@core.logger.log_task(task, plan, selection)
|
|
194
|
+
GitManager.create_branch(@core.logger.task_id, task) if options[:git]
|
|
195
|
+
|
|
196
|
+
capable_engines = %w[claude codex cursor]
|
|
197
|
+
initial_engine = selection[:engine]&.to_s || 'claude'
|
|
198
|
+
fallback_chain = ([initial_engine] + (capable_engines - [initial_engine])).uniq
|
|
199
|
+
|
|
200
|
+
chain = EngineChain.build(fallback_chain)
|
|
201
|
+
prompt = PromptBuilder.new
|
|
202
|
+
.add_context(ContextLoader.load)
|
|
203
|
+
.add_task(task)
|
|
204
|
+
.build
|
|
205
|
+
|
|
206
|
+
adapter_opts = { model: selection[:model], fork_session: true, resume: true, cloud: options[:cloud] }
|
|
207
|
+
|
|
208
|
+
output = chain.call(prompt, adapter_opts, total: fallback_chain.size)
|
|
209
|
+
@core.logger.log_result(output)
|
|
210
|
+
|
|
211
|
+
GitManager.commit_changes(@core.logger.task_id, task) if options[:git]
|
|
212
|
+
puts output
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def generate_fix_prompt(summary, options)
|
|
216
|
+
type = options[:type] || :test
|
|
217
|
+
|
|
218
|
+
builder = PromptBuilder.new
|
|
219
|
+
.add_context(ContextLoader.load)
|
|
220
|
+
.add_diagnostic(type, summary['failed_items'] || summary['failed_tests'], summary['error_summary'])
|
|
221
|
+
.add_instruction("TASK: Fix the #{type} failures identifying above.")
|
|
222
|
+
|
|
223
|
+
builder.add_instruction('Fix ONLY the first offense listed.') if options[:fix_first_only]
|
|
224
|
+
|
|
225
|
+
builder.add_instruction("You MUST provide JSON with 'explanation' and 'patches' (with 'file' and 'content' fields).")
|
|
226
|
+
.add_files(summary['files'])
|
|
227
|
+
.build
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def apply_fix_with_fallbacks(fix_prompt, selection)
|
|
231
|
+
capable_engines = %w[claude codex cursor]
|
|
232
|
+
initial_engine = selection[:engine]&.to_s || 'claude'
|
|
233
|
+
fallback_chain = ([initial_engine] + (capable_engines - [initial_engine])).uniq
|
|
234
|
+
|
|
235
|
+
chain = EngineChain.build(fallback_chain)
|
|
236
|
+
adapter_opts = { model: selection[:model], fork_session: true, resume: true }
|
|
237
|
+
|
|
238
|
+
raw = chain.call_fix(fix_prompt, adapter_opts, total: fallback_chain.size) do |current_engine|
|
|
239
|
+
create_checkpoint(current_engine)
|
|
240
|
+
end
|
|
241
|
+
JSON.parse(raw)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def apply_patches(result)
|
|
245
|
+
result['patches'].each do |patch|
|
|
246
|
+
path = File.expand_path(patch['file'], Dir.pwd)
|
|
247
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
248
|
+
File.write(path, patch['content'])
|
|
249
|
+
puts "Applied fix to #{patch['file']} ✅"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def handle_verification_result(verify_result, type)
|
|
254
|
+
if verify_result[:exit_status].zero?
|
|
255
|
+
puts "Fix successful! #{type.to_s.capitalize} issues resolved. ✅"
|
|
256
|
+
true
|
|
257
|
+
else
|
|
258
|
+
puts "Fix failed. #{type.to_s.capitalize} issues still persist. ❌"
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def create_checkpoint(engine)
|
|
264
|
+
# Checkpointing logic varies by engine.
|
|
265
|
+
# For now, we ensure a git-based safety net if not in a git repo or if native fails.
|
|
266
|
+
case engine
|
|
267
|
+
when :claude
|
|
268
|
+
# Claude Code does automatic checkpointing on every prompt.
|
|
269
|
+
@spinner.update(title: 'Leveraging Claude auto-checkpoint...')
|
|
270
|
+
when :codex
|
|
271
|
+
# Codex sessions are automatically persisted.
|
|
272
|
+
@spinner.update(title: 'Leveraging Codex session persistence...')
|
|
273
|
+
else
|
|
274
|
+
# Fallback to git stash or similar if we wanted a hard checkpoint,
|
|
275
|
+
# but since we are often on a task branch, git is our checkpoint.
|
|
276
|
+
@spinner.update(title: "Ensuring state persistence for #{engine}...")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def run_lint(options)
|
|
281
|
+
run_diagnostic_loop('bundle exec rubocop -A', options.merge(type: :lint, title: 'Running RuboCop'))
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ares
|
|
4
|
+
module Runtime
|
|
5
|
+
class TaskLogger
|
|
6
|
+
attr_reader :task_id
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@task_id = SecureRandom.uuid
|
|
10
|
+
@log_dir = File.join(ConfigManager.project_root, 'logs')
|
|
11
|
+
FileUtils.mkdir_p(@log_dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def log_task(task, plan, selection)
|
|
15
|
+
log_file = File.join(@log_dir, "#{@task_id}.json")
|
|
16
|
+
data = {
|
|
17
|
+
task_id: @task_id,
|
|
18
|
+
timestamp: Time.now.iso8601,
|
|
19
|
+
task: task,
|
|
20
|
+
plan: plan,
|
|
21
|
+
selection: selection
|
|
22
|
+
}
|
|
23
|
+
File.write(log_file, JSON.pretty_generate(data))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def log_result(result)
|
|
27
|
+
log_file = File.join(@log_dir, "#{@task_id}.json")
|
|
28
|
+
return unless File.exist?(log_file)
|
|
29
|
+
|
|
30
|
+
data = JSON.parse(File.read(log_file))
|
|
31
|
+
data[:result] = result
|
|
32
|
+
data[:completed_at] = Time.now.iso8601
|
|
33
|
+
File.write(log_file, JSON.pretty_generate(data))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Ares
|
|
6
|
+
module Runtime
|
|
7
|
+
# Core class for executing terminal commands, with support for sandboxing via Codex.
|
|
8
|
+
class TerminalRunner
|
|
9
|
+
def self.run(cmd, stdin_data: nil)
|
|
10
|
+
# Ensure cmd is an array for capture2e if we want to avoid shell injection,
|
|
11
|
+
# but the Router currently passes strings for complex commands.
|
|
12
|
+
# We'll normalize or handle both.
|
|
13
|
+
output, status = if cmd.is_a?(Array)
|
|
14
|
+
Open3.capture2e(*cmd, stdin_data: stdin_data)
|
|
15
|
+
else
|
|
16
|
+
Open3.capture2e(cmd, stdin_data: stdin_data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Safe join for logging/errors
|
|
20
|
+
cmd_str = cmd.is_a?(Array) ? cmd.join(' ') : cmd
|
|
21
|
+
raise "Command failed: #{cmd_str}\nOutput: #{output}" unless status.success?
|
|
22
|
+
|
|
23
|
+
{ output: output, exit_status: status.exitstatus }
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
# Return a hash consistent with the Router's expectations
|
|
26
|
+
{ output: e.message, exit_status: 1 }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.run_sandboxed(cmd)
|
|
30
|
+
# Use codex sandbox for secure execution
|
|
31
|
+
cmd_array = cmd.is_a?(Array) ? cmd : [cmd]
|
|
32
|
+
full_cmd = ['codex', 'sandbox', '--full-auto', '--', *cmd_array]
|
|
33
|
+
run(full_cmd)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|