ocak 0.1.0 → 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/lib/ocak/agent_generator.rb +4 -2
- data/lib/ocak/cli.rb +2 -0
- data/lib/ocak/commands/hiz.rb +180 -0
- data/lib/ocak/commands/resume.rb +3 -3
- data/lib/ocak/issue_fetcher.rb +6 -3
- data/lib/ocak/monorepo_detector.rb +97 -0
- data/lib/ocak/pipeline_executor.rb +176 -0
- data/lib/ocak/pipeline_runner.rb +10 -201
- data/lib/ocak/pipeline_state.rb +4 -2
- data/lib/ocak/process_runner.rb +4 -2
- data/lib/ocak/stack_detector.rb +149 -281
- data/lib/ocak/stream_parser.rb +2 -1
- data/lib/ocak.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4aa86d60189e50486edaba37ce3a2763d52a28b817818ac5b6466c952087cff
|
|
4
|
+
data.tar.gz: 4a1d662842ef9f7e88fe5b6deeb30c51f061a6a598f948e1e622bf60e05abc70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14787c35edcd0b30fd87b1aaf3e9537e091778a457ab43c33304b037dcbd79efa03d3f1dbdfa5d7d4256431cea8e7067d2c66ea9e5fcbaa43f86cfdef1802105
|
|
7
|
+
data.tar.gz: 8d0c0b8a7ad9a8ed47d9b2864d9238b6a6705df00d6580db7296d67fab284198001c41ed9cc024edb6288344183ef07059788f536392ba7b7b386baa52106e7e
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -150,7 +150,8 @@ module Ocak
|
|
|
150
150
|
def claude_available?
|
|
151
151
|
_, _, status = Open3.capture3('which', 'claude')
|
|
152
152
|
status.success?
|
|
153
|
-
rescue Errno::ENOENT
|
|
153
|
+
rescue Errno::ENOENT => e
|
|
154
|
+
@logger&.warn("Claude CLI not found: #{e.message}")
|
|
154
155
|
false
|
|
155
156
|
end
|
|
156
157
|
|
|
@@ -164,7 +165,8 @@ module Ocak
|
|
|
164
165
|
chdir: @project_dir
|
|
165
166
|
)
|
|
166
167
|
status.success? ? stdout : nil
|
|
167
|
-
rescue Errno::ENOENT
|
|
168
|
+
rescue Errno::ENOENT => e
|
|
169
|
+
@logger&.warn("Failed to run Claude prompt: #{e.message}")
|
|
168
170
|
nil
|
|
169
171
|
end
|
|
170
172
|
end
|
data/lib/ocak/cli.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'commands/debt'
|
|
|
9
9
|
require_relative 'commands/status'
|
|
10
10
|
require_relative 'commands/clean'
|
|
11
11
|
require_relative 'commands/resume'
|
|
12
|
+
require_relative 'commands/hiz'
|
|
12
13
|
|
|
13
14
|
module Ocak
|
|
14
15
|
module CLI
|
|
@@ -23,6 +24,7 @@ module Ocak
|
|
|
23
24
|
register 'status', Ocak::Commands::Status
|
|
24
25
|
register 'clean', Ocak::Commands::Clean
|
|
25
26
|
register 'resume', Ocak::Commands::Resume
|
|
27
|
+
register 'hiz', Ocak::Commands::Hiz
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative '../config'
|
|
6
|
+
require_relative '../claude_runner'
|
|
7
|
+
require_relative '../issue_fetcher'
|
|
8
|
+
require_relative '../verification'
|
|
9
|
+
require_relative '../logger'
|
|
10
|
+
|
|
11
|
+
module Ocak
|
|
12
|
+
module Commands
|
|
13
|
+
class Hiz < Dry::CLI::Command
|
|
14
|
+
include Verification
|
|
15
|
+
|
|
16
|
+
desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
|
|
17
|
+
|
|
18
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number to process'
|
|
19
|
+
option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
|
|
20
|
+
|
|
21
|
+
MODEL = 'sonnet'
|
|
22
|
+
|
|
23
|
+
STEPS = [
|
|
24
|
+
{ agent: 'implementer', role: 'implement' },
|
|
25
|
+
{ agent: 'reviewer', role: 'review' },
|
|
26
|
+
{ agent: 'security-reviewer', role: 'security' }
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def call(issue:, **options)
|
|
30
|
+
@config = Config.load
|
|
31
|
+
issue_number = issue.to_i
|
|
32
|
+
logger = build_logger(issue_number)
|
|
33
|
+
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
34
|
+
claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
|
|
35
|
+
issues = IssueFetcher.new(config: @config, logger: logger)
|
|
36
|
+
|
|
37
|
+
logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
|
|
38
|
+
|
|
39
|
+
run_fast_pipeline(issue_number, claude: claude, logger: logger, issues: issues)
|
|
40
|
+
rescue Config::ConfigNotFound => e
|
|
41
|
+
warn "Error: #{e.message}"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def run_fast_pipeline(issue_number, claude:, logger:, issues:)
|
|
48
|
+
chdir = @config.project_dir
|
|
49
|
+
branch = create_branch(issue_number, chdir)
|
|
50
|
+
|
|
51
|
+
failure = run_agents(issue_number, claude: claude, logger: logger, chdir: chdir)
|
|
52
|
+
if failure
|
|
53
|
+
handle_failure(issue_number, failure[:phase], failure[:output], issues: issues, logger: logger)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
verification_failure = run_final_verification_step(claude: claude, logger: logger, chdir: chdir)
|
|
58
|
+
if verification_failure
|
|
59
|
+
handle_failure(issue_number, 'final-verify', verification_failure[:output],
|
|
60
|
+
issues: issues, logger: logger)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
push_and_create_pr(issue_number, branch, logger: logger, issues: issues, chdir: chdir)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def run_agents(issue_number, claude:, logger:, chdir:)
|
|
68
|
+
STEPS.each do |step|
|
|
69
|
+
result = run_step(step, issue_number, claude: claude, logger: logger, chdir: chdir)
|
|
70
|
+
next if result.success?
|
|
71
|
+
|
|
72
|
+
if step[:role] == 'implement'
|
|
73
|
+
logger.error("Implementation failed for issue ##{issue_number}")
|
|
74
|
+
return { phase: 'implement', output: result.output }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
logger.warn("#{step[:role]} reported issues but continuing")
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def create_branch(issue_number, chdir)
|
|
83
|
+
branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
|
|
84
|
+
_, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
|
|
85
|
+
raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
|
|
86
|
+
|
|
87
|
+
branch
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_step(step, issue_number, claude:, logger:, chdir:)
|
|
91
|
+
agent = step[:agent]
|
|
92
|
+
role = step[:role]
|
|
93
|
+
logger.info("--- Phase: #{role} (#{agent}) [sonnet] ---")
|
|
94
|
+
prompt = build_prompt(role, issue_number)
|
|
95
|
+
claude.run_agent(agent, prompt, chdir: chdir, model: MODEL)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_prompt(role, issue_number)
|
|
99
|
+
case role
|
|
100
|
+
when 'implement' then "Implement GitHub issue ##{issue_number}"
|
|
101
|
+
when 'review' then "Review the changes for GitHub issue ##{issue_number}. Run: git diff main"
|
|
102
|
+
when 'security' then "Security review changes for GitHub issue ##{issue_number}. Run: git diff main"
|
|
103
|
+
else "Run #{role} for GitHub issue ##{issue_number}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_final_verification_step(claude:, logger:, chdir:)
|
|
108
|
+
return nil unless @config.test_command || @config.lint_check_command
|
|
109
|
+
|
|
110
|
+
logger.info('--- Final verification ---')
|
|
111
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
112
|
+
return nil if result[:success]
|
|
113
|
+
|
|
114
|
+
logger.warn('Final checks failed, attempting fix...')
|
|
115
|
+
claude.run_agent('implementer',
|
|
116
|
+
"Fix these test/lint failures:\n\n#{result[:output]}",
|
|
117
|
+
chdir: chdir, model: MODEL)
|
|
118
|
+
|
|
119
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
120
|
+
return nil if result[:success]
|
|
121
|
+
|
|
122
|
+
{ success: false, phase: 'final-verify', output: result[:output] }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def push_and_create_pr(issue_number, branch, logger:, issues:, chdir:)
|
|
126
|
+
commit_changes(issue_number, chdir)
|
|
127
|
+
|
|
128
|
+
_, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
|
|
129
|
+
unless status.success?
|
|
130
|
+
logger.error("Push failed: #{stderr}")
|
|
131
|
+
handle_failure(issue_number, 'push', stderr, issues: issues, logger: logger)
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
issue_data = issues.view(issue_number)
|
|
136
|
+
issue_title = issue_data&.dig('title')
|
|
137
|
+
pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
|
|
138
|
+
pr_body = "Automated PR for issue ##{issue_number} (hiz fast mode)\n\nCloses ##{issue_number}"
|
|
139
|
+
|
|
140
|
+
stdout, stderr, status = Open3.capture3(
|
|
141
|
+
'gh', 'pr', 'create',
|
|
142
|
+
'--title', pr_title,
|
|
143
|
+
'--body', pr_body,
|
|
144
|
+
'--head', branch,
|
|
145
|
+
chdir: chdir
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if status.success?
|
|
149
|
+
pr_url = stdout.strip
|
|
150
|
+
logger.info("PR created: #{pr_url}")
|
|
151
|
+
puts "PR created: #{pr_url}"
|
|
152
|
+
else
|
|
153
|
+
logger.error("PR creation failed: #{stderr}")
|
|
154
|
+
handle_failure(issue_number, 'pr-create', stderr, issues: issues, logger: logger)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def commit_changes(issue_number, chdir)
|
|
159
|
+
stdout, = Open3.capture3('git', 'status', '--porcelain', chdir: chdir)
|
|
160
|
+
return if stdout.strip.empty?
|
|
161
|
+
|
|
162
|
+
Open3.capture3('git', 'add', '-A', chdir: chdir)
|
|
163
|
+
Open3.capture3('git', 'commit', '-m', "feat: implement issue ##{issue_number} [hiz]",
|
|
164
|
+
chdir: chdir)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def handle_failure(issue_number, phase, output, issues:, logger:)
|
|
168
|
+
logger.error("Issue ##{issue_number} failed at phase: #{phase}")
|
|
169
|
+
issues.comment(issue_number,
|
|
170
|
+
"Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{output.to_s[0..1000]}\n```")
|
|
171
|
+
warn "Issue ##{issue_number} failed at phase: #{phase}"
|
|
172
|
+
Open3.capture3('git', 'checkout', 'main', chdir: @config.project_dir)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_logger(issue_number)
|
|
176
|
+
PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -61,9 +61,9 @@ module Ocak
|
|
|
61
61
|
issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
|
|
62
62
|
|
|
63
63
|
runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
|
|
64
|
-
result = runner.
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
result = runner.run_pipeline(issue_number,
|
|
65
|
+
logger: logger, claude: claude, chdir: chdir,
|
|
66
|
+
skip_steps: saved[:completed_steps])
|
|
67
67
|
|
|
68
68
|
ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
|
|
69
69
|
issues: issues, claude: claude, logger: logger, watch: watch_formatter }
|
data/lib/ocak/issue_fetcher.rb
CHANGED
|
@@ -25,7 +25,8 @@ module Ocak
|
|
|
25
25
|
issues.reject! { |i| in_progress?(i) }
|
|
26
26
|
issues.select! { |i| authorized_issue?(i) }
|
|
27
27
|
issues
|
|
28
|
-
rescue JSON::ParserError
|
|
28
|
+
rescue JSON::ParserError => e
|
|
29
|
+
@logger&.warn("Failed to parse issue list JSON: #{e.message}")
|
|
29
30
|
[]
|
|
30
31
|
end
|
|
31
32
|
|
|
@@ -55,7 +56,8 @@ module Ocak
|
|
|
55
56
|
return nil unless status.success?
|
|
56
57
|
|
|
57
58
|
JSON.parse(stdout)
|
|
58
|
-
rescue JSON::ParserError
|
|
59
|
+
rescue JSON::ParserError => e
|
|
60
|
+
@logger&.warn("Failed to parse issue view JSON: #{e.message}")
|
|
59
61
|
nil
|
|
60
62
|
end
|
|
61
63
|
|
|
@@ -115,7 +117,8 @@ module Ocak
|
|
|
115
117
|
return [] unless status.success?
|
|
116
118
|
|
|
117
119
|
JSON.parse(stdout).fetch('comments', [])
|
|
118
|
-
rescue JSON::ParserError
|
|
120
|
+
rescue JSON::ParserError => e
|
|
121
|
+
@logger&.warn("Failed to parse issue comments JSON: #{e.message}")
|
|
119
122
|
[]
|
|
120
123
|
end
|
|
121
124
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Ocak
|
|
6
|
+
module MonorepoDetector
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def detect_monorepo
|
|
10
|
+
packages = []
|
|
11
|
+
packages.concat(detect_npm_workspaces)
|
|
12
|
+
packages.concat(detect_pnpm_workspaces)
|
|
13
|
+
packages.concat(detect_cargo_workspaces)
|
|
14
|
+
packages.concat(detect_go_workspaces)
|
|
15
|
+
packages.concat(detect_lerna_packages)
|
|
16
|
+
packages.concat(detect_convention_packages) if packages.empty?
|
|
17
|
+
packages.uniq!
|
|
18
|
+
{ detected: packages.any?, packages: packages }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def detect_npm_workspaces
|
|
22
|
+
return [] unless exists?('package.json')
|
|
23
|
+
|
|
24
|
+
pkg = begin
|
|
25
|
+
JSON.parse(read_file('package.json'))
|
|
26
|
+
rescue JSON::ParserError => e
|
|
27
|
+
warn("Failed to parse package.json: #{e.message}")
|
|
28
|
+
{}
|
|
29
|
+
end
|
|
30
|
+
workspaces = pkg['workspaces']
|
|
31
|
+
workspaces = workspaces['packages'] if workspaces.is_a?(Hash)
|
|
32
|
+
return [] unless workspaces.is_a?(Array) && workspaces.any?
|
|
33
|
+
|
|
34
|
+
expand_workspace_globs(workspaces)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detect_pnpm_workspaces
|
|
38
|
+
return [] unless exists?('pnpm-workspace.yaml')
|
|
39
|
+
|
|
40
|
+
content = read_file('pnpm-workspace.yaml')
|
|
41
|
+
globs = content.scan(/^\s*-\s*['"]?([^'"#\n]+)/).flatten.map(&:strip)
|
|
42
|
+
expand_workspace_globs(globs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def detect_cargo_workspaces
|
|
46
|
+
return [] unless exists?('Cargo.toml') && read_file('Cargo.toml').include?('[workspace]')
|
|
47
|
+
|
|
48
|
+
read_file('Cargo.toml').scan(/members\s*=\s*\[(.*?)\]/m).flatten.flat_map do |members|
|
|
49
|
+
globs = members.scan(/"([^"]+)"/).flatten
|
|
50
|
+
expand_workspace_globs(globs)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def detect_go_workspaces
|
|
55
|
+
return [] unless exists?('go.work')
|
|
56
|
+
|
|
57
|
+
read_file('go.work').scan(/use\s+(\S+)/).flatten.select do |pkg|
|
|
58
|
+
Dir.exist?(File.join(@dir, pkg))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def detect_lerna_packages
|
|
63
|
+
return [] unless exists?('lerna.json')
|
|
64
|
+
|
|
65
|
+
lerna = begin
|
|
66
|
+
JSON.parse(read_file('lerna.json'))
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
warn("Failed to parse lerna.json: #{e.message}")
|
|
69
|
+
{}
|
|
70
|
+
end
|
|
71
|
+
expand_workspace_globs(lerna['packages'] || ['packages/*'])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def detect_convention_packages
|
|
75
|
+
packages = []
|
|
76
|
+
%w[packages apps services modules libs].each do |candidate|
|
|
77
|
+
path = File.join(@dir, candidate)
|
|
78
|
+
next unless Dir.exist?(path)
|
|
79
|
+
|
|
80
|
+
subdirs = Dir.entries(path).reject { |e| e.start_with?('.') }.select do |e|
|
|
81
|
+
File.directory?(File.join(path, e))
|
|
82
|
+
end
|
|
83
|
+
packages.concat(subdirs.map { |s| "#{candidate}/#{s}" }) if subdirs.size > 1
|
|
84
|
+
end
|
|
85
|
+
packages
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def expand_workspace_globs(globs)
|
|
89
|
+
globs.flat_map do |glob|
|
|
90
|
+
pattern = File.join(@dir, glob)
|
|
91
|
+
Dir.glob(pattern).select { |p| File.directory?(p) }.map do |p|
|
|
92
|
+
p.sub("#{@dir}/", '')
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require_relative 'pipeline_state'
|
|
5
|
+
require_relative 'verification'
|
|
6
|
+
require_relative 'planner'
|
|
7
|
+
|
|
8
|
+
module Ocak
|
|
9
|
+
class PipelineExecutor
|
|
10
|
+
include Verification
|
|
11
|
+
include Planner
|
|
12
|
+
|
|
13
|
+
StepContext = Struct.new(:issue_number, :idx, :role, :result, :state, :logger, :chdir)
|
|
14
|
+
|
|
15
|
+
def initialize(config:)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full')
|
|
20
|
+
chdir ||= @config.project_dir
|
|
21
|
+
logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
|
|
22
|
+
|
|
23
|
+
state = { last_review_output: nil, had_fixes: false, completed_steps: [], total_cost: 0.0,
|
|
24
|
+
complexity: complexity }
|
|
25
|
+
|
|
26
|
+
failure = run_pipeline_steps(issue_number, state, logger: logger, claude: claude, chdir: chdir,
|
|
27
|
+
skip_steps: skip_steps)
|
|
28
|
+
log_cost_summary(state[:total_cost], logger)
|
|
29
|
+
return failure if failure
|
|
30
|
+
|
|
31
|
+
failure = run_final_verification(logger: logger, claude: claude, chdir: chdir)
|
|
32
|
+
return failure if failure
|
|
33
|
+
|
|
34
|
+
pipeline_state.delete(issue_number)
|
|
35
|
+
|
|
36
|
+
logger.info("=== Pipeline complete for issue ##{issue_number} ===")
|
|
37
|
+
{ success: true, output: 'Pipeline completed successfully' }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def run_pipeline_steps(issue_number, state, logger:, claude:, chdir:, skip_steps: [])
|
|
43
|
+
@config.steps.each_with_index do |step, idx|
|
|
44
|
+
step = symbolize(step)
|
|
45
|
+
role = step[:role].to_s
|
|
46
|
+
|
|
47
|
+
if skip_steps.include?(idx)
|
|
48
|
+
logger.info("Skipping #{role} (already completed)")
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
next if skip_step?(step, state, logger)
|
|
53
|
+
|
|
54
|
+
result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
|
|
55
|
+
chdir: chdir)
|
|
56
|
+
ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
|
|
57
|
+
failure = record_step_result(ctx)
|
|
58
|
+
return failure if failure
|
|
59
|
+
end
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute_step(step, issue_number, review_output, logger:, claude:, chdir:)
|
|
64
|
+
agent = step[:agent].to_s
|
|
65
|
+
role = step[:role].to_s
|
|
66
|
+
logger.info("--- Phase: #{role} (#{agent}) ---")
|
|
67
|
+
prompt = build_step_prompt(role, issue_number, review_output)
|
|
68
|
+
claude.run_agent(agent.tr('_', '-'), prompt, chdir: chdir)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def record_step_result(ctx)
|
|
72
|
+
update_pipeline_state(ctx.role, ctx.result, ctx.state)
|
|
73
|
+
ctx.state[:completed_steps] << ctx.idx
|
|
74
|
+
ctx.state[:total_cost] += ctx.result.cost_usd.to_f
|
|
75
|
+
save_step_progress(ctx)
|
|
76
|
+
|
|
77
|
+
check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save_step_progress(ctx)
|
|
81
|
+
pipeline_state.save(ctx.issue_number,
|
|
82
|
+
completed_steps: ctx.state[:completed_steps],
|
|
83
|
+
worktree_path: ctx.chdir,
|
|
84
|
+
branch: current_branch(ctx.chdir))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_step_failure(ctx)
|
|
88
|
+
return nil if ctx.result.success? || !%w[implement merge].include?(ctx.role)
|
|
89
|
+
|
|
90
|
+
ctx.logger.error("#{ctx.role} failed")
|
|
91
|
+
{ success: false, phase: ctx.role, output: ctx.result.output }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_cost_budget(state, logger)
|
|
95
|
+
return nil unless @config.cost_budget && state[:total_cost] > @config.cost_budget
|
|
96
|
+
|
|
97
|
+
cost = format('%.2f', state[:total_cost])
|
|
98
|
+
budget = format('%.2f', @config.cost_budget)
|
|
99
|
+
logger.error("Cost budget exceeded ($#{cost}/$#{budget})")
|
|
100
|
+
{ success: false, phase: 'budget', output: "Cost budget exceeded: $#{cost}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def skip_step?(step, state, logger)
|
|
104
|
+
role = step[:role].to_s
|
|
105
|
+
condition = step[:condition]
|
|
106
|
+
|
|
107
|
+
if step[:complexity] == 'full' && state[:complexity] == 'simple'
|
|
108
|
+
logger.info("Skipping #{role} — fast-track issue")
|
|
109
|
+
return true
|
|
110
|
+
end
|
|
111
|
+
if condition == 'has_findings' && !state[:last_review_output]&.include?("\u{1F534}")
|
|
112
|
+
logger.info("Skipping #{role} — no blocking findings")
|
|
113
|
+
return true
|
|
114
|
+
end
|
|
115
|
+
if condition == 'had_fixes' && !state[:had_fixes]
|
|
116
|
+
logger.info("Skipping #{role} — no fixes were made")
|
|
117
|
+
return true
|
|
118
|
+
end
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def update_pipeline_state(role, result, state)
|
|
123
|
+
case role
|
|
124
|
+
when 'review', 'verify', 'security', 'audit'
|
|
125
|
+
state[:last_review_output] = result.output
|
|
126
|
+
when 'fix'
|
|
127
|
+
state[:had_fixes] = true
|
|
128
|
+
state[:last_review_output] = nil
|
|
129
|
+
when 'implement'
|
|
130
|
+
state[:last_review_output] = nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_final_verification(logger:, claude:, chdir:)
|
|
135
|
+
return nil unless @config.test_command || @config.lint_check_command
|
|
136
|
+
|
|
137
|
+
logger.info('--- Final verification ---')
|
|
138
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
139
|
+
return nil if result[:success]
|
|
140
|
+
|
|
141
|
+
logger.warn('Final checks failed, attempting fix...')
|
|
142
|
+
claude.run_agent('implementer',
|
|
143
|
+
"Fix these test/lint failures:\n\n#{result[:output]}",
|
|
144
|
+
chdir: chdir)
|
|
145
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
146
|
+
return nil if result[:success]
|
|
147
|
+
|
|
148
|
+
{ success: false, phase: 'final-verify', output: result[:output] }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def log_cost_summary(total_cost, logger)
|
|
152
|
+
return if total_cost.zero?
|
|
153
|
+
|
|
154
|
+
budget = @config.cost_budget
|
|
155
|
+
budget_str = budget ? " / $#{format('%.2f', budget)} budget" : ''
|
|
156
|
+
logger.info("Pipeline cost: $#{format('%.4f', total_cost)}#{budget_str}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def pipeline_state
|
|
160
|
+
@pipeline_state ||= PipelineState.new(log_dir: File.join(@config.project_dir, @config.log_dir))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def current_branch(chdir)
|
|
164
|
+
stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
|
|
165
|
+
stdout.strip
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def symbolize(hash)
|
|
171
|
+
return hash unless hash.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
hash.transform_keys(&:to_sym)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|