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,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative '../config'
|
|
6
|
+
require_relative '../claude_runner'
|
|
7
|
+
require_relative '../git_utils'
|
|
8
|
+
require_relative '../issue_fetcher'
|
|
9
|
+
require_relative '../verification'
|
|
10
|
+
require_relative '../planner'
|
|
11
|
+
require_relative '../step_comments'
|
|
12
|
+
require_relative '../logger'
|
|
13
|
+
|
|
14
|
+
module Ocak
|
|
15
|
+
module Commands
|
|
16
|
+
class Hiz < Dry::CLI::Command
|
|
17
|
+
include Verification
|
|
18
|
+
include Planner
|
|
19
|
+
include StepComments
|
|
20
|
+
|
|
21
|
+
desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
|
|
22
|
+
|
|
23
|
+
argument :issue, type: :integer, required: true, desc: 'Issue number to process'
|
|
24
|
+
option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
|
|
25
|
+
option :dry_run, type: :boolean, default: false, desc: 'Show pipeline plan without executing'
|
|
26
|
+
option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
|
|
27
|
+
option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
|
|
28
|
+
|
|
29
|
+
STEP_MODELS = {
|
|
30
|
+
'implementer' => 'sonnet',
|
|
31
|
+
'reviewer' => 'haiku',
|
|
32
|
+
'security-reviewer' => 'sonnet'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
IMPLEMENT_STEP = { agent: 'implementer', role: 'implement' }.freeze
|
|
36
|
+
|
|
37
|
+
REVIEW_STEPS = [
|
|
38
|
+
{ agent: 'reviewer', role: 'review' },
|
|
39
|
+
{ agent: 'security-reviewer', role: 'security' }
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
|
|
43
|
+
|
|
44
|
+
def call(issue:, **options)
|
|
45
|
+
@config = Config.load
|
|
46
|
+
issue_number = issue.to_i
|
|
47
|
+
|
|
48
|
+
if options[:dry_run]
|
|
49
|
+
print_dry_run(issue_number)
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
logger = build_logger(issue_number)
|
|
54
|
+
watch_formatter = options[:watch] ? WatchFormatter.new : nil
|
|
55
|
+
claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
|
|
56
|
+
issues = IssueFetcher.new(config: @config, logger: logger)
|
|
57
|
+
|
|
58
|
+
logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
|
|
59
|
+
|
|
60
|
+
run_fast_pipeline(issue_number, claude: claude, logger: logger, issues: issues)
|
|
61
|
+
rescue Config::ConfigNotFound => e
|
|
62
|
+
warn "Error: #{e.message}"
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def print_dry_run(issue_number)
|
|
69
|
+
puts "[DRY RUN] Hiz pipeline for issue ##{issue_number}:"
|
|
70
|
+
puts ' 1. implement (implementer) [sonnet]'
|
|
71
|
+
puts ' 2. review (reviewer) [haiku] || security (security-reviewer) [sonnet]'
|
|
72
|
+
has_verify = @config.test_command || @config.lint_check_command
|
|
73
|
+
puts ' 3. final-verify (verification)' if has_verify
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run_fast_pipeline(issue_number, claude:, logger:, issues:)
|
|
77
|
+
state = HizState.new(issues, 0.0, 0, {})
|
|
78
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
79
|
+
chdir = @config.project_dir
|
|
80
|
+
|
|
81
|
+
post_hiz_start_comment(issue_number, state: state)
|
|
82
|
+
branch = create_branch(issue_number, chdir)
|
|
83
|
+
|
|
84
|
+
failure = run_agents(issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
|
|
85
|
+
if failure
|
|
86
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
87
|
+
post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: failure[:phase],
|
|
88
|
+
state: state)
|
|
89
|
+
handle_failure(issue_number, failure[:phase], failure[:output], issues: state.issues, logger: logger)
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
verification_failure = run_final_verification_step(issue_number, claude: claude, logger: logger,
|
|
94
|
+
chdir: chdir, state: state)
|
|
95
|
+
if verification_failure
|
|
96
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
97
|
+
post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: 'final-verify',
|
|
98
|
+
state: state)
|
|
99
|
+
handle_failure(issue_number, 'final-verify', verification_failure[:output],
|
|
100
|
+
issues: state.issues, logger: logger)
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
|
|
105
|
+
post_hiz_summary_comment(issue_number, duration, success: true, state: state)
|
|
106
|
+
push_and_create_pr(issue_number, branch, logger: logger, chdir: chdir, state: state)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run_agents(issue_number, claude:, logger:, chdir:, state:)
|
|
110
|
+
result = run_step(IMPLEMENT_STEP, issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
|
|
111
|
+
state.steps_run += 1
|
|
112
|
+
state.total_cost += result.cost_usd.to_f
|
|
113
|
+
unless result.success?
|
|
114
|
+
logger.error("Implementation failed for issue ##{issue_number}")
|
|
115
|
+
return { phase: 'implement', output: result.output }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
state.review_results = run_reviews_in_parallel(issue_number, claude: claude, logger: logger,
|
|
119
|
+
chdir: chdir, state: state)
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def create_branch(issue_number, chdir)
|
|
124
|
+
branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
|
|
125
|
+
_, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
|
|
126
|
+
raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
|
|
127
|
+
|
|
128
|
+
branch
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def run_reviews_in_parallel(issue_number, claude:, logger:, chdir:, state:)
|
|
132
|
+
threads = REVIEW_STEPS.map do |step|
|
|
133
|
+
Thread.new do
|
|
134
|
+
run_step(step, issue_number, claude: claude, logger: logger, chdir: chdir, state: state)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
logger.error("#{step[:role]} thread failed: #{e.message}")
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
results = {}
|
|
142
|
+
threads.each_with_index do |thread, i|
|
|
143
|
+
result = thread.value
|
|
144
|
+
step = REVIEW_STEPS[i]
|
|
145
|
+
if result
|
|
146
|
+
state.steps_run += 1
|
|
147
|
+
state.total_cost += result.cost_usd.to_f
|
|
148
|
+
results[step[:role]] = result if result.blocking_findings? || result.warnings?
|
|
149
|
+
end
|
|
150
|
+
next if result.nil? || result.success?
|
|
151
|
+
|
|
152
|
+
logger.warn("#{step[:role]} reported issues but continuing")
|
|
153
|
+
end
|
|
154
|
+
results
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def run_step(step, issue_number, claude:, logger:, chdir:, state:)
|
|
158
|
+
agent = step[:agent]
|
|
159
|
+
role = step[:role]
|
|
160
|
+
model = STEP_MODELS[agent]
|
|
161
|
+
logger.info("--- Phase: #{role} (#{agent}) [#{model}] ---")
|
|
162
|
+
post_step_comment(issue_number, "\u{1F504} **Phase: #{role}** (#{agent})", state: state)
|
|
163
|
+
prompt = build_step_prompt(role, issue_number, nil)
|
|
164
|
+
result = claude.run_agent(agent, prompt, chdir: chdir, model: model)
|
|
165
|
+
post_step_completion_comment(issue_number, role, result, state: state)
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def run_final_verification_step(issue_number, claude:, logger:, chdir:, state:)
|
|
170
|
+
return nil unless @config.test_command || @config.lint_check_command
|
|
171
|
+
|
|
172
|
+
logger.info('--- Final verification ---')
|
|
173
|
+
post_step_comment(issue_number, "\u{1F504} **Phase: final-verify** (verification)", state: state)
|
|
174
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
175
|
+
|
|
176
|
+
unless result[:success]
|
|
177
|
+
logger.warn('Final checks failed, attempting fix...')
|
|
178
|
+
post_step_comment(issue_number,
|
|
179
|
+
"\u{26A0}\u{FE0F} **Final verification failed** \u2014 attempting auto-fix...",
|
|
180
|
+
state: state)
|
|
181
|
+
fix_prompt = "Fix these test/lint failures:\n\n" \
|
|
182
|
+
"<verification_output>\n#{result[:output]}\n</verification_output>"
|
|
183
|
+
claude.run_agent('implementer', fix_prompt,
|
|
184
|
+
chdir: chdir, model: STEP_MODELS['implementer'])
|
|
185
|
+
result = run_final_checks(logger, chdir: chdir)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
state.steps_run += 1
|
|
189
|
+
if result[:success]
|
|
190
|
+
post_step_comment(issue_number, "\u{2705} **Phase: final-verify** completed", state: state)
|
|
191
|
+
nil
|
|
192
|
+
else
|
|
193
|
+
post_step_comment(issue_number, "\u{274C} **Phase: final-verify** failed", state: state)
|
|
194
|
+
{ success: false, phase: 'final-verify', output: result[:output] }
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
|
|
199
|
+
commit_changes(issue_number, chdir, logger: logger)
|
|
200
|
+
|
|
201
|
+
_, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
|
|
202
|
+
unless status.success?
|
|
203
|
+
logger.error("Push failed: #{stderr}")
|
|
204
|
+
handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger)
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
issue_data = state.issues.view(issue_number)
|
|
209
|
+
issue_title = issue_data&.dig('title')
|
|
210
|
+
pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
|
|
211
|
+
pr_body = build_pr_body(issue_number, state: state)
|
|
212
|
+
|
|
213
|
+
stdout, stderr, status = Open3.capture3(
|
|
214
|
+
'gh', 'pr', 'create',
|
|
215
|
+
'--title', pr_title,
|
|
216
|
+
'--body', pr_body,
|
|
217
|
+
'--head', branch,
|
|
218
|
+
chdir: chdir
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if status.success?
|
|
222
|
+
pr_url = stdout.strip
|
|
223
|
+
logger.info("PR created: #{pr_url}")
|
|
224
|
+
puts "PR created: #{pr_url}"
|
|
225
|
+
else
|
|
226
|
+
logger.error("PR creation failed: #{stderr}")
|
|
227
|
+
handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def commit_changes(issue_number, chdir, logger:)
|
|
232
|
+
GitUtils.commit_changes(
|
|
233
|
+
chdir: chdir,
|
|
234
|
+
message: "feat: implement issue ##{issue_number} [hiz]",
|
|
235
|
+
logger: logger
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def build_pr_body(issue_number, state:)
|
|
240
|
+
body = "Automated PR for issue ##{issue_number} (hiz fast mode)\n\nCloses ##{issue_number}"
|
|
241
|
+
return body if state.review_results.nil? || state.review_results.empty?
|
|
242
|
+
|
|
243
|
+
state.review_results.each do |role, result|
|
|
244
|
+
heading = role == 'review' ? 'Review Findings' : 'Security Review Findings'
|
|
245
|
+
body += "\n\n---\n\n## #{heading}\n\n#{result.output}"
|
|
246
|
+
end
|
|
247
|
+
body
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def handle_failure(issue_number, phase, output, issues:, logger:)
|
|
251
|
+
logger.error("Issue ##{issue_number} failed at phase: #{phase}")
|
|
252
|
+
issues.comment(issue_number,
|
|
253
|
+
"Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{output.to_s[0..1000]}\n```")
|
|
254
|
+
warn "Issue ##{issue_number} failed at phase: #{phase}"
|
|
255
|
+
_, stderr, status = Open3.capture3('git', 'checkout', 'main', chdir: @config.project_dir)
|
|
256
|
+
logger.warn("Cleanup checkout to main failed: #{stderr}") unless status.success?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_logger(issue_number)
|
|
260
|
+
PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def post_step_comment(issue_number, body, state:)
|
|
264
|
+
state.issues&.comment(issue_number, body)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def post_step_completion_comment(issue_number, role, result, state:)
|
|
270
|
+
duration = (result.duration_ms.to_f / 1000).round
|
|
271
|
+
cost = format('%.3f', result.cost_usd.to_f)
|
|
272
|
+
if result.success?
|
|
273
|
+
post_step_comment(issue_number, "\u{2705} **Phase: #{role}** completed \u2014 #{duration}s | $#{cost}",
|
|
274
|
+
state: state)
|
|
275
|
+
else
|
|
276
|
+
post_step_comment(issue_number, "\u{274C} **Phase: #{role}** failed \u2014 #{duration}s | $#{cost}",
|
|
277
|
+
state: state)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def post_hiz_start_comment(issue_number, state:)
|
|
282
|
+
steps = "implement \u2192 review \u2225 security"
|
|
283
|
+
steps += " \u2192 verify" if @config.test_command || @config.lint_check_command
|
|
284
|
+
post_step_comment(issue_number, "\u{1F680} **Hiz (fast mode) started** \u2014 #{steps}", state: state)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def post_hiz_summary_comment(issue_number, duration, success:, state:, failed_phase: nil)
|
|
288
|
+
total = 3 + (@config.test_command || @config.lint_check_command ? 1 : 0)
|
|
289
|
+
cost = format('%.2f', state.total_cost)
|
|
290
|
+
|
|
291
|
+
if success
|
|
292
|
+
post_step_comment(issue_number,
|
|
293
|
+
"\u{2705} **Pipeline complete** \u2014 #{state.steps_run}/#{total} steps run " \
|
|
294
|
+
"| 0 skipped | $#{cost} total | #{duration}s",
|
|
295
|
+
state: state)
|
|
296
|
+
else
|
|
297
|
+
post_step_comment(issue_number,
|
|
298
|
+
"\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
|
|
299
|
+
"#{state.steps_run}/#{total} steps completed | $#{cost} total",
|
|
300
|
+
state: state)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'fileutils'
|
|
|
5
5
|
require_relative '../stack_detector'
|
|
6
6
|
require_relative '../agent_generator'
|
|
7
7
|
require_relative '../config'
|
|
8
|
+
require_relative '../issue_fetcher'
|
|
8
9
|
|
|
9
10
|
module Ocak
|
|
10
11
|
module Commands
|
|
@@ -36,6 +37,7 @@ module Ocak
|
|
|
36
37
|
generate_files(generator, project_dir, options)
|
|
37
38
|
update_settings(project_dir, stack)
|
|
38
39
|
update_gitignore(project_dir)
|
|
40
|
+
create_labels(project_dir)
|
|
39
41
|
|
|
40
42
|
puts ''
|
|
41
43
|
print_summary(project_dir, stack, options)
|
|
@@ -174,6 +176,15 @@ module Ocak
|
|
|
174
176
|
puts ' Updated .gitignore'
|
|
175
177
|
end
|
|
176
178
|
|
|
179
|
+
def create_labels(project_dir)
|
|
180
|
+
config = Config.load(project_dir)
|
|
181
|
+
fetcher = IssueFetcher.new(config: config)
|
|
182
|
+
fetcher.ensure_labels(config.all_labels)
|
|
183
|
+
puts ' Created GitHub labels'
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
puts " Warning: could not create labels: #{e.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
177
188
|
def print_summary(_project_dir, _stack, options)
|
|
178
189
|
puts 'Ocak initialized successfully!'
|
|
179
190
|
puts ''
|
|
@@ -194,10 +205,9 @@ module Ocak
|
|
|
194
205
|
puts ' 5. Run the pipeline: ocak run --once'
|
|
195
206
|
puts ''
|
|
196
207
|
puts 'Quick commands:'
|
|
197
|
-
puts ' ocak run
|
|
208
|
+
puts ' ocak run 42 Run one issue'
|
|
198
209
|
puts ' ocak run --watch Run with live output'
|
|
199
210
|
puts ' ocak status Check pipeline state'
|
|
200
|
-
puts ' ocak audit Run codebase audit'
|
|
201
211
|
end
|
|
202
212
|
|
|
203
213
|
def init_logger
|
data/lib/ocak/commands/resume.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative '../config'
|
|
4
|
+
require_relative '../git_utils'
|
|
4
5
|
require_relative '../pipeline_runner'
|
|
5
6
|
require_relative '../pipeline_state'
|
|
6
7
|
require_relative '../claude_runner'
|
|
@@ -16,14 +17,23 @@ module Ocak
|
|
|
16
17
|
|
|
17
18
|
argument :issue, type: :integer, required: true, desc: 'Issue number to resume'
|
|
18
19
|
option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
|
|
20
|
+
option :dry_run, type: :boolean, default: false, desc: 'Show what would re-run without executing'
|
|
21
|
+
option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
|
|
22
|
+
option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
|
|
19
23
|
|
|
20
24
|
def call(issue:, **options)
|
|
21
25
|
config = Config.load
|
|
22
26
|
issue_number = issue.to_i
|
|
23
27
|
saved = load_state(config, issue_number)
|
|
24
|
-
chdir = resolve_worktree(config, saved)
|
|
25
28
|
|
|
26
29
|
print_resume_info(issue_number, saved, config)
|
|
30
|
+
|
|
31
|
+
if options[:dry_run]
|
|
32
|
+
print_dry_run(saved, config)
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
chdir = resolve_worktree(config, saved)
|
|
27
37
|
run_resumed_pipeline(config, issue_number, saved, chdir, options)
|
|
28
38
|
rescue Config::ConfigNotFound => e
|
|
29
39
|
warn "Error: #{e.message}"
|
|
@@ -51,6 +61,15 @@ module Ocak
|
|
|
51
61
|
puts ''
|
|
52
62
|
end
|
|
53
63
|
|
|
64
|
+
def print_dry_run(saved, config)
|
|
65
|
+
completed = saved[:completed_steps] || []
|
|
66
|
+
puts '[DRY RUN] Steps that would re-run:'
|
|
67
|
+
config.steps.each_with_index do |step, idx|
|
|
68
|
+
status = completed.include?(idx) ? 'skip (completed)' : 'run'
|
|
69
|
+
puts " #{idx + 1}. #{step['role']} (#{step['agent']}) — #{status}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
def run_resumed_pipeline(config, issue_number, saved, chdir, options)
|
|
55
74
|
log_dir = File.join(config.project_dir, config.log_dir)
|
|
56
75
|
logger = PipelineLogger.new(log_dir: log_dir, issue_number: issue_number)
|
|
@@ -61,9 +80,9 @@ module Ocak
|
|
|
61
80
|
issues.transition(issue_number, from: config.label_failed, to: config.label_in_progress)
|
|
62
81
|
|
|
63
82
|
runner = PipelineRunner.new(config: config, options: { watch: options[:watch] })
|
|
64
|
-
result = runner.
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
result = runner.run_pipeline(issue_number,
|
|
84
|
+
logger: logger, claude: claude, chdir: chdir,
|
|
85
|
+
skip_steps: saved[:completed_steps])
|
|
67
86
|
|
|
68
87
|
ctx = { config: config, issue_number: issue_number, saved: saved, chdir: chdir,
|
|
69
88
|
issues: issues, claude: claude, logger: logger, watch: watch_formatter }
|
|
@@ -84,7 +103,7 @@ module Ocak
|
|
|
84
103
|
|
|
85
104
|
def attempt_merge(ctx)
|
|
86
105
|
merger = MergeManager.new(config: ctx[:config], claude: ctx[:claude],
|
|
87
|
-
logger: ctx[:logger], watch: ctx[:watch])
|
|
106
|
+
logger: ctx[:logger], issues: ctx[:issues], watch: ctx[:watch])
|
|
88
107
|
worktree = WorktreeManager::Worktree.new(
|
|
89
108
|
path: ctx[:chdir], branch: ctx[:saved][:branch], issue_number: ctx[:issue_number]
|
|
90
109
|
)
|
|
@@ -112,6 +131,11 @@ module Ocak
|
|
|
112
131
|
exit 1
|
|
113
132
|
end
|
|
114
133
|
|
|
134
|
+
unless GitUtils.safe_branch_name?(saved[:branch])
|
|
135
|
+
warn "Unsafe branch name '#{saved[:branch]}'. Cannot resume."
|
|
136
|
+
exit 1
|
|
137
|
+
end
|
|
138
|
+
|
|
115
139
|
_, _, status = Open3.capture3('git', 'rev-parse', '--verify', saved[:branch], chdir: config.project_dir)
|
|
116
140
|
unless status.success?
|
|
117
141
|
warn "Worktree no longer exists and branch '#{saved[:branch]}' not found. Cannot resume."
|
|
@@ -120,7 +144,11 @@ module Ocak
|
|
|
120
144
|
|
|
121
145
|
worktrees = WorktreeManager.new(config: config)
|
|
122
146
|
wt = worktrees.create(saved[:issue_number], setup_command: config.setup_command)
|
|
123
|
-
Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
|
|
147
|
+
_, stderr, status = Open3.capture3('git', 'checkout', saved[:branch], chdir: wt.path)
|
|
148
|
+
unless status.success?
|
|
149
|
+
warn "Failed to checkout branch '#{saved[:branch]}': #{stderr}"
|
|
150
|
+
exit 1
|
|
151
|
+
end
|
|
124
152
|
wt.path
|
|
125
153
|
end
|
|
126
154
|
end
|
data/lib/ocak/commands/run.rb
CHANGED
|
@@ -13,32 +13,49 @@ module Ocak
|
|
|
13
13
|
class Run < Dry::CLI::Command
|
|
14
14
|
desc 'Run the issue processing pipeline'
|
|
15
15
|
|
|
16
|
+
argument :issue, type: :integer, required: false, desc: 'Issue number (single-issue mode)'
|
|
17
|
+
|
|
16
18
|
option :watch, type: :boolean, default: false, desc: 'Stream agent activity to terminal'
|
|
17
|
-
option :single, type: :integer, desc: 'Run a single issue without worktrees'
|
|
18
19
|
option :dry_run, type: :boolean, default: false, desc: 'Show what would happen'
|
|
19
20
|
option :once, type: :boolean, default: false, desc: 'Process current batch and exit'
|
|
20
21
|
option :max_parallel, type: :integer, desc: 'Max concurrent pipelines'
|
|
21
22
|
option :poll_interval, type: :integer, desc: 'Seconds between polls'
|
|
23
|
+
option :manual_review, type: :boolean, default: false,
|
|
24
|
+
desc: 'Create PRs without auto-merge; wait for human review'
|
|
25
|
+
option :audit, type: :boolean, default: false,
|
|
26
|
+
desc: 'Run auditor as post-pipeline gate; create PR with findings if issues found'
|
|
27
|
+
option :fast, type: :boolean, default: false,
|
|
28
|
+
desc: 'Lean pipeline — skip security, document, audit steps'
|
|
29
|
+
option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
|
|
30
|
+
option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
|
|
22
31
|
|
|
23
|
-
def call(**options)
|
|
32
|
+
def call(issue: nil, **options)
|
|
24
33
|
config = Config.load
|
|
25
34
|
|
|
26
35
|
# CLI options override config
|
|
27
36
|
config.override(:max_parallel, options[:max_parallel]) if options[:max_parallel]
|
|
28
37
|
config.override(:poll_interval, options[:poll_interval]) if options[:poll_interval]
|
|
38
|
+
config.override(:manual_review, true) if options[:manual_review]
|
|
39
|
+
config.override(:audit_mode, true) if options[:audit]
|
|
40
|
+
|
|
41
|
+
log_level = resolve_log_level(options)
|
|
29
42
|
|
|
30
43
|
runner = PipelineRunner.new(
|
|
31
44
|
config: config,
|
|
32
45
|
options: {
|
|
33
46
|
watch: options[:watch],
|
|
34
|
-
single:
|
|
47
|
+
single: issue&.to_i,
|
|
35
48
|
dry_run: options[:dry_run],
|
|
36
|
-
once: options[:once]
|
|
49
|
+
once: options[:once],
|
|
50
|
+
fast: options[:fast],
|
|
51
|
+
log_level: log_level
|
|
37
52
|
}
|
|
38
53
|
)
|
|
39
54
|
|
|
40
55
|
setup_signal_handlers(runner)
|
|
41
56
|
runner.run
|
|
57
|
+
runner.print_shutdown_summary if runner.shutting_down?
|
|
58
|
+
exit 130 if runner.shutting_down?
|
|
42
59
|
rescue Config::ConfigNotFound => e
|
|
43
60
|
warn "Error: #{e.message}"
|
|
44
61
|
exit 1
|
|
@@ -46,12 +63,17 @@ module Ocak
|
|
|
46
63
|
|
|
47
64
|
private
|
|
48
65
|
|
|
66
|
+
def resolve_log_level(options)
|
|
67
|
+
return :quiet if options[:quiet]
|
|
68
|
+
return :verbose if options[:verbose]
|
|
69
|
+
|
|
70
|
+
:normal
|
|
71
|
+
end
|
|
72
|
+
|
|
49
73
|
def setup_signal_handlers(runner)
|
|
50
74
|
%w[INT TERM].each do |signal|
|
|
51
75
|
trap(signal) do
|
|
52
|
-
warn "\nReceived #{signal}, shutting down gracefully..."
|
|
53
76
|
runner.shutdown!
|
|
54
|
-
exit 0
|
|
55
77
|
end
|
|
56
78
|
end
|
|
57
79
|
end
|
data/lib/ocak/commands/status.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'open3'
|
|
4
4
|
require 'json'
|
|
5
5
|
require_relative '../config'
|
|
6
|
+
require_relative '../run_report'
|
|
6
7
|
require_relative '../worktree_manager'
|
|
7
8
|
|
|
8
9
|
module Ocak
|
|
@@ -10,9 +11,24 @@ module Ocak
|
|
|
10
11
|
class Status < Dry::CLI::Command
|
|
11
12
|
desc 'Show pipeline status'
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
option :report, type: :boolean, default: false, desc: 'Show run reports'
|
|
15
|
+
|
|
16
|
+
def call(**options)
|
|
14
17
|
config = Config.load
|
|
15
18
|
|
|
19
|
+
if options[:report]
|
|
20
|
+
show_reports(config)
|
|
21
|
+
else
|
|
22
|
+
show_default_status(config)
|
|
23
|
+
end
|
|
24
|
+
rescue Config::ConfigNotFound => e
|
|
25
|
+
warn "Error: #{e.message}"
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def show_default_status(config)
|
|
16
32
|
puts 'Pipeline Status'
|
|
17
33
|
puts '=' * 40
|
|
18
34
|
puts ''
|
|
@@ -22,13 +38,8 @@ module Ocak
|
|
|
22
38
|
show_worktrees(config)
|
|
23
39
|
puts ''
|
|
24
40
|
show_recent_logs(config)
|
|
25
|
-
rescue Config::ConfigNotFound => e
|
|
26
|
-
warn "Error: #{e.message}"
|
|
27
|
-
exit 1
|
|
28
41
|
end
|
|
29
42
|
|
|
30
|
-
private
|
|
31
|
-
|
|
32
43
|
def show_issues(config)
|
|
33
44
|
puts 'Issues:'
|
|
34
45
|
|
|
@@ -72,6 +83,109 @@ module Ocak
|
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
|
|
86
|
+
def show_reports(config)
|
|
87
|
+
reports = RunReport.load_all(project_dir: config.project_dir)
|
|
88
|
+
|
|
89
|
+
if reports.empty?
|
|
90
|
+
puts 'No run reports found.'
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sorted = reports.sort_by { |r| r[:started_at].to_s }.reverse
|
|
95
|
+
show_recent_runs(sorted)
|
|
96
|
+
puts ''
|
|
97
|
+
show_aggregates(sorted)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show_recent_runs(reports)
|
|
101
|
+
puts 'Recent Runs (last 10):'
|
|
102
|
+
reports.first(10).each do |r|
|
|
103
|
+
icon = r[:success] ? "\u2705" : "\u274C"
|
|
104
|
+
steps_str = step_count_str(r)
|
|
105
|
+
date = format_report_date(r[:started_at])
|
|
106
|
+
failed = r[:success] ? '' : " (failed: #{r[:failed_phase]})"
|
|
107
|
+
cost = format('$%.2f', r[:total_cost_usd].to_f)
|
|
108
|
+
puts " ##{r[:issue_number]} #{icon} #{r[:total_duration_s]}s #{cost} #{steps_str} #{date}#{failed}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def show_aggregates(reports)
|
|
113
|
+
recent = reports.first(20)
|
|
114
|
+
puts "Aggregates (last #{recent.size} runs):"
|
|
115
|
+
|
|
116
|
+
avg_cost = recent.sum { |r| r[:total_cost_usd].to_f } / recent.size
|
|
117
|
+
avg_duration = recent.sum { |r| r[:total_duration_s].to_i } / recent.size
|
|
118
|
+
success_count = recent.count { |r| r[:success] }
|
|
119
|
+
success_rate = (success_count.to_f / recent.size * 100).round
|
|
120
|
+
|
|
121
|
+
puts " Avg cost: $#{format('%.2f', avg_cost)}"
|
|
122
|
+
puts " Avg duration: #{avg_duration}s"
|
|
123
|
+
puts " Success rate: #{success_rate}%"
|
|
124
|
+
|
|
125
|
+
show_slowest_step(recent)
|
|
126
|
+
show_most_skipped(recent)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def show_slowest_step(reports)
|
|
130
|
+
durations, costs = collect_step_metrics(reports)
|
|
131
|
+
return if durations.empty?
|
|
132
|
+
|
|
133
|
+
name, dur_values = durations.max_by { |_, vals| vals.sum.to_f / vals.size }
|
|
134
|
+
avg_dur = (dur_values.sum.to_f / dur_values.size).round
|
|
135
|
+
avg_cost = costs[name].sum / costs[name].size
|
|
136
|
+
puts " Slowest step: #{name} (avg #{avg_dur}s, $#{format('%.2f', avg_cost)})"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def collect_step_metrics(reports)
|
|
140
|
+
durations = Hash.new { |h, k| h[k] = [] }
|
|
141
|
+
costs = Hash.new { |h, k| h[k] = [] }
|
|
142
|
+
|
|
143
|
+
reports.each do |r|
|
|
144
|
+
(r[:steps] || []).each do |s|
|
|
145
|
+
next unless s[:status] == 'completed'
|
|
146
|
+
|
|
147
|
+
durations[s[:role]] << s[:duration_s].to_i
|
|
148
|
+
costs[s[:role]] << s[:cost_usd].to_f
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
[durations, costs]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def show_most_skipped(reports)
|
|
156
|
+
skip_counts = Hash.new(0)
|
|
157
|
+
total_counts = Hash.new(0)
|
|
158
|
+
|
|
159
|
+
reports.each do |r|
|
|
160
|
+
(r[:steps] || []).each do |s|
|
|
161
|
+
total_counts[s[:role]] += 1
|
|
162
|
+
skip_counts[s[:role]] += 1 if s[:status] == 'skipped'
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
skipped_roles = skip_counts.select { |_, count| count.positive? }
|
|
167
|
+
return if skipped_roles.empty?
|
|
168
|
+
|
|
169
|
+
most = skipped_roles.max_by { |role, count| count.to_f / total_counts[role] }
|
|
170
|
+
name = most[0]
|
|
171
|
+
rate = (most[1].to_f / total_counts[name] * 100).round
|
|
172
|
+
puts " Most skipped: #{name} (#{rate}% skip rate)"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def step_count_str(report)
|
|
176
|
+
steps = report[:steps] || []
|
|
177
|
+
completed = steps.count { |s| s[:status] == 'completed' }
|
|
178
|
+
"#{completed}/#{steps.size} steps"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def format_report_date(iso_str)
|
|
182
|
+
return 'unknown' unless iso_str
|
|
183
|
+
|
|
184
|
+
Time.parse(iso_str).strftime('%Y-%m-%d %H:%M')
|
|
185
|
+
rescue ArgumentError
|
|
186
|
+
iso_str.to_s[0..15]
|
|
187
|
+
end
|
|
188
|
+
|
|
75
189
|
def fetch_issue_count(label, config)
|
|
76
190
|
stdout, _, status = Open3.capture3(
|
|
77
191
|
'gh', 'issue', 'list',
|