ocak 0.1.0 → 0.3.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/README.md +246 -70
- data/lib/ocak/agent_generator.rb +6 -2
- data/lib/ocak/claude_runner.rb +7 -6
- data/lib/ocak/cli.rb +2 -0
- data/lib/ocak/commands/clean.rb +67 -6
- data/lib/ocak/commands/design.rb +1 -9
- data/lib/ocak/commands/hiz.rb +305 -0
- data/lib/ocak/commands/init.rb +12 -2
- data/lib/ocak/commands/resume.rb +34 -6
- data/lib/ocak/commands/run.rb +28 -6
- data/lib/ocak/commands/status.rb +120 -6
- data/lib/ocak/config.rb +11 -3
- data/lib/ocak/git_utils.rb +42 -0
- data/lib/ocak/issue_fetcher.rb +90 -5
- data/lib/ocak/logger.rb +10 -2
- data/lib/ocak/merge_manager.rb +63 -13
- data/lib/ocak/merge_orchestration.rb +102 -0
- data/lib/ocak/monorepo_detector.rb +97 -0
- data/lib/ocak/pipeline_executor.rb +316 -0
- data/lib/ocak/pipeline_runner.rb +106 -218
- data/lib/ocak/pipeline_state.rb +4 -2
- data/lib/ocak/planner.rb +5 -3
- data/lib/ocak/process_registry.rb +44 -0
- data/lib/ocak/process_runner.rb +11 -3
- data/lib/ocak/reready_processor.rb +174 -0
- data/lib/ocak/run_report.rb +82 -0
- data/lib/ocak/stack_detector.rb +149 -281
- data/lib/ocak/step_comments.rb +23 -0
- data/lib/ocak/stream_parser.rb +12 -2
- data/lib/ocak/templates/agents/documenter.md.erb +39 -9
- data/lib/ocak/templates/agents/implementer.md.erb +42 -0
- data/lib/ocak/templates/agents/planner.md.erb +9 -6
- data/lib/ocak/templates/gitignore_additions.txt +2 -0
- data/lib/ocak/templates/ocak.yml.erb +6 -2
- data/lib/ocak/worktree_manager.rb +6 -2
- data/lib/ocak.rb +1 -1
- metadata +10 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'pipeline_state'
|
|
6
|
+
require_relative 'run_report'
|
|
7
|
+
require_relative 'verification'
|
|
8
|
+
require_relative 'planner'
|
|
9
|
+
require_relative 'step_comments'
|
|
10
|
+
|
|
11
|
+
module Ocak
|
|
12
|
+
class PipelineExecutor
|
|
13
|
+
include Verification
|
|
14
|
+
include Planner
|
|
15
|
+
include StepComments
|
|
16
|
+
|
|
17
|
+
StepContext = Struct.new(:issue_number, :idx, :role, :result, :state, :logger, :chdir)
|
|
18
|
+
|
|
19
|
+
attr_writer :issues
|
|
20
|
+
|
|
21
|
+
def initialize(config:, issues: nil, shutdown_check: nil)
|
|
22
|
+
@config = config
|
|
23
|
+
@issues = issues
|
|
24
|
+
@shutdown_check = shutdown_check
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_pipeline(issue_number, logger:, claude:, chdir: nil, skip_steps: [], complexity: 'full')
|
|
28
|
+
chdir ||= @config.project_dir
|
|
29
|
+
logger.info("=== Starting pipeline for issue ##{issue_number} (#{complexity}) ===")
|
|
30
|
+
|
|
31
|
+
report = RunReport.new(complexity: complexity)
|
|
32
|
+
state = build_initial_state(complexity, report)
|
|
33
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
post_pipeline_start_comment(issue_number, state)
|
|
35
|
+
|
|
36
|
+
failure = run_pipeline_steps(issue_number, state, logger: logger, claude: claude, chdir: chdir,
|
|
37
|
+
skip_steps: skip_steps)
|
|
38
|
+
log_cost_summary(state[:total_cost], logger)
|
|
39
|
+
|
|
40
|
+
return handle_interrupted(issue_number, state, report, logger) if state[:interrupted]
|
|
41
|
+
return handle_failure(issue_number, state, failure, report, start_time) if failure
|
|
42
|
+
|
|
43
|
+
failure = run_final_verification(issue_number, logger: logger, claude: claude, chdir: chdir)
|
|
44
|
+
return handle_failure(issue_number, state, failure, report, start_time) if failure
|
|
45
|
+
|
|
46
|
+
pipeline_state.delete(issue_number)
|
|
47
|
+
finish_success(issue_number, state, report, start_time, logger)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_initial_state(complexity, report)
|
|
53
|
+
{ last_review_output: nil, had_fixes: false, completed_steps: [], total_cost: 0.0,
|
|
54
|
+
complexity: complexity, steps_run: 0, steps_skipped: 0,
|
|
55
|
+
audit_output: nil, audit_blocked: false, report: report }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_interrupted(issue_number, state, report, logger)
|
|
59
|
+
save_report(report, issue_number, success: false, failed_phase: 'interrupted')
|
|
60
|
+
logger.info("=== Pipeline interrupted for issue ##{issue_number} ===")
|
|
61
|
+
build_interrupted_result(state)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def handle_failure(issue_number, state, failure, report, start_time)
|
|
65
|
+
save_report(report, issue_number, success: false, failed_phase: failure[:phase])
|
|
66
|
+
post_failure_and_return(issue_number, state, failure, start_time)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish_success(issue_number, state, report, start_time, logger)
|
|
70
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
71
|
+
save_report(report, issue_number, success: true)
|
|
72
|
+
post_pipeline_summary_comment(issue_number, state, duration, success: true)
|
|
73
|
+
logger.info("=== Pipeline complete for issue ##{issue_number} ===")
|
|
74
|
+
{ success: true, output: 'Pipeline completed successfully',
|
|
75
|
+
audit_blocked: state[:audit_blocked], audit_output: state[:audit_output] }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_interrupted_result(state)
|
|
79
|
+
last_step = state[:completed_steps].any? ? @config.steps[state[:completed_steps].last] : nil
|
|
80
|
+
last_role = last_step ? symbolize(last_step)[:role].to_s : 'startup'
|
|
81
|
+
{ success: false, phase: last_role, output: 'Pipeline interrupted', interrupted: true }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def post_failure_and_return(issue_number, state, failure, start_time)
|
|
85
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
86
|
+
post_pipeline_summary_comment(issue_number, state, duration, success: false,
|
|
87
|
+
failed_phase: failure[:phase])
|
|
88
|
+
failure
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def run_pipeline_steps(issue_number, state, logger:, claude:, chdir:, skip_steps: [])
|
|
92
|
+
@config.steps.each_with_index do |step, idx|
|
|
93
|
+
break if check_shutdown(state, logger)
|
|
94
|
+
|
|
95
|
+
step = symbolize(step)
|
|
96
|
+
role = step[:role].to_s
|
|
97
|
+
agent = step[:agent].to_s
|
|
98
|
+
|
|
99
|
+
next if handle_already_completed(idx, role, skip_steps, logger)
|
|
100
|
+
|
|
101
|
+
reason = skip_reason(step, state)
|
|
102
|
+
if reason
|
|
103
|
+
logger.info("Skipping #{role} \u2014 #{reason}")
|
|
104
|
+
record_skipped_step(issue_number, state, idx, agent, role, reason)
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
result = execute_step(step, issue_number, state[:last_review_output], logger: logger, claude: claude,
|
|
109
|
+
chdir: chdir)
|
|
110
|
+
state[:report].record_step(index: idx, agent: agent, role: role, status: 'completed', result: result)
|
|
111
|
+
ctx = StepContext.new(issue_number, idx, role, result, state, logger, chdir)
|
|
112
|
+
failure = record_step_result(ctx)
|
|
113
|
+
return failure if failure
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def check_shutdown(state, logger)
|
|
119
|
+
return false unless @shutdown_check&.call
|
|
120
|
+
|
|
121
|
+
logger.info('Shutdown requested, stopping pipeline')
|
|
122
|
+
state[:interrupted] = true
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_already_completed(idx, role, skip_steps, logger)
|
|
126
|
+
return false unless skip_steps.include?(idx)
|
|
127
|
+
|
|
128
|
+
logger.info("Skipping #{role} (already completed)")
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record_skipped_step(issue_number, state, idx, agent, role, reason)
|
|
133
|
+
post_step_comment(issue_number, "\u{23ED}\u{FE0F} **Skipping #{role}** \u2014 #{reason}")
|
|
134
|
+
state[:report].record_step(index: idx, agent: agent, role: role, status: 'skipped', skip_reason: reason)
|
|
135
|
+
state[:steps_skipped] += 1
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def execute_step(step, issue_number, review_output, logger:, claude:, chdir:)
|
|
139
|
+
agent = step[:agent].to_s
|
|
140
|
+
role = step[:role].to_s
|
|
141
|
+
logger.info("--- Phase: #{role} (#{agent}) ---")
|
|
142
|
+
post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})")
|
|
143
|
+
prompt = build_step_prompt(role, issue_number, review_output)
|
|
144
|
+
claude.run_agent(agent.tr('_', '-'), prompt, chdir: chdir)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def record_step_result(ctx)
|
|
148
|
+
update_pipeline_state(ctx.role, ctx.result, ctx.state)
|
|
149
|
+
ctx.state[:completed_steps] << ctx.idx
|
|
150
|
+
ctx.state[:steps_run] += 1
|
|
151
|
+
ctx.state[:total_cost] += ctx.result.cost_usd.to_f
|
|
152
|
+
save_step_progress(ctx)
|
|
153
|
+
write_step_output(ctx.issue_number, ctx.idx, ctx.role, ctx.result.output)
|
|
154
|
+
post_step_completion_comment(ctx.issue_number, ctx.role, ctx.result)
|
|
155
|
+
|
|
156
|
+
check_step_failure(ctx) || check_cost_budget(ctx.state, ctx.logger)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def save_step_progress(ctx)
|
|
160
|
+
pipeline_state.save(ctx.issue_number,
|
|
161
|
+
completed_steps: ctx.state[:completed_steps],
|
|
162
|
+
worktree_path: ctx.chdir,
|
|
163
|
+
branch: current_branch(ctx.chdir, logger: ctx.logger))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def write_step_output(issue_number, idx, agent, output)
|
|
167
|
+
return if output.to_s.empty?
|
|
168
|
+
|
|
169
|
+
safe_agent = agent.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
|
|
170
|
+
dir = File.join(@config.project_dir, '.ocak', 'logs', "issue-#{issue_number}")
|
|
171
|
+
FileUtils.mkdir_p(dir)
|
|
172
|
+
File.write(File.join(dir, "step-#{idx}-#{safe_agent}.md"), output)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil # sidecar write failures must never crash the pipeline
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def check_step_failure(ctx)
|
|
178
|
+
return nil if ctx.result.success? || !%w[implement merge].include?(ctx.role)
|
|
179
|
+
|
|
180
|
+
ctx.logger.error("#{ctx.role} failed")
|
|
181
|
+
{ success: false, phase: ctx.role, output: ctx.result.output }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def check_cost_budget(state, logger)
|
|
185
|
+
return nil unless @config.cost_budget && state[:total_cost] > @config.cost_budget
|
|
186
|
+
|
|
187
|
+
cost = format('%.2f', state[:total_cost])
|
|
188
|
+
budget = format('%.2f', @config.cost_budget)
|
|
189
|
+
logger.error("Cost budget exceeded ($#{cost}/$#{budget})")
|
|
190
|
+
{ success: false, phase: 'budget', output: "Cost budget exceeded: $#{cost}" }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def skip_reason(step, state)
|
|
194
|
+
condition = step[:condition]
|
|
195
|
+
|
|
196
|
+
return 'audit found blocking issues' if step[:role].to_s == 'merge' && @config.audit_mode && state[:audit_blocked]
|
|
197
|
+
return 'manual review mode' if step[:role].to_s == 'merge' && @config.manual_review
|
|
198
|
+
return 'fast-track issue (simple complexity)' if step[:complexity] == 'full' && state[:complexity] == 'simple'
|
|
199
|
+
if condition == 'has_findings' && !state[:last_review_output]&.include?("\u{1F534}")
|
|
200
|
+
return 'no blocking findings from review'
|
|
201
|
+
end
|
|
202
|
+
return 'no fixes were made' if condition == 'had_fixes' && !state[:had_fixes]
|
|
203
|
+
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def update_pipeline_state(role, result, state)
|
|
208
|
+
case role
|
|
209
|
+
when 'review', 'verify', 'security', 'audit'
|
|
210
|
+
state[:last_review_output] = result.output
|
|
211
|
+
if role == 'audit'
|
|
212
|
+
state[:audit_output] = result.output
|
|
213
|
+
state[:audit_blocked] = !result.success? || result.output.to_s.match?(/BLOCK|🔴/)
|
|
214
|
+
end
|
|
215
|
+
when 'fix'
|
|
216
|
+
state[:had_fixes] = true
|
|
217
|
+
state[:last_review_output] = nil
|
|
218
|
+
when 'implement'
|
|
219
|
+
state[:last_review_output] = nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def run_final_verification(issue_number, logger:, claude:, chdir:)
|
|
224
|
+
return nil unless @config.test_command || @config.lint_check_command
|
|
225
|
+
|
|
226
|
+
logger.info('--- Final verification ---')
|
|
227
|
+
post_step_comment(issue_number, "\u{1F504} **Phase: final-verify** (verification)")
|
|
228
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
229
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
230
|
+
|
|
231
|
+
unless result[:success]
|
|
232
|
+
logger.warn('Final checks failed, attempting fix...')
|
|
233
|
+
post_step_comment(issue_number, "\u{26A0}\u{FE0F} **Final verification failed** \u2014 attempting auto-fix...")
|
|
234
|
+
fix_prompt = "Fix these test/lint failures:\n\n" \
|
|
235
|
+
"<verification_output>\n#{result[:output]}\n</verification_output>"
|
|
236
|
+
claude.run_agent('implementer', fix_prompt, chdir: chdir)
|
|
237
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
241
|
+
if result[:success]
|
|
242
|
+
post_step_comment(issue_number, "\u{2705} **Phase: final-verify** completed \u{2014} #{duration}s")
|
|
243
|
+
nil
|
|
244
|
+
else
|
|
245
|
+
post_step_comment(issue_number, "\u{274C} **Phase: final-verify** failed \u{2014} #{duration}s")
|
|
246
|
+
{ success: false, phase: 'final-verify', output: result[:output] }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def log_cost_summary(total_cost, logger)
|
|
251
|
+
return if total_cost.zero?
|
|
252
|
+
|
|
253
|
+
budget = @config.cost_budget
|
|
254
|
+
budget_str = budget ? " / $#{format('%.2f', budget)} budget" : ''
|
|
255
|
+
logger.info("Pipeline cost: $#{format('%.4f', total_cost)}#{budget_str}")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def save_report(report, issue_number, success:, failed_phase: nil)
|
|
259
|
+
report.finish(success: success, failed_phase: failed_phase)
|
|
260
|
+
report.save(issue_number, project_dir: @config.project_dir)
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil # report save failures must never crash the pipeline
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def pipeline_state
|
|
266
|
+
@pipeline_state ||= PipelineState.new(log_dir: File.join(@config.project_dir, @config.log_dir))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def current_branch(chdir, logger: nil)
|
|
270
|
+
stdout, = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD', chdir: chdir)
|
|
271
|
+
stdout.strip
|
|
272
|
+
rescue StandardError => e
|
|
273
|
+
logger&.debug("Could not determine current branch: #{e.message}")
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def post_pipeline_start_comment(issue_number, state)
|
|
278
|
+
total = @config.steps.size
|
|
279
|
+
conditional = conditional_step_count(state)
|
|
280
|
+
post_step_comment(issue_number,
|
|
281
|
+
"\u{1F680} **Pipeline started** \u2014 complexity: `#{state[:complexity]}` " \
|
|
282
|
+
"| steps: #{total} (#{conditional} may be skipped)")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def post_pipeline_summary_comment(issue_number, state, duration, success:, failed_phase: nil)
|
|
286
|
+
total = @config.steps.size
|
|
287
|
+
cost = format('%.2f', state[:total_cost])
|
|
288
|
+
|
|
289
|
+
if success
|
|
290
|
+
post_step_comment(issue_number,
|
|
291
|
+
"\u{2705} **Pipeline complete** \u2014 #{state[:steps_run]}/#{total} steps run " \
|
|
292
|
+
"| #{state[:steps_skipped]} skipped | $#{cost} total | #{duration}s")
|
|
293
|
+
else
|
|
294
|
+
post_step_comment(issue_number,
|
|
295
|
+
"\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
|
|
296
|
+
"#{state[:steps_run]}/#{total} steps completed | $#{cost} total")
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def conditional_step_count(state)
|
|
301
|
+
@config.steps.count do |step|
|
|
302
|
+
step = symbolize(step)
|
|
303
|
+
step[:condition] ||
|
|
304
|
+
(step[:complexity] == 'full' && state[:complexity] == 'simple') ||
|
|
305
|
+
(step[:role].to_s == 'merge' && @config.manual_review) ||
|
|
306
|
+
(step[:role].to_s == 'merge' && @config.audit_mode) # merge may be skipped if audit finds blocking issues
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def symbolize(hash)
|
|
311
|
+
return hash unless hash.is_a?(Hash)
|
|
312
|
+
|
|
313
|
+
hash.transform_keys(&:to_sym)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|