ocak 0.1.0 → 0.2.0

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