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.
- checksums.yaml +4 -4
- data/README.md +27 -0
- data/lib/ocak/agent_generator.rb +4 -3
- data/lib/ocak/batch_processing.rb +102 -0
- data/lib/ocak/claude_runner.rb +12 -8
- data/lib/ocak/cli.rb +13 -0
- data/lib/ocak/command_runner.rb +39 -0
- data/lib/ocak/commands/hiz.rb +73 -140
- data/lib/ocak/commands/init.rb +12 -6
- data/lib/ocak/commands/issue/close.rb +37 -0
- data/lib/ocak/commands/issue/create.rb +59 -0
- data/lib/ocak/commands/issue/edit.rb +31 -0
- data/lib/ocak/commands/issue/list.rb +43 -0
- data/lib/ocak/commands/issue/view.rb +58 -0
- data/lib/ocak/commands/resume.rb +7 -6
- data/lib/ocak/commands/status.rb +20 -0
- data/lib/ocak/config.rb +28 -5
- data/lib/ocak/failure_reporting.rb +17 -0
- data/lib/ocak/git_utils.rb +25 -9
- data/lib/ocak/instance_builders.rb +50 -0
- data/lib/ocak/issue_backend.rb +31 -0
- data/lib/ocak/issue_fetcher.rb +40 -47
- data/lib/ocak/local_issue_fetcher.rb +165 -0
- data/lib/ocak/local_merge_manager.rb +104 -0
- data/lib/ocak/logger.rb +5 -2
- data/lib/ocak/merge_manager.rb +36 -31
- data/lib/ocak/merge_orchestration.rb +8 -2
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +51 -169
- data/lib/ocak/pipeline_runner.rb +20 -167
- data/lib/ocak/pipeline_state.rb +11 -2
- data/lib/ocak/planner.rb +2 -3
- data/lib/ocak/process_runner.rb +4 -2
- data/lib/ocak/project_key.rb +38 -0
- data/lib/ocak/reready_processor.rb +15 -13
- data/lib/ocak/run_report.rb +7 -2
- data/lib/ocak/shutdown_handling.rb +67 -0
- data/lib/ocak/state_management.rb +104 -0
- data/lib/ocak/step_comments.rb +12 -7
- data/lib/ocak/step_execution.rb +66 -0
- data/lib/ocak/stream_parser.rb +7 -2
- data/lib/ocak/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +32 -8
- data/lib/ocak/templates/agents/merger.md.erb +12 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +4 -0
- data/lib/ocak/templates/agents/reviewer.md.erb +2 -2
- data/lib/ocak/templates/agents/security_reviewer.md.erb +11 -0
- data/lib/ocak/templates/ocak.yml.erb +16 -0
- data/lib/ocak/verification.rb +43 -3
- data/lib/ocak/worktree_manager.rb +7 -3
- data/lib/ocak.rb +1 -1
- metadata +18 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb7c3ddd6ff9b6dacc60c4ee6f3fabae90089eace4e457a28e824be15a901cda
|
|
4
|
+
data.tar.gz: c9b5a76e36361dddb4731fff6298388f0246421272923951f83fdcebde9e5d4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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',
|
|
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
|
data/lib/ocak/claude_runner.rb
CHANGED
|
@@ -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' =>
|
|
33
|
-
'reviewer' =>
|
|
34
|
-
'security-reviewer' =>
|
|
35
|
-
'auditor' =>
|
|
36
|
-
'documenter' =>
|
|
37
|
-
'merger' =>
|
|
38
|
-
'implementer' =>
|
|
39
|
-
'pipeline' =>
|
|
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
|
data/lib/ocak/commands/hiz.rb
CHANGED
|
@@ -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 '../
|
|
9
|
-
require_relative '../
|
|
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
|
-
|
|
30
|
-
'implementer'
|
|
31
|
-
'reviewer'
|
|
32
|
-
'security-reviewer'
|
|
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 =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
260
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
270
|
-
|
|
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}",
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
issues: state.issues)
|
|
301
234
|
end
|
|
302
235
|
end
|
|
303
236
|
end
|
data/lib/ocak/commands/init.rb
CHANGED
|
@@ -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 =
|
|
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 ||=
|
|
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
|