ocak 0.4.0 → 0.6.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -21
  3. data/lib/ocak/agent_generator.rb +11 -1
  4. data/lib/ocak/batch_processing.rb +132 -0
  5. data/lib/ocak/claude_runner.rb +12 -8
  6. data/lib/ocak/cli.rb +13 -0
  7. data/lib/ocak/command_runner.rb +39 -0
  8. data/lib/ocak/commands/hiz.rb +28 -28
  9. data/lib/ocak/commands/init.rb +37 -0
  10. data/lib/ocak/commands/issue/close.rb +37 -0
  11. data/lib/ocak/commands/issue/create.rb +59 -0
  12. data/lib/ocak/commands/issue/edit.rb +31 -0
  13. data/lib/ocak/commands/issue/list.rb +43 -0
  14. data/lib/ocak/commands/issue/view.rb +58 -0
  15. data/lib/ocak/commands/resume.rb +11 -9
  16. data/lib/ocak/commands/status.rb +29 -12
  17. data/lib/ocak/config.rb +72 -1
  18. data/lib/ocak/conflict_resolution.rb +73 -0
  19. data/lib/ocak/failure_reporting.rb +6 -3
  20. data/lib/ocak/git_utils.rb +18 -11
  21. data/lib/ocak/instance_builders.rb +54 -0
  22. data/lib/ocak/issue_backend.rb +31 -0
  23. data/lib/ocak/issue_fetcher.rb +9 -0
  24. data/lib/ocak/issue_state_machine.rb +36 -0
  25. data/lib/ocak/local_issue_fetcher.rb +165 -0
  26. data/lib/ocak/local_merge_manager.rb +104 -0
  27. data/lib/ocak/merge_manager.rb +30 -103
  28. data/lib/ocak/merge_orchestration.rb +36 -24
  29. data/lib/ocak/merge_verification.rb +40 -0
  30. data/lib/ocak/parallel_execution.rb +36 -0
  31. data/lib/ocak/pipeline_executor.rb +17 -185
  32. data/lib/ocak/pipeline_runner.rb +32 -180
  33. data/lib/ocak/planner.rb +16 -1
  34. data/lib/ocak/project_key.rb +38 -0
  35. data/lib/ocak/reready_processor.rb +11 -11
  36. data/lib/ocak/run_report.rb +5 -2
  37. data/lib/ocak/shutdown_handling.rb +67 -0
  38. data/lib/ocak/state_management.rb +104 -0
  39. data/lib/ocak/step_execution.rb +66 -0
  40. data/lib/ocak/stream_parser.rb +1 -1
  41. data/lib/ocak/target_resolver.rb +41 -0
  42. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  43. data/lib/ocak/templates/agents/implementer.md.erb +35 -8
  44. data/lib/ocak/templates/agents/merger.md.erb +24 -5
  45. data/lib/ocak/templates/agents/pipeline.md.erb +22 -0
  46. data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
  47. data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
  48. data/lib/ocak/templates/gitignore_additions.txt +1 -0
  49. data/lib/ocak/templates/ocak.yml.erb +24 -0
  50. data/lib/ocak/verification.rb +6 -1
  51. data/lib/ocak/worktree_manager.rb +9 -6
  52. data/lib/ocak.rb +1 -1
  53. metadata +21 -1
@@ -14,50 +14,59 @@ module Ocak
14
14
  elsif @config.manual_review
15
15
  handle_batch_manual_review(result, merger: merger, issues: issues, logger: logger)
16
16
  elsif merger.merge(result[:issue_number], result[:worktree])
17
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_completed)
17
+ @state_machine.mark_completed(result[:issue_number])
18
18
  logger.info("Issue ##{result[:issue_number]} merged successfully")
19
19
  else
20
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
20
+ @state_machine.mark_failed(result[:issue_number])
21
21
  logger.error("Issue ##{result[:issue_number]} merge failed")
22
22
  end
23
23
  end
24
24
 
25
25
  def handle_single_success(issue_number, result, logger:, claude:, issues:)
26
+ target_dir = result[:target_repo]&.dig(:path) || @config.project_dir
27
+
26
28
  if result[:audit_blocked]
27
- handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues)
29
+ handle_single_audit_blocked(issue_number, result, logger: logger, claude: claude, issues: issues,
30
+ chdir: target_dir)
28
31
  elsif @config.manual_review
29
- handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
32
+ handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: target_dir)
30
33
  else
31
- claude.run_agent('merger', "Create a PR, merge it, and close issue ##{issue_number}",
32
- chdir: @config.project_dir)
33
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_completed)
34
+ unless pipeline_has_merge_step?
35
+ prompt = if result[:target_repo]
36
+ "Create a PR and merge it for issue ##{issue_number}. " \
37
+ 'Do NOT close any issues (the issue lives in a different repository).'
38
+ else
39
+ "Create a PR, merge it, and close issue ##{issue_number}"
40
+ end
41
+ claude.run_agent('merger', prompt, chdir: target_dir)
42
+ end
43
+ @state_machine.mark_completed(issue_number)
34
44
  logger.info("Issue ##{issue_number} completed successfully")
35
45
  end
36
46
  end
37
47
 
38
- def handle_single_manual_review(issue_number, logger:, claude:, issues:)
48
+ def handle_single_manual_review(issue_number, logger:, claude:, issues: nil, chdir: @config.project_dir) # rubocop:disable Lint/UnusedMethodArgument
39
49
  claude.run_agent('merger',
40
50
  "Create a PR for issue ##{issue_number} but do NOT merge it and do NOT close the issue",
41
- chdir: @config.project_dir)
42
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_awaiting_review)
51
+ chdir: chdir)
52
+ @state_machine.mark_for_review(issue_number)
43
53
  logger.info("Issue ##{issue_number} PR created (manual review mode)")
44
54
  end
45
55
 
46
- def handle_batch_manual_review(result, merger:, issues:, logger:)
56
+ def handle_batch_manual_review(result, merger:, logger:, issues: nil) # rubocop:disable Lint/UnusedMethodArgument
47
57
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
48
58
  if pr_number
49
- issues.transition(result[:issue_number], from: @config.label_in_progress,
50
- to: @config.label_awaiting_review)
59
+ @state_machine.mark_for_review(result[:issue_number])
51
60
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (manual review mode)")
52
61
  else
53
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
62
+ @state_machine.mark_failed(result[:issue_number])
54
63
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
55
64
  end
56
65
  end
57
66
 
58
- def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:)
59
- handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues)
60
- post_audit_comment_single(result[:audit_output], logger: logger, issues: issues)
67
+ def handle_single_audit_blocked(issue_number, result, logger:, claude:, issues:, chdir: @config.project_dir)
68
+ handle_single_manual_review(issue_number, logger: logger, claude: claude, issues: issues, chdir: chdir)
69
+ post_audit_comment_single(result[:audit_output], logger: logger, issues: issues, chdir: chdir)
61
70
  end
62
71
 
63
72
  def handle_batch_audit(result, merger:, issues:, logger:)
@@ -68,17 +77,16 @@ module Ocak
68
77
  pr_number = merger.create_pr_only(result[:issue_number], result[:worktree])
69
78
  if pr_number
70
79
  issues.pr_comment(pr_number, "## Audit Report\n\n#{audit_output}")
71
- issues.transition(result[:issue_number], from: @config.label_in_progress,
72
- to: @config.label_awaiting_review)
80
+ @state_machine.mark_for_review(result[:issue_number])
73
81
  logger.info("Issue ##{result[:issue_number]} PR ##{pr_number} created (audit findings)")
74
82
  else
75
- issues.transition(result[:issue_number], from: @config.label_in_progress, to: @config.label_failed)
83
+ @state_machine.mark_failed(result[:issue_number])
76
84
  logger.error("Issue ##{result[:issue_number]} PR creation failed")
77
85
  end
78
86
  end
79
87
 
80
- def post_audit_comment_single(audit_output, logger:, issues:)
81
- pr_number = find_pr_for_branch(logger: logger)
88
+ def post_audit_comment_single(audit_output, logger:, issues:, chdir: @config.project_dir)
89
+ pr_number = find_pr_for_branch(logger: logger, chdir: chdir)
82
90
  unless pr_number
83
91
  logger.warn("Could not find PR to post audit comment — findings were: #{audit_output.to_s[0..200]}")
84
92
  return
@@ -88,8 +96,12 @@ module Ocak
88
96
  logger.info("Posted audit comment on PR ##{pr_number}")
89
97
  end
90
98
 
91
- def find_pr_for_branch(logger:)
92
- stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: @config.project_dir)
99
+ def pipeline_has_merge_step?
100
+ @config.steps.any? { |s| s[:role].to_s == 'merge' || s['role'].to_s == 'merge' }
101
+ end
102
+
103
+ def find_pr_for_branch(logger:, chdir: @config.project_dir)
104
+ stdout, _, status = Open3.capture3('gh', 'pr', 'view', '--json', 'number', chdir: chdir)
93
105
  return nil unless status.success?
94
106
 
95
107
  data = JSON.parse(stdout)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ module Ocak
7
+ # Test verification and git push logic — verify_tests, push_branch.
8
+ # Extracted from MergeManager to reduce file size.
9
+ module MergeVerification
10
+ private
11
+
12
+ def verify_tests(worktree)
13
+ test_cmd = @config.test_command
14
+ return true unless test_cmd
15
+
16
+ @logger.info('Running tests after rebase...')
17
+ stdout, stderr, status = shell(test_cmd, chdir: worktree.path)
18
+
19
+ if status.success?
20
+ @logger.info('Tests passed after rebase')
21
+ true
22
+ else
23
+ @logger.warn('Tests failed after rebase')
24
+ @logger.debug("Test output:\n#{stdout[0..2000]}\n#{stderr[0..500]}")
25
+ false
26
+ end
27
+ end
28
+
29
+ def push_branch(worktree)
30
+ result = run_git('push', '-u', 'origin', worktree.branch, chdir: worktree.path)
31
+
32
+ unless result.success?
33
+ @logger.error("Push failed: #{result.error}")
34
+ return false
35
+ end
36
+
37
+ true
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Parallel group execution logic extracted from PipelineExecutor.
5
+ # Includers must provide run_single_step method and symbolize helper.
6
+ module ParallelExecution
7
+ def collect_parallel_group(steps, start_idx)
8
+ group = []
9
+ idx = start_idx
10
+ while idx < steps.size
11
+ step = symbolize(steps[idx])
12
+ break unless step[:parallel]
13
+
14
+ group << [step, idx]
15
+ idx += 1
16
+ end
17
+ group
18
+ end
19
+
20
+ def run_parallel_group(group, issue_number, state, logger:, claude:, chdir:)
21
+ mutex = Mutex.new
22
+ threads = group.map do |step, idx|
23
+ Thread.new do
24
+ run_single_step(step, idx, issue_number, state, logger: logger, claude: claude,
25
+ chdir: chdir, mutex: mutex)
26
+ rescue StandardError => e
27
+ logger.error("#{step[:role]} thread failed: #{e.message}")
28
+ { success: false, phase: step[:role].to_s, output: "Thread error: #{e.message}" }
29
+ end
30
+ end
31
+
32
+ results = threads.map(&:value)
33
+ results.compact.find { |r| r.is_a?(Hash) && !r[:success] }
34
+ end
35
+ end
36
+ end
@@ -2,19 +2,25 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'fileutils'
5
+ require_relative 'command_runner'
5
6
  require_relative 'pipeline_state'
6
7
  require_relative 'run_report'
7
8
  require_relative 'verification'
8
9
  require_relative 'planner'
9
10
  require_relative 'step_comments'
11
+ require_relative 'state_management'
12
+ require_relative 'step_execution'
13
+ require_relative 'parallel_execution'
10
14
 
11
15
  module Ocak
12
16
  class PipelineExecutor
17
+ include CommandRunner
13
18
  include Verification
14
19
  include Planner
15
20
  include StepComments
16
-
17
- StepContext = Struct.new(:issue_number, :idx, :role, :result, :state, :logger, :chdir)
21
+ include StateManagement
22
+ include StepExecution
23
+ include ParallelExecution
18
24
 
19
25
  attr_writer :issues
20
26
 
@@ -26,11 +32,13 @@ module Ocak
26
32
 
27
33
  def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full', # rubocop:disable Metrics/ParameterLists
28
34
  steps: nil, verification_model: nil,
29
- post_start_comment: true, post_summary_comment: true)
35
+ post_start_comment: true, post_summary_comment: true,
36
+ skip_merge: false)
30
37
  @logger = logger
31
38
  @custom_steps = steps
32
39
  @verification_model = verification_model
33
40
  @post_summary_comment = post_summary_comment
41
+ @skip_merge = skip_merge
34
42
  chdir ||= @config.project_dir
35
43
  logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
36
44
 
@@ -120,55 +128,6 @@ module Ocak
120
128
  nil
121
129
  end
122
130
 
123
- def collect_parallel_group(steps, start_idx)
124
- group = []
125
- idx = start_idx
126
- while idx < steps.size
127
- step = symbolize(steps[idx])
128
- break unless step[:parallel]
129
-
130
- group << [step, idx]
131
- idx += 1
132
- end
133
- group
134
- end
135
-
136
- def run_parallel_group(group, issue_number, state, logger:, claude:, chdir:)
137
- mutex = Mutex.new
138
- threads = group.map do |step, idx|
139
- Thread.new do
140
- run_single_step(step, idx, issue_number, state, logger: logger, claude: claude,
141
- chdir: chdir, mutex: mutex)
142
- rescue StandardError => e
143
- logger.error("#{step[:role]} thread failed: #{e.message}")
144
- nil
145
- end
146
- end
147
-
148
- results = threads.map(&:value)
149
- results.compact.find { |r| r.is_a?(Hash) && !r[:success] }
150
- end
151
-
152
- def run_single_step(step, idx, issue_number, state, logger:, claude:, chdir:, mutex: nil) # rubocop:disable Metrics/ParameterLists
153
- role = step[:role].to_s
154
- agent = step[:agent].to_s
155
-
156
- return nil if handle_already_completed(idx, role, @skip_steps, logger)
157
-
158
- reason = skip_reason(step, state)
159
- if reason
160
- logger.info("Skipping #{role} \u2014 #{reason}")
161
- record_skipped_step(issue_number, state, idx, agent, role, reason)
162
- return nil
163
- end
164
-
165
- result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
166
- chdir: chdir)
167
- state[:report].record_step(index: idx, agent: agent, role: role, status: 'completed', result: result)
168
- ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
169
- record_step_result(ctx, mutex: mutex)
170
- end
171
-
172
131
  def check_shutdown(state, logger)
173
132
  return false unless @shutdown_check&.call
174
133
 
@@ -176,121 +135,6 @@ module Ocak
176
135
  state[:interrupted] = true
177
136
  end
178
137
 
179
- def handle_already_completed(idx, role, skip_steps, logger)
180
- return false unless skip_steps.include?(idx)
181
-
182
- logger.info("Skipping #{role} (already completed)")
183
- true
184
- end
185
-
186
- def record_skipped_step(issue_number, state, idx, agent, role, reason)
187
- post_step_comment(issue_number, "\u{23ED}\u{FE0F} **Skipping #{role}** \u2014 #{reason}")
188
- state[:report].record_step(index: idx, agent: agent, role: role, status: 'skipped', skip_reason: reason)
189
- state[:steps_skipped] += 1
190
- end
191
-
192
- def execute_step(step, issue_number, review_output, logger:, claude:, chdir:)
193
- agent = step[:agent].to_s
194
- role = step[:role].to_s
195
- logger.info("--- Phase: #{role} (#{agent}) ---")
196
- post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})")
197
- prompt = build_step_prompt(role, issue_number, review_output)
198
- opts = { chdir: chdir }
199
- opts[:model] = step[:model].to_s if step[:model]
200
- claude.run_agent(agent.tr('_', '-'), prompt, **opts)
201
- end
202
-
203
- def record_step_result(ctx, mutex: nil)
204
- sync(mutex) { accumulate_state(ctx) }
205
- save_step_progress(ctx)
206
- write_step_output(ctx.issue_number, ctx.idx, ctx.role, ctx.result.output)
207
- post_step_completion_comment(ctx.issue_number, ctx.role, ctx.result)
208
-
209
- check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
210
- end
211
-
212
- def accumulate_state(ctx)
213
- update_pipeline_state(ctx.role, ctx.result, ctx.state)
214
- ctx.state[:completed_steps] << ctx.idx
215
- ctx.state[:steps_run] += 1
216
- ctx.state[:total_cost] += ctx.result.cost_usd.to_f
217
- ctx.state[:step_results][ctx.role] = ctx.result
218
- end
219
-
220
- def sync(mutex, &)
221
- if mutex
222
- mutex.synchronize(&)
223
- else
224
- yield
225
- end
226
- end
227
-
228
- def save_step_progress(ctx)
229
- pipeline_state.save(ctx.issue_number,
230
- completed_steps: ctx.state[:completed_steps],
231
- worktree_path: ctx.chdir,
232
- branch: current_branch(ctx.chdir, logger: ctx.logger))
233
- end
234
-
235
- def write_step_output(issue_number, idx, agent, output)
236
- return if output.to_s.empty?
237
- return unless issue_number.to_s.match?(/\A\d+\z/)
238
-
239
- safe_agent = agent.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
240
- dir = File.join(@config.project_dir, '.ocak', 'logs', "issue-#{issue_number}")
241
- FileUtils.mkdir_p(dir)
242
- File.write(File.join(dir, "step-#{idx}-#{safe_agent}.md"), output)
243
- rescue StandardError => e
244
- @logger&.debug("Step output write failed: #{e.message}")
245
- nil
246
- end
247
-
248
- def check_step_failure(ctx)
249
- return nil if ctx.result.success? || !%w[implement merge].include?(ctx.role)
250
-
251
- ctx.logger.error("#{ctx.role} failed")
252
- { success: false, phase: ctx.role, output: ctx.result.output }
253
- end
254
-
255
- def check_cost_budget(state, logger)
256
- return nil unless @config.cost_budget && state[:total_cost] > @config.cost_budget
257
-
258
- cost = format('%.2f', state[:total_cost])
259
- budget = format('%.2f', @config.cost_budget)
260
- logger.error("Cost budget exceeded ($#{cost}/$#{budget})")
261
- { success: false, phase: 'budget', output: "Cost budget exceeded: $#{cost}" }
262
- end
263
-
264
- def skip_reason(step, state)
265
- condition = step[:condition]
266
-
267
- return 'audit found blocking issues' if step[:role].to_s == 'merge' && @config.audit_mode && state[:audit_blocked]
268
- return 'manual review mode' if step[:role].to_s == 'merge' && @config.manual_review
269
- return 'fast-track issue (simple complexity)' if step[:complexity] == 'full' && state[:complexity] == 'simple'
270
- if condition == 'has_findings' && !state[:last_review_output]&.include?("\u{1F534}")
271
- return 'no blocking findings from review'
272
- end
273
- return 'no fixes were made' if condition == 'had_fixes' && !state[:had_fixes]
274
-
275
- nil
276
- end
277
-
278
- def update_pipeline_state(role, result, state)
279
- case role
280
- when 'review', 'verify', 'security', 'audit'
281
- state[:last_review_output] = result.output
282
- if role == 'audit'
283
- state[:audit_output] = result.output
284
- state[:audit_blocked] = !result.success? || result.output.to_s.match?(/BLOCK|🔴/)
285
- end
286
- when 'fix'
287
- state[:had_fixes] = true
288
- state[:last_review_output] = nil
289
- when 'implement'
290
- state[:last_review_output] = nil
291
- end
292
- end
293
-
294
138
  def run_final_verification(issue_number, logger:, claude:, chdir:)
295
139
  run_verification_with_retry(logger: logger, claude: claude, chdir: chdir,
296
140
  model: @verification_model) do |body|
@@ -298,29 +142,17 @@ module Ocak
298
142
  end
299
143
  end
300
144
 
301
- def log_cost_summary(total_cost, logger)
302
- return if total_cost.zero?
303
-
304
- budget = @config.cost_budget
305
- budget_str = budget ? " / $#{format('%.2f', budget)} budget" : ''
306
- logger.info("Pipeline cost: $#{format('%.4f', total_cost)}#{budget_str}")
307
- end
308
-
309
- def save_report(report, issue_number, success:, failed_phase: nil)
310
- report.finish(success: success, failed_phase: failed_phase)
311
- report.save(issue_number, project_dir: @config.project_dir)
312
- rescue StandardError => e
313
- @logger&.debug("Report save failed: #{e.message}")
314
- nil
315
- end
316
-
317
145
  def pipeline_state
318
146
  @pipeline_state ||= PipelineState.new(log_dir: File.join(@config.project_dir, @config.log_dir))
319
147
  end
320
148
 
321
149
  def current_branch(chdir, logger: nil)
322
- stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
323
- stdout.strip
150
+ result = run_git('rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
151
+ if result.status.nil?
152
+ logger&.debug("Could not determine current branch: #{result.error}")
153
+ return nil
154
+ end
155
+ result.output
324
156
  rescue StandardError => e
325
157
  logger&.debug("Could not determine current branch: #{e.message}")
326
158
  nil