ocak 0.3.0 → 0.5.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -0
  3. data/lib/ocak/agent_generator.rb +4 -3
  4. data/lib/ocak/batch_processing.rb +102 -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 +73 -140
  9. data/lib/ocak/commands/init.rb +12 -6
  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 +7 -6
  16. data/lib/ocak/commands/status.rb +20 -0
  17. data/lib/ocak/config.rb +28 -5
  18. data/lib/ocak/failure_reporting.rb +17 -0
  19. data/lib/ocak/git_utils.rb +25 -9
  20. data/lib/ocak/instance_builders.rb +50 -0
  21. data/lib/ocak/issue_backend.rb +31 -0
  22. data/lib/ocak/issue_fetcher.rb +40 -47
  23. data/lib/ocak/local_issue_fetcher.rb +165 -0
  24. data/lib/ocak/local_merge_manager.rb +104 -0
  25. data/lib/ocak/logger.rb +5 -2
  26. data/lib/ocak/merge_manager.rb +36 -31
  27. data/lib/ocak/merge_orchestration.rb +8 -2
  28. data/lib/ocak/parallel_execution.rb +36 -0
  29. data/lib/ocak/pipeline_executor.rb +51 -169
  30. data/lib/ocak/pipeline_runner.rb +20 -167
  31. data/lib/ocak/pipeline_state.rb +11 -2
  32. data/lib/ocak/planner.rb +2 -3
  33. data/lib/ocak/process_runner.rb +4 -2
  34. data/lib/ocak/project_key.rb +38 -0
  35. data/lib/ocak/reready_processor.rb +15 -13
  36. data/lib/ocak/run_report.rb +7 -2
  37. data/lib/ocak/shutdown_handling.rb +67 -0
  38. data/lib/ocak/state_management.rb +104 -0
  39. data/lib/ocak/step_comments.rb +12 -7
  40. data/lib/ocak/step_execution.rb +66 -0
  41. data/lib/ocak/stream_parser.rb +7 -2
  42. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  43. data/lib/ocak/templates/agents/implementer.md.erb +32 -8
  44. data/lib/ocak/templates/agents/merger.md.erb +12 -5
  45. data/lib/ocak/templates/agents/pipeline.md.erb +4 -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/ocak.yml.erb +16 -0
  49. data/lib/ocak/verification.rb +43 -3
  50. data/lib/ocak/worktree_manager.rb +7 -3
  51. data/lib/ocak.rb +1 -1
  52. metadata +18 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3ade72c57a02f3647218543b060295454aec639d90ba261b4fe9b88fa05f1bf
4
- data.tar.gz: 86decb5ce7099d43214859b82f3b12fc7d55b5cdf3d1d57a30ed38dc7c74a957
3
+ metadata.gz: bb7c3ddd6ff9b6dacc60c4ee6f3fabae90089eace4e457a28e824be15a901cda
4
+ data.tar.gz: c9b5a76e36361dddb4731fff6298388f0246421272923951f83fdcebde9e5d4b
5
5
  SHA512:
6
- metadata.gz: 6ba522815876568e13b9453cdbd943a873c2ad28cee59a6c92d99b6dc62ccfeeadac57a2a2815b95f834b7c7173ffb2baf40107dcdfabc2cc74cf3d04446ad2d
7
- data.tar.gz: 9b4d7d602aa8c2c213d64bbf75a5d9c5dfecb01952d327140c268f32474aaa02e3664f6b29cd60c9eee45fa1ac97e6f5012b9bbac51d54b1de68d40c34291a64
6
+ metadata.gz: 9f37e6ad101953a378c73a1c76817e194386424174a87841d9f13321a4b465c5708ecb374f0526d2e2744bbcd19bd2473727ea288205dd731325ba80164fcec8
7
+ data.tar.gz: 18301a7948e7b979f7ee0daecd75369477ddbcd147d12d67305fdc55a7aeab44e371bd41859135f5c8105d5886ff49663f77cc61afaad9b9872a13581bac15f1
data/README.md CHANGED
@@ -173,6 +173,19 @@ When `--manual-review` is enabled, PRs sit open for human review. After leaving
173
173
  4. Remove the `auto-reready` label
174
174
  5. Comment "Feedback addressed. Please re-review."
175
175
 
176
+ ### Local Issues (Offline Mode)
177
+
178
+ Ocak can run without GitHub. Set `issues.backend: local` in `ocak.yml` (or just create `.ocak/issues/` and it auto-detects). Issues are stored as numbered markdown files with YAML frontmatter.
179
+
180
+ ```bash
181
+ ocak issue create "Add retry logic to API client" --label auto-ready
182
+ ocak issue list
183
+ ocak issue view 1
184
+ ocak run 1 --watch
185
+ ```
186
+
187
+ Issues live in `.ocak/issues/0001.md`, `.ocak/issues/0002.md`, etc. Labels, complexity, and pipeline comments are all tracked in the file. Merging goes straight to main (no PRs) via `LocalMergeManager`.
188
+
176
189
  ### Graceful Shutdown
177
190
 
178
191
  `Ctrl+C` once — current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
@@ -199,6 +212,10 @@ stack:
199
212
  - "bundle exec brakeman -q"
200
213
  - "bundle exec bundler-audit check"
201
214
 
215
+ # Issue backend (omit for GitHub, or set to "local" for offline mode)
216
+ issues:
217
+ backend: github # or "local" — auto-detected if .ocak/issues/ exists
218
+
202
219
  # Pipeline settings
203
220
  pipeline:
204
221
  max_parallel: 5
@@ -398,6 +415,16 @@ ocak clean Remove stale worktrees
398
415
  --all Clean worktrees and logs
399
416
  --keep N Only remove artifacts older than N days
400
417
 
418
+ ocak issue create TITLE [options] Create a local issue
419
+ --body TEXT Issue body (opens $EDITOR if omitted)
420
+ --label LABEL Add label (repeatable)
421
+ --complexity full|simple Set complexity (default: full)
422
+
423
+ ocak issue list [--label LABEL] List local issues
424
+ ocak issue view ISSUE View a local issue
425
+ ocak issue edit ISSUE Open issue in $EDITOR
426
+ ocak issue close ISSUE Mark issue as completed
427
+
401
428
  ocak design [DESCRIPTION] Launch interactive issue design session
402
429
  ocak audit [SCOPE] Print instructions for /audit skill
403
430
  ocak debt Print instructions for /debt skill
@@ -3,6 +3,7 @@
3
3
  require 'erb'
4
4
  require 'fileutils'
5
5
  require 'open3'
6
+ require_relative 'claude_runner'
6
7
 
7
8
  module Ocak
8
9
  class AgentGenerator
@@ -109,7 +110,7 @@ module Ocak
109
110
  agent_path = File.join(output_dir, "#{output_name}.md")
110
111
  current_content = File.read(agent_path)
111
112
 
112
- prompt = build_enhancement_prompt(agent, current_content, context)
113
+ prompt = build_enhancement_prompt(current_content, context)
113
114
  result = run_claude_prompt(prompt)
114
115
 
115
116
  if result && !result.strip.empty? && result.include?('---')
@@ -131,7 +132,7 @@ module Ocak
131
132
  parts.join("\n\n")
132
133
  end
133
134
 
134
- def build_enhancement_prompt(_agent, template_content, context)
135
+ def build_enhancement_prompt(template_content, context)
135
136
  <<~PROMPT
136
137
  You are customizing a Claude Code agent for a specific project.
137
138
 
@@ -161,7 +162,7 @@ module Ocak
161
162
  stdout, _, status = Open3.capture3(
162
163
  'claude', '-p',
163
164
  '--output-format', 'text',
164
- '--model', 'haiku',
165
+ '--model', ClaudeRunner::MODEL_HAIKU,
165
166
  '--allowedTools', 'Read,Glob,Grep',
166
167
  '--', prompt,
167
168
  chdir: @project_dir
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ocak
4
+ # Batch processing logic — process_issues, run_batch, process_one_issue, build_issue_result.
5
+ # Extracted from PipelineRunner to reduce file size.
6
+ module BatchProcessing
7
+ private
8
+
9
+ def process_issues(ready_issues, logger:, issues:)
10
+ if ready_issues.size > @config.max_issues_per_run
11
+ logger.warn("Capping to #{@config.max_issues_per_run} issues (found #{ready_issues.size})")
12
+ ready_issues = ready_issues.first(@config.max_issues_per_run)
13
+ end
14
+
15
+ claude = build_claude(logger)
16
+ batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
17
+
18
+ batches.each_with_index do |batch, idx|
19
+ batch_issues = batch['issues'][0...@config.max_parallel]
20
+ logger.info("Running batch #{idx + 1}/#{batches.size} (#{batch_issues.size} issues)")
21
+
22
+ if @options[:dry_run]
23
+ batch_issues.each { |i| logger.info("[DRY RUN] Would process issue ##{i['number']}: #{i['title']}") }
24
+ next
25
+ end
26
+
27
+ run_batch(batch_issues, logger: logger, issues: issues)
28
+ end
29
+ end
30
+
31
+ def run_batch(batch_issues, logger:, issues:)
32
+ worktrees = WorktreeManager.new(config: @config, logger: logger)
33
+
34
+ threads = batch_issues.map do |issue|
35
+ Thread.new { process_one_issue(issue, worktrees: worktrees, issues: issues) }
36
+ end
37
+ results = threads.map(&:value)
38
+
39
+ unless @shutting_down
40
+ merger = build_merge_manager(logger: logger, issues: issues)
41
+ results.select { |r| r[:success] }.each do |result|
42
+ merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
43
+ end
44
+ end
45
+
46
+ results.each do |result|
47
+ next unless result[:worktree]
48
+ next if result[:interrupted]
49
+
50
+ worktrees.remove(result[:worktree])
51
+ rescue StandardError => e
52
+ logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
53
+ end
54
+
55
+ programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
56
+ raise programming_error if programming_error
57
+ end
58
+
59
+ def process_one_issue(issue, worktrees:, issues:)
60
+ issue_number = issue['number']
61
+ logger = build_logger(issue_number: issue_number)
62
+ claude = build_claude(logger)
63
+ worktree = nil
64
+
65
+ @active_mutex.synchronize do
66
+ @active_issues << issue_number
67
+ end
68
+ issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
69
+ worktree = worktrees.create(issue_number, setup_command: @config.setup_command)
70
+ logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
71
+
72
+ complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
73
+ result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
74
+ complexity: complexity, skip_merge: true)
75
+
76
+ build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
77
+ logger: logger)
78
+ rescue StandardError => e
79
+ handle_process_error(e, issue_number: issue_number, logger: logger, issues: issues)
80
+ result = { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
81
+ # NameError includes NoMethodError
82
+ result[:programming_error] = e if e.is_a?(NameError) || e.is_a?(TypeError)
83
+ result
84
+ ensure
85
+ @active_mutex.synchronize { @active_issues.delete(issue_number) }
86
+ end
87
+
88
+ def build_issue_result(result, issue_number:, worktree:, issues:, logger: nil)
89
+ if result[:interrupted]
90
+ handle_interrupted_issue(issue_number, worktree&.path, result[:phase],
91
+ logger: logger || build_logger(issue_number: issue_number), issues: issues)
92
+ { issue_number: issue_number, success: false, worktree: worktree, interrupted: true }
93
+ elsif result[:success]
94
+ { issue_number: issue_number, success: true, worktree: worktree,
95
+ audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
96
+ else
97
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config, logger: logger)
98
+ { issue_number: issue_number, success: false, worktree: worktree }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -28,15 +28,19 @@ module Ocak
28
28
  'planner' => 'Read,Glob,Grep,Bash'
29
29
  }.freeze
30
30
 
31
+ MODEL_HAIKU = ENV.fetch('OCAK_MODEL_HAIKU', 'haiku')
32
+ MODEL_SONNET = ENV.fetch('OCAK_MODEL_SONNET', 'sonnet')
33
+ MODEL_OPUS = ENV.fetch('OCAK_MODEL_OPUS', 'opus')
34
+
31
35
  AGENT_MODELS = {
32
- 'planner' => 'haiku',
33
- 'reviewer' => 'sonnet',
34
- 'security-reviewer' => 'sonnet',
35
- 'auditor' => 'sonnet',
36
- 'documenter' => 'sonnet',
37
- 'merger' => 'sonnet',
38
- 'implementer' => nil,
39
- 'pipeline' => nil
36
+ 'planner' => MODEL_SONNET,
37
+ 'reviewer' => MODEL_OPUS,
38
+ 'security-reviewer' => MODEL_SONNET,
39
+ 'auditor' => MODEL_SONNET,
40
+ 'documenter' => MODEL_SONNET,
41
+ 'merger' => MODEL_SONNET,
42
+ 'implementer' => MODEL_SONNET,
43
+ 'pipeline' => MODEL_OPUS
40
44
  }.freeze
41
45
 
42
46
  TIMEOUT = 600 # 10 minutes per agent invocation
data/lib/ocak/cli.rb CHANGED
@@ -10,6 +10,11 @@ require_relative 'commands/status'
10
10
  require_relative 'commands/clean'
11
11
  require_relative 'commands/resume'
12
12
  require_relative 'commands/hiz'
13
+ require_relative 'commands/issue/create'
14
+ require_relative 'commands/issue/list'
15
+ require_relative 'commands/issue/view'
16
+ require_relative 'commands/issue/edit'
17
+ require_relative 'commands/issue/close'
13
18
 
14
19
  module Ocak
15
20
  module CLI
@@ -25,6 +30,14 @@ module Ocak
25
30
  register 'clean', Ocak::Commands::Clean
26
31
  register 'resume', Ocak::Commands::Resume
27
32
  register 'hiz', Ocak::Commands::Hiz
33
+
34
+ register 'issue' do |prefix|
35
+ prefix.register 'create', Ocak::Commands::Issue::Create
36
+ prefix.register 'list', Ocak::Commands::Issue::List
37
+ prefix.register 'view', Ocak::Commands::Issue::View
38
+ prefix.register 'edit', Ocak::Commands::Issue::Edit
39
+ prefix.register 'close', Ocak::Commands::Issue::Close
40
+ end
28
41
  end
29
42
  end
30
43
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Ocak
6
+ module CommandRunner
7
+ CommandResult = Struct.new(:stdout, :stderr, :status) do
8
+ def success?
9
+ status&.success? == true
10
+ end
11
+
12
+ def output
13
+ stdout.strip
14
+ end
15
+
16
+ def error
17
+ stderr[0...500]
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def run_git(*, chdir: nil)
24
+ run_command('git', *, chdir: chdir)
25
+ end
26
+
27
+ def run_gh(*, chdir: nil)
28
+ run_command('gh', *, chdir: chdir)
29
+ end
30
+
31
+ def run_command(*, chdir: nil)
32
+ opts = chdir ? { chdir: chdir } : {}
33
+ stdout, stderr, status = Open3.capture3(*, **opts)
34
+ CommandResult.new(stdout, stderr, status)
35
+ rescue Errno::ENOENT => e
36
+ CommandResult.new('', e.message, nil)
37
+ end
38
+ end
39
+ end
@@ -5,17 +5,14 @@ require 'securerandom'
5
5
  require_relative '../config'
6
6
  require_relative '../claude_runner'
7
7
  require_relative '../git_utils'
8
- require_relative '../issue_fetcher'
9
- require_relative '../verification'
10
- require_relative '../planner'
8
+ require_relative '../issue_backend'
9
+ require_relative '../pipeline_executor'
11
10
  require_relative '../step_comments'
12
11
  require_relative '../logger'
13
12
 
14
13
  module Ocak
15
14
  module Commands
16
15
  class Hiz < Dry::CLI::Command
17
- include Verification
18
- include Planner
19
16
  include StepComments
20
17
 
21
18
  desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
@@ -26,17 +23,10 @@ module Ocak
26
23
  option :verbose, type: :boolean, default: false, desc: 'Increase log detail'
27
24
  option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
28
25
 
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' }
26
+ HIZ_STEPS = [
27
+ { agent: 'implementer', role: 'implement', model: ClaudeRunner::MODEL_SONNET },
28
+ { agent: 'reviewer', role: 'review', model: ClaudeRunner::MODEL_HAIKU, parallel: true },
29
+ { agent: 'security-reviewer', role: 'security', model: ClaudeRunner::MODEL_SONNET, parallel: true }
40
30
  ].freeze
41
31
 
42
32
  HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
@@ -50,10 +40,10 @@ module Ocak
50
40
  return
51
41
  end
52
42
 
53
- logger = build_logger(issue_number)
43
+ @logger = logger = build_logger(issue_number)
54
44
  watch_formatter = options[:watch] ? WatchFormatter.new : nil
55
45
  claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
56
- issues = IssueFetcher.new(config: @config, logger: logger)
46
+ issues = IssueBackend.build(config: @config, logger: logger)
57
47
 
58
48
  logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
59
49
 
@@ -78,130 +68,70 @@ module Ocak
78
68
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
69
  chdir = @config.project_dir
80
70
 
71
+ issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
81
72
  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)
73
+ begin
74
+ branch = create_branch(issue_number, chdir)
75
+ rescue RuntimeError => e
76
+ fail_pipeline(issue_number, 'create-branch', e.message,
77
+ start_time: start_time, state: state, logger: logger)
90
78
  return
91
79
  end
92
80
 
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)
81
+ executor = PipelineExecutor.new(config: @config, issues: issues)
82
+ result = executor.run_pipeline(
83
+ issue_number, logger: logger, claude: claude, chdir: chdir,
84
+ steps: HIZ_STEPS, verification_model: ClaudeRunner::MODEL_SONNET,
85
+ post_start_comment: false, post_summary_comment: false
86
+ )
87
+
88
+ state.total_cost = result[:total_cost] || 0.0
89
+ state.steps_run = result[:steps_run] || 0
90
+ state.steps_run += 1 if verification_ran?(result)
91
+
92
+ unless result[:success]
93
+ fail_pipeline(issue_number, result[:phase], result[:output],
94
+ start_time: start_time, state: state, logger: logger, branch: branch)
101
95
  return
102
96
  end
103
97
 
98
+ state.review_results = extract_review_results(result[:step_results])
99
+
104
100
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
105
101
  post_hiz_summary_comment(issue_number, duration, success: true, state: state)
106
102
  push_and_create_pr(issue_number, branch, logger: logger, chdir: chdir, state: state)
107
103
  end
108
104
 
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
105
+ def verification_ran?(result)
106
+ (@config.test_command || @config.lint_check_command) &&
107
+ (result[:success] || result[:phase] == 'final-verify')
108
+ end
117
109
 
118
- state.review_results = run_reviews_in_parallel(issue_number, claude: claude, logger: logger,
119
- chdir: chdir, state: state)
120
- nil
110
+ def extract_review_results(step_results)
111
+ return {} unless step_results
112
+
113
+ step_results.select do |_role, r|
114
+ r&.blocking_findings? || r&.warnings?
115
+ end
121
116
  end
122
117
 
123
118
  def create_branch(issue_number, chdir)
124
119
  branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
120
+ raise "Unsafe branch name: #{branch}" unless GitUtils.safe_branch_name?(branch)
121
+
125
122
  _, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
126
123
  raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
127
124
 
128
125
  branch
129
126
  end
130
127
 
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
128
  def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
199
129
  commit_changes(issue_number, chdir, logger: logger)
200
130
 
201
131
  _, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
202
132
  unless status.success?
203
133
  logger.error("Push failed: #{stderr}")
204
- handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger)
134
+ handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger, branch: branch)
205
135
  return
206
136
  end
207
137
 
@@ -224,7 +154,7 @@ module Ocak
224
154
  puts "PR created: #{pr_url}"
225
155
  else
226
156
  logger.error("PR creation failed: #{stderr}")
227
- handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger)
157
+ handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger, branch: branch)
228
158
  end
229
159
  end
230
160
 
@@ -247,41 +177,44 @@ module Ocak
247
177
  body
248
178
  end
249
179
 
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?
180
+ def fail_pipeline(issue_number, phase, output, start_time:, state:, logger:, branch: nil) # rubocop:disable Metrics/ParameterLists
181
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time).round
182
+ post_hiz_summary_comment(issue_number, duration, success: false, failed_phase: phase, state: state)
183
+ handle_failure(issue_number, phase, output, issues: state.issues, logger: logger, branch: branch)
257
184
  end
258
185
 
259
- def build_logger(issue_number)
260
- PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
186
+ def handle_failure(issue_number, phase, output, issues:, logger:, branch: nil)
187
+ logger.error("Issue ##{issue_number} failed at phase: #{phase}")
188
+ issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
189
+ begin
190
+ sanitized = output.to_s[0..1000].gsub('```', "'''")
191
+ issues.comment(issue_number,
192
+ "Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{sanitized}\n```")
193
+ rescue StandardError => e
194
+ logger&.debug("Failure comment failed: #{e.message}")
195
+ nil
196
+ end
197
+ warn "Issue ##{issue_number} failed at phase: #{phase}"
198
+ GitUtils.checkout_main(chdir: @config.project_dir, logger: logger)
199
+ delete_branch(branch, logger: logger) if branch
261
200
  end
262
201
 
263
- def post_step_comment(issue_number, body, state:)
264
- state.issues&.comment(issue_number, body)
265
- rescue StandardError
266
- nil
202
+ def delete_branch(branch, logger:)
203
+ _, stderr, status = Open3.capture3('git', 'branch', '-D', branch, chdir: @config.project_dir)
204
+ logger.warn("Failed to delete branch #{branch}: #{stderr}") unless status.success?
205
+ rescue StandardError => e
206
+ logger.warn("Error deleting branch #{branch}: #{e.message}")
267
207
  end
268
208
 
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
209
+ def build_logger(issue_number)
210
+ PipelineLogger.new(log_dir: File.join(@config.project_dir, @config.log_dir), issue_number: issue_number)
279
211
  end
280
212
 
281
213
  def post_hiz_start_comment(issue_number, state:)
282
214
  steps = "implement \u2192 review \u2225 security"
283
215
  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)
216
+ post_step_comment(issue_number, "\u{1F680} **Hiz (fast mode) started** \u2014 #{steps}",
217
+ issues: state.issues)
285
218
  end
286
219
 
287
220
  def post_hiz_summary_comment(issue_number, duration, success:, state:, failed_phase: nil)
@@ -292,12 +225,12 @@ module Ocak
292
225
  post_step_comment(issue_number,
293
226
  "\u{2705} **Pipeline complete** \u2014 #{state.steps_run}/#{total} steps run " \
294
227
  "| 0 skipped | $#{cost} total | #{duration}s",
295
- state: state)
228
+ issues: state.issues)
296
229
  else
297
230
  post_step_comment(issue_number,
298
231
  "\u{274C} **Pipeline failed** at phase: #{failed_phase} \u2014 " \
299
232
  "#{state.steps_run}/#{total} steps completed | $#{cost} total",
300
- state: state)
233
+ issues: state.issues)
301
234
  end
302
235
  end
303
236
  end
@@ -85,7 +85,12 @@ module Ocak
85
85
 
86
86
  def update_settings(project_dir, stack)
87
87
  settings_path = File.join(project_dir, '.claude', 'settings.json')
88
- existing = File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
88
+ existing = begin
89
+ File.exist?(settings_path) ? JSON.parse(File.read(settings_path)) : {}
90
+ rescue JSON::ParserError
91
+ puts ' Warning: .claude/settings.json is not valid JSON, creating fresh'
92
+ {}
93
+ end
89
94
 
90
95
  merge_permissions(existing, stack)
91
96
  merge_hooks(existing)
@@ -178,6 +183,11 @@ module Ocak
178
183
 
179
184
  def create_labels(project_dir)
180
185
  config = Config.load(project_dir)
186
+ if config.local_issues?
187
+ puts ' Skipped label creation (local issue backend)'
188
+ return
189
+ end
190
+
181
191
  fetcher = IssueFetcher.new(config: config)
182
192
  fetcher.ensure_labels(config.all_labels)
183
193
  puts ' Created GitHub labels'
@@ -211,11 +221,7 @@ module Ocak
211
221
  end
212
222
 
213
223
  def init_logger
214
- @init_logger ||= Object.new.tap do |l|
215
- def l.info(msg)
216
- puts " #{msg}"
217
- end
218
- end
224
+ @init_logger ||= Struct.new(:_) { def info(msg) = puts(" #{msg}") }.new
219
225
  end
220
226
  end
221
227
  end