ocak 0.4.0 → 0.6.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -21
  3. data/lib/ocak/agent_generator.rb +11 -1
  4. data/lib/ocak/batch_processing.rb +132 -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 +28 -28
  9. data/lib/ocak/commands/init.rb +37 -0
  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 +11 -9
  16. data/lib/ocak/commands/status.rb +29 -12
  17. data/lib/ocak/config.rb +72 -1
  18. data/lib/ocak/conflict_resolution.rb +73 -0
  19. data/lib/ocak/failure_reporting.rb +6 -3
  20. data/lib/ocak/git_utils.rb +18 -11
  21. data/lib/ocak/instance_builders.rb +54 -0
  22. data/lib/ocak/issue_backend.rb +31 -0
  23. data/lib/ocak/issue_fetcher.rb +9 -0
  24. data/lib/ocak/issue_state_machine.rb +36 -0
  25. data/lib/ocak/local_issue_fetcher.rb +165 -0
  26. data/lib/ocak/local_merge_manager.rb +104 -0
  27. data/lib/ocak/merge_manager.rb +30 -103
  28. data/lib/ocak/merge_orchestration.rb +36 -24
  29. data/lib/ocak/merge_verification.rb +40 -0
  30. data/lib/ocak/parallel_execution.rb +36 -0
  31. data/lib/ocak/pipeline_executor.rb +17 -185
  32. data/lib/ocak/pipeline_runner.rb +32 -180
  33. data/lib/ocak/planner.rb +16 -1
  34. data/lib/ocak/project_key.rb +38 -0
  35. data/lib/ocak/reready_processor.rb +11 -11
  36. data/lib/ocak/run_report.rb +5 -2
  37. data/lib/ocak/shutdown_handling.rb +67 -0
  38. data/lib/ocak/state_management.rb +104 -0
  39. data/lib/ocak/step_execution.rb +66 -0
  40. data/lib/ocak/stream_parser.rb +1 -1
  41. data/lib/ocak/target_resolver.rb +41 -0
  42. data/lib/ocak/templates/agents/auditor.md.erb +38 -9
  43. data/lib/ocak/templates/agents/implementer.md.erb +35 -8
  44. data/lib/ocak/templates/agents/merger.md.erb +24 -5
  45. data/lib/ocak/templates/agents/pipeline.md.erb +22 -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/gitignore_additions.txt +1 -0
  49. data/lib/ocak/templates/ocak.yml.erb +24 -0
  50. data/lib/ocak/verification.rb +6 -1
  51. data/lib/ocak/worktree_manager.rb +9 -6
  52. data/lib/ocak.rb +1 -1
  53. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f09506ff0c348c7f0822da37268c614ea209b9ca8edb643b7a4619df96113a3
4
- data.tar.gz: 90a52276424ab7f2dbaa19643ca22ffde525460089d7f2b3f97670c638b43f2b
3
+ metadata.gz: 21e35193d8d3fc3b9c24f7e75502749ce4f9a57352da20d9bb0c980c2191ad9a
4
+ data.tar.gz: 2204cd3797a57348f3a3742fa17385375050d8b66fe37d4bdb466a4f603f31dd
5
5
  SHA512:
6
- metadata.gz: 04fd822b0fc96ae9f845d97c431a40552c081d56475925069c2158685d126fb816ef87ddd9946b041c2bf624e9e6676a92c2ab30da2b8d10128eebb7a133a57b
7
- data.tar.gz: 111a2c5c925cbad3919f65a92ed3d0d16c88acfd934d0e8d1baf97b21d966a53822129255f28f1941b692bd03eb455eff8ca186555d9e78b2cce189085f53853
6
+ metadata.gz: 7fddde997f1ba1c8f5e5e9db9e3f6dc9281ac1db82743b651f72f658967fcefeca67b05c5e338dbbaad9be798adae10f24807d1fec3ddd9975e72daba853f709
7
+ data.tar.gz: 0d5f6f2f9e7300eb8b242e654296dd156192e4993976a40d0ebb050a1bf417e9b36e85f1b6b4f4e8e99cc1e375275be5175f6ef9489056c2dcc1d3025cffaa9e
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ocak
2
2
 
3
- *Ocak (pronounced "oh-JAHK") is Turkish for "forge" or "hearth" the place where raw material meets fire and becomes something useful. Also: let 'em cook.*
3
+ *Ocak (pronounced "oh-JAHK") is Turkish for "forge" or "hearth" - the place where raw material meets fire and becomes something useful. Also: let 'em cook.*
4
4
 
5
5
  Multi-agent pipeline that processes GitHub issues autonomously with Claude Code. You write an issue, label it, and ocak runs it through implement → review → fix → security review → document → audit → merge. Each issue gets its own worktree so they can run in parrallel.
6
6
 
@@ -83,8 +83,8 @@ stateDiagram-v2
83
83
  ### Complexity Classification
84
84
 
85
85
  The planner classifes each issue as `simple` or `full`:
86
- - **Simple** skips security review, second fix pass, documenter, and auditor
87
- - **Full** runs the whole thing
86
+ - **Simple** - skips security review, second fix pass, documenter, and auditor
87
+ - **Full** - runs the whole thing
88
88
 
89
89
  `--fast` forces all issues to `simple`, giving you: implement → review → fix (if needed) → verify (if fixed) → merge.
90
90
 
@@ -102,7 +102,7 @@ flowchart LR
102
102
  E --> F["Merger agent<br>create PR + merge"]
103
103
  ```
104
104
 
105
- Merging is sequential one at a time so you dont get conflicts between parallel worktrees.
105
+ Merging is sequential - one at a time - so you dont get conflicts between parallel worktrees.
106
106
 
107
107
  ## Agents
108
108
 
@@ -119,7 +119,7 @@ Merging is sequential — one at a time — so you dont get conflicts between pa
119
119
  | **planner** | Batch issues, classify complexity | Read, Glob, Grep, Bash (read-only) | haiku |
120
120
  | **pipeline** | Self-contained single-agent mode | All tools | opus |
121
121
 
122
- Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access they can only read and report stuff back.
122
+ Review agents (reviewer, security-reviewer, auditor) have no Write/Edit access - they can only read and report stuff back.
123
123
 
124
124
  ## Skills
125
125
 
@@ -128,13 +128,13 @@ Interactive skills for use inside Claude Code:
128
128
  | Skill | Description |
129
129
  |-------|-------------|
130
130
  | `/design [description]` | Researches your codebase, asks clarifying questions, produces a detailed implementation-ready issue |
131
- | `/audit [scope]` | Codebase sweep scopes: `security`, `errors`, `patterns`, `tests`, `data`, `dependencies`, or `all` |
131
+ | `/audit [scope]` | Codebase sweep - scopes: `security`, `errors`, `patterns`, `tests`, `data`, `dependencies`, or `all` |
132
132
  | `/scan-file <path>` | Deep single-file analysis with test coverage check, scored 1-10 per method |
133
133
  | `/debt` | Tech debt tracker with risk scoring (churn, coverage, suppressions, age, blast radius) |
134
134
 
135
135
  ## Modes
136
136
 
137
- ### Full Pipeline `ocak run`
137
+ ### Full Pipeline - `ocak run`
138
138
 
139
139
  The default. Polls for `auto-ready` issues, plans batches, runs the full step sequence in parallel worktrees, merges sequentally.
140
140
 
@@ -146,7 +146,7 @@ ocak run --audit --watch # Auditor as merge gate
146
146
  ocak run --fast --watch # Skip security/docs/audit steps
147
147
  ```
148
148
 
149
- ### Fast Mode `ocak hiz`
149
+ ### Fast Mode - `ocak hiz`
150
150
 
151
151
  Lightweight alternative for quick PRs you'll review youself:
152
152
 
@@ -173,11 +173,54 @@ 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
+
189
+ ### Multi-Repo Mode
190
+
191
+ Run issues across multiple repos from a single issue tracker. One "god repo" holds all the issues, agents run in worktrees of whatever repo the issue targets.
192
+
193
+ Each issue specifies its target with YAML front-matter at the top of the body:
194
+
195
+ ```
196
+ ---
197
+ target_repo: my-service
198
+ ---
199
+
200
+ Actual issue description here...
201
+ ```
202
+
203
+ Enable it in `ocak.yml`:
204
+
205
+ ```yaml
206
+ multi_repo: true
207
+ ```
208
+
209
+ Map repo names to local paths in `~/.config/ocak/config.yml`:
210
+
211
+ ```yaml
212
+ repos:
213
+ my-service: ~/dev/my-service
214
+ other-thing: ~/dev/other-thing
215
+ ```
216
+
217
+ Labels and comments stay on the god repo's issues. PRs get created in the target repo. Worktree isolation, parallel batches, sequential merging all work the same.
218
+
176
219
  ### Graceful Shutdown
177
220
 
178
- `Ctrl+C` once current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
221
+ `Ctrl+C` once - current agent step finishes, then the pipeline stops. WIP gets committed, labels reset to `auto-ready`, and resume commands are printed.
179
222
 
180
- `Ctrl+C` twice kills active subprocesses immediatley (SIGTERM → wait → SIGKILL), then same cleanup runs.
223
+ `Ctrl+C` twice - kills active subprocesses immediatley (SIGTERM → wait → SIGKILL), then same cleanup runs.
181
224
 
182
225
  ```bash
183
226
  ocak resume 42 --watch # Pick up from where it stopped
@@ -199,6 +242,10 @@ stack:
199
242
  - "bundle exec brakeman -q"
200
243
  - "bundle exec bundler-audit check"
201
244
 
245
+ # Issue backend (omit for GitHub, or set to "local" for offline mode)
246
+ issues:
247
+ backend: github # or "local" - auto-detected if .ocak/issues/ exists
248
+
202
249
  # Pipeline settings
203
250
  pipeline:
204
251
  max_parallel: 5
@@ -224,7 +271,7 @@ labels:
224
271
  reready: "auto-reready"
225
272
  awaiting_review: "auto-pending-human"
226
273
 
227
- # Pipeline steps add, remove, reorder as you like
274
+ # Pipeline steps - add, remove, reorder as you like
228
275
  steps:
229
276
  - agent: implementer
230
277
  role: implement
@@ -259,6 +306,25 @@ agents:
259
306
  # ...
260
307
  ```
261
308
 
309
+ ### User Config
310
+
311
+ `ocak init` scaffolds `~/.config/ocak/config.yml` (or `$XDG_CONFIG_HOME/ocak/config.yml`) for machine-specific settings. When both files exist, project `ocak.yml` wins on conflicts.
312
+
313
+ ```yaml
314
+ # ~/.config/ocak/config.yml
315
+
316
+ # Repo path mappings for multi-repo mode
317
+ repos:
318
+ my-service: ~/dev/my-service
319
+
320
+ # Machine-level overrides
321
+ pipeline:
322
+ max_parallel: 3
323
+ cost_budget: 10.00
324
+ ```
325
+
326
+ The `repos:` key only comes from user config so repo paths dont leak into project config.
327
+
262
328
  ## Customization
263
329
 
264
330
  ### Swap Agents
@@ -334,8 +400,8 @@ steps:
334
400
  | Python | Django, Flask, FastAPI | pytest | ruff, flake8 | bandit, safety |
335
401
  | Rust | Actix, Axum, Rocket | cargo test | cargo clippy | cargo audit |
336
402
  | Go | Gin, Echo, Fiber, Chi | go test | golangci-lint | gosec |
337
- | Java | | gradle test | | |
338
- | Elixir | Phoenix | mix test | mix credo | |
403
+ | Java | - | gradle test | - | - |
404
+ | Elixir | Phoenix | mix test | mix credo | - |
339
405
 
340
406
  Monorepo detection: npm/pnpm workspaces, Cargo workspaces, Go workspaces, Lerna, and convention-based (`packages/`, `apps/`, `services/`).
341
407
 
@@ -351,15 +417,15 @@ Shows per-run stats (cost, duration, steps completed, failures) and aggregates a
351
417
 
352
418
  ## Writing Good Issues
353
419
 
354
- The `/design` skill produces issues formatted for zero-context agents. Think of it as writing a ticket for a contractor who's never seen your codebase everthing they need should be in the issue body. The key sections:
420
+ The `/design` skill produces issues formatted for zero-context agents. Think of it as writing a ticket for a contractor who's never seen your codebase - everthing they need should be in the issue body. The key sections:
355
421
 
356
- - **Context** what part of the system, with specific file paths
357
- - **Acceptance Criteria** "when X, then Y" format, each independantly testable
358
- - **Implementation Guide** exact files to create/modify
359
- - **Patterns to Follow** references to actual files in the codebase
360
- - **Security Considerations** auth, validation, data exposure
361
- - **Test Requirements** specific test cases with file paths
362
- - **Out of Scope** explicit boundaries so it doesnt scope creep
422
+ - **Context** - what part of the system, with specific file paths
423
+ - **Acceptance Criteria** - "when X, then Y" format, each independantly testable
424
+ - **Implementation Guide** - exact files to create/modify
425
+ - **Patterns to Follow** - references to actual files in the codebase
426
+ - **Security Considerations** - auth, validation, data exposure
427
+ - **Test Requirements** - specific test cases with file paths
428
+ - **Out of Scope** - explicit boundaries so it doesnt scope creep
363
429
 
364
430
  ## CLI Reference
365
431
 
@@ -398,6 +464,16 @@ ocak clean Remove stale worktrees
398
464
  --all Clean worktrees and logs
399
465
  --keep N Only remove artifacts older than N days
400
466
 
467
+ ocak issue create TITLE [options] Create a local issue
468
+ --body TEXT Issue body (opens $EDITOR if omitted)
469
+ --label LABEL Add label (repeatable)
470
+ --complexity full|simple Set complexity (default: full)
471
+
472
+ ocak issue list [--label LABEL] List local issues
473
+ ocak issue view ISSUE View a local issue
474
+ ocak issue edit ISSUE Open issue in $EDITOR
475
+ ocak issue close ISSUE Mark issue as completed
476
+
401
477
  ocak design [DESCRIPTION] Launch interactive issue design session
402
478
  ocak audit [SCOPE] Print instructions for /audit skill
403
479
  ocak debt Print instructions for /debt skill
@@ -413,6 +489,10 @@ Depends on the issue. Simple stuff is $2-5, complex issues can be $10-15. The im
413
489
 
414
490
  Reasonably. Review agents are read-only (no Write/Edit tools), merging is sequential so you dont get conflicts, and failed piplines get labeled and logged. You can always `--dry-run` first to see what it would do.
415
491
 
492
+ **Can I trust `ocak.yml` from someone else's repo?**
493
+
494
+ Treat it like a Makefile. Commands in `test_command`, `lint_command`, `security_commands`, etc. run with your user privileges. First time you run ocak in a new repo it'll print a warning. Review the commands before running `ocak run` on repos you didnt write.
495
+
416
496
  **What if it breaks?**
417
497
 
418
498
  Issues get labeled `pipeline-failed` with a comment explaining what went wrong. Worktrees get cleaned up automaticaly. Run `ocak clean` to remove any stragglers, and check `logs/pipeline/` for detailed logs.
@@ -3,9 +3,13 @@
3
3
  require 'erb'
4
4
  require 'fileutils'
5
5
  require 'open3'
6
+ require_relative 'claude_runner'
7
+ require_relative 'command_runner'
6
8
 
7
9
  module Ocak
8
10
  class AgentGenerator
11
+ include CommandRunner
12
+
9
13
  AGENT_TEMPLATES = %w[
10
14
  implementer reviewer security_reviewer documenter
11
15
  merger pipeline planner auditor
@@ -136,10 +140,14 @@ module Ocak
136
140
  You are customizing a Claude Code agent for a specific project.
137
141
 
138
142
  Here is the project context:
143
+ <project_context>
139
144
  #{context}
145
+ </project_context>
140
146
 
141
147
  Here is the base agent template:
148
+ <base_template>
142
149
  #{template_content}
150
+ </base_template>
143
151
 
144
152
  Customize this agent to reference this project's actual conventions, file paths,
145
153
  and patterns. Keep the same structure (YAML frontmatter + markdown). Keep the same
@@ -150,6 +158,7 @@ module Ocak
150
158
  end
151
159
 
152
160
  def claude_available?
161
+ # Uses Open3 directly; 'which' is not a git/gh command
153
162
  _, _, status = Open3.capture3('which', 'claude')
154
163
  status.success?
155
164
  rescue Errno::ENOENT => e
@@ -158,10 +167,11 @@ module Ocak
158
167
  end
159
168
 
160
169
  def run_claude_prompt(prompt)
170
+ # Uses Open3 directly; 'claude' is not a git/gh command
161
171
  stdout, _, status = Open3.capture3(
162
172
  'claude', '-p',
163
173
  '--output-format', 'text',
164
- '--model', 'haiku',
174
+ '--model', ClaudeRunner::MODEL_HAIKU,
165
175
  '--allowedTools', 'Read,Glob,Grep',
166
176
  '--', prompt,
167
177
  chdir: @project_dir
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'target_resolver'
4
+
5
+ module Ocak
6
+ # Batch processing logic — process_issues, run_batch, process_one_issue, build_issue_result.
7
+ # Extracted from PipelineRunner to reduce file size.
8
+ module BatchProcessing
9
+ private
10
+
11
+ def process_issues(ready_issues, logger:, issues:)
12
+ if ready_issues.size > @config.max_issues_per_run
13
+ logger.warn("Capping to #{@config.max_issues_per_run} issues (found #{ready_issues.size})")
14
+ ready_issues = ready_issues.first(@config.max_issues_per_run)
15
+ end
16
+
17
+ ready_issues = resolve_targets(ready_issues, logger: logger) if @config.multi_repo?
18
+
19
+ claude = build_claude(logger)
20
+ batches = @executor.plan_batches(ready_issues, logger: logger, claude: claude)
21
+
22
+ batches.each_with_index do |batch, idx|
23
+ batch_issues = batch['issues'][0...@config.max_parallel]
24
+ logger.info("Running batch #{idx + 1}/#{batches.size} (#{batch_issues.size} issues)")
25
+
26
+ if @options[:dry_run]
27
+ batch_issues.each { |i| logger.info("[DRY RUN] Would process issue ##{i['number']}: #{i['title']}") }
28
+ next
29
+ end
30
+
31
+ run_batch(batch_issues, logger: logger, issues: issues)
32
+ end
33
+ end
34
+
35
+ def run_batch(batch_issues, logger:, issues:)
36
+ shared_worktrees = @config.multi_repo? ? nil : WorktreeManager.new(config: @config, logger: logger)
37
+
38
+ threads = batch_issues.map do |issue|
39
+ Thread.new { process_one_issue(issue, worktrees: shared_worktrees, issues: issues) }
40
+ end
41
+ results = threads.map(&:value)
42
+
43
+ unless @shutting_down
44
+ merger = build_merge_manager(logger: logger, issues: issues)
45
+ results.select { |r| r[:success] }.each do |result|
46
+ merge_completed_issue(result, merger: merger, issues: issues, logger: logger)
47
+ end
48
+ end
49
+
50
+ results.each do |result|
51
+ next unless result[:worktree]
52
+ next if result[:interrupted]
53
+
54
+ worktree_manager_for(result[:worktree], shared: shared_worktrees, logger: logger)
55
+ .remove(result[:worktree])
56
+ rescue StandardError => e
57
+ logger.warn("Failed to clean worktree for ##{result[:issue_number]}: #{e.message}")
58
+ end
59
+
60
+ programming_error = results.find { |r| r[:programming_error] }&.dig(:programming_error)
61
+ raise programming_error if programming_error
62
+ end
63
+
64
+ def process_one_issue(issue, worktrees:, issues:)
65
+ issue_number = issue['number']
66
+ logger = build_logger(issue_number: issue_number)
67
+ claude = build_claude(logger)
68
+ worktree = nil
69
+
70
+ @active_mutex.synchronize do
71
+ @active_issues << issue_number
72
+ end
73
+ @state_machine.mark_in_progress(issue_number)
74
+
75
+ target = issue['_target']
76
+ worktree_mgr = if target
77
+ WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger)
78
+ else
79
+ worktrees
80
+ end
81
+ worktree = worktree_mgr.create(issue_number, setup_command: @config.setup_command)
82
+ logger.info("Created worktree at #{worktree.path} (branch: #{worktree.branch})")
83
+
84
+ complexity = @options[:fast] ? 'simple' : issue.fetch('complexity', 'full')
85
+ result = run_pipeline(issue_number, logger: logger, claude: claude, chdir: worktree.path,
86
+ complexity: complexity, skip_merge: true)
87
+
88
+ build_issue_result(result, issue_number: issue_number, worktree: worktree, issues: issues,
89
+ logger: logger)
90
+ rescue StandardError => e
91
+ handle_process_error(e, issue_number: issue_number, logger: logger, issues: issues)
92
+ result = { issue_number: issue_number, success: false, worktree: worktree, error: e.message }
93
+ # NameError includes NoMethodError
94
+ result[:programming_error] = e if e.is_a?(NameError) || e.is_a?(TypeError)
95
+ result
96
+ ensure
97
+ @active_mutex.synchronize { @active_issues.delete(issue_number) }
98
+ end
99
+
100
+ def build_issue_result(result, issue_number:, worktree:, issues:, logger: nil)
101
+ if result[:interrupted]
102
+ handle_interrupted_issue(issue_number, worktree&.path, result[:phase],
103
+ logger: logger || build_logger(issue_number: issue_number), issues: issues)
104
+ { issue_number: issue_number, success: false, worktree: worktree, interrupted: true }
105
+ elsif result[:success]
106
+ { issue_number: issue_number, success: true, worktree: worktree,
107
+ audit_blocked: result[:audit_blocked], audit_output: result[:audit_output] }
108
+ else
109
+ report_pipeline_failure(issue_number, result, issues: issues, config: @config, logger: logger)
110
+ { issue_number: issue_number, success: false, worktree: worktree }
111
+ end
112
+ end
113
+
114
+ def worktree_manager_for(worktree, shared:, logger:)
115
+ target = worktree.target_repo
116
+ return WorktreeManager.new(config: @config, repo_dir: target[:path], logger: logger) if target
117
+
118
+ shared || WorktreeManager.new(config: @config, logger: logger)
119
+ end
120
+
121
+ def resolve_targets(ready_issues, logger:)
122
+ ready_issues.filter_map do |issue|
123
+ target = TargetResolver.resolve(issue, config: @config)
124
+ issue['_target'] = target
125
+ issue
126
+ rescue TargetResolver::TargetResolutionError => e
127
+ logger.error("Skipping issue ##{issue['number']}: #{e.message}")
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ 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
@@ -4,8 +4,10 @@ require 'open3'
4
4
  require 'securerandom'
5
5
  require_relative '../config'
6
6
  require_relative '../claude_runner'
7
+ require_relative '../command_runner'
7
8
  require_relative '../git_utils'
8
- require_relative '../issue_fetcher'
9
+ require_relative '../issue_backend'
10
+ require_relative '../issue_state_machine'
9
11
  require_relative '../pipeline_executor'
10
12
  require_relative '../step_comments'
11
13
  require_relative '../logger'
@@ -14,6 +16,7 @@ module Ocak
14
16
  module Commands
15
17
  class Hiz < Dry::CLI::Command
16
18
  include StepComments
19
+ include CommandRunner
17
20
 
18
21
  desc 'Fast-mode: implement an issue with Sonnet, create a PR (no merge)'
19
22
 
@@ -24,9 +27,9 @@ module Ocak
24
27
  option :quiet, type: :boolean, default: false, desc: 'Suppress non-error output'
25
28
 
26
29
  HIZ_STEPS = [
27
- { agent: 'implementer', role: 'implement', model: 'sonnet' },
28
- { agent: 'reviewer', role: 'review', model: 'haiku', parallel: true },
29
- { agent: 'security-reviewer', role: 'security', model: 'sonnet', parallel: true }
30
+ { agent: 'implementer', role: 'implement', model: ClaudeRunner::MODEL_SONNET },
31
+ { agent: 'reviewer', role: 'review', model: ClaudeRunner::MODEL_HAIKU, parallel: true },
32
+ { agent: 'security-reviewer', role: 'security', model: ClaudeRunner::MODEL_SONNET, parallel: true }
30
33
  ].freeze
31
34
 
32
35
  HizState = Struct.new(:issues, :total_cost, :steps_run, :review_results)
@@ -43,7 +46,8 @@ module Ocak
43
46
  @logger = logger = build_logger(issue_number)
44
47
  watch_formatter = options[:watch] ? WatchFormatter.new : nil
45
48
  claude = ClaudeRunner.new(config: @config, logger: logger, watch: watch_formatter)
46
- issues = IssueFetcher.new(config: @config, logger: logger)
49
+ issues = IssueBackend.build(config: @config, logger: logger)
50
+ @state_machine = IssueStateMachine.new(config: @config, issues: issues)
47
51
 
48
52
  logger.info("=== Hiz (fast mode) for issue ##{issue_number} ===")
49
53
 
@@ -68,7 +72,7 @@ module Ocak
68
72
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
73
  chdir = @config.project_dir
70
74
 
71
- issues.transition(issue_number, from: @config.label_ready, to: @config.label_in_progress)
75
+ @state_machine.mark_in_progress(issue_number)
72
76
  post_hiz_start_comment(issue_number, state: state)
73
77
  begin
74
78
  branch = create_branch(issue_number, chdir)
@@ -81,7 +85,7 @@ module Ocak
81
85
  executor = PipelineExecutor.new(config: @config, issues: issues)
82
86
  result = executor.run_pipeline(
83
87
  issue_number, logger: logger, claude: claude, chdir: chdir,
84
- steps: HIZ_STEPS, verification_model: 'sonnet',
88
+ steps: HIZ_STEPS, verification_model: ClaudeRunner::MODEL_SONNET,
85
89
  post_start_comment: false, post_summary_comment: false
86
90
  )
87
91
 
@@ -119,8 +123,8 @@ module Ocak
119
123
  branch = "hiz/issue-#{issue_number}-#{SecureRandom.hex(4)}"
120
124
  raise "Unsafe branch name: #{branch}" unless GitUtils.safe_branch_name?(branch)
121
125
 
122
- _, stderr, status = Open3.capture3('git', 'checkout', '-b', branch, chdir: chdir)
123
- raise "Failed to create branch #{branch}: #{stderr}" unless status.success?
126
+ result = run_git('checkout', '-b', branch, chdir: chdir)
127
+ raise "Failed to create branch #{branch}: #{result.error}" unless result.success?
124
128
 
125
129
  branch
126
130
  end
@@ -128,10 +132,10 @@ module Ocak
128
132
  def push_and_create_pr(issue_number, branch, logger:, chdir:, state:)
129
133
  commit_changes(issue_number, chdir, logger: logger)
130
134
 
131
- _, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch, chdir: chdir)
132
- unless status.success?
133
- logger.error("Push failed: #{stderr}")
134
- handle_failure(issue_number, 'push', stderr, issues: state.issues, logger: logger, branch: branch)
135
+ push_result = run_git('push', '-u', 'origin', branch, chdir: chdir)
136
+ unless push_result.success?
137
+ logger.error("Push failed: #{push_result.error}")
138
+ handle_failure(issue_number, 'push', push_result.error, issues: state.issues, logger: logger, branch: branch)
135
139
  return
136
140
  end
137
141
 
@@ -140,21 +144,16 @@ module Ocak
140
144
  pr_title = issue_title ? "Fix ##{issue_number}: #{issue_title}" : "Fix ##{issue_number}"
141
145
  pr_body = build_pr_body(issue_number, state: state)
142
146
 
143
- stdout, stderr, status = Open3.capture3(
144
- 'gh', 'pr', 'create',
145
- '--title', pr_title,
146
- '--body', pr_body,
147
- '--head', branch,
148
- chdir: chdir
149
- )
147
+ pr_result = run_gh('pr', 'create', '--title', pr_title, '--body', pr_body, '--head', branch, chdir: chdir)
150
148
 
151
- if status.success?
152
- pr_url = stdout.strip
149
+ if pr_result.success?
150
+ pr_url = pr_result.output
153
151
  logger.info("PR created: #{pr_url}")
154
152
  puts "PR created: #{pr_url}"
155
153
  else
156
- logger.error("PR creation failed: #{stderr}")
157
- handle_failure(issue_number, 'pr-create', stderr, issues: state.issues, logger: logger, branch: branch)
154
+ logger.error("PR creation failed: #{pr_result.error}")
155
+ handle_failure(issue_number, 'pr-create', pr_result.error, issues: state.issues, logger: logger,
156
+ branch: branch)
158
157
  end
159
158
  end
160
159
 
@@ -185,12 +184,13 @@ module Ocak
185
184
 
186
185
  def handle_failure(issue_number, phase, output, issues:, logger:, branch: nil)
187
186
  logger.error("Issue ##{issue_number} failed at phase: #{phase}")
188
- issues.transition(issue_number, from: @config.label_in_progress, to: @config.label_failed)
187
+ @state_machine.mark_failed(issue_number)
189
188
  begin
190
189
  sanitized = output.to_s[0..1000].gsub('```', "'''")
191
190
  issues.comment(issue_number,
192
191
  "Hiz (fast mode) failed at phase: #{phase}\n\n```\n#{sanitized}\n```")
193
- rescue StandardError
192
+ rescue StandardError => e
193
+ logger&.debug("Failure comment failed: #{e.message}")
194
194
  nil
195
195
  end
196
196
  warn "Issue ##{issue_number} failed at phase: #{phase}"
@@ -199,8 +199,8 @@ module Ocak
199
199
  end
200
200
 
201
201
  def delete_branch(branch, logger:)
202
- _, stderr, status = Open3.capture3('git', 'branch', '-D', branch, chdir: @config.project_dir)
203
- logger.warn("Failed to delete branch #{branch}: #{stderr}") unless status.success?
202
+ result = run_git('branch', '-D', branch, chdir: @config.project_dir)
203
+ logger.warn("Failed to delete branch #{branch}: #{result.error}") unless result.success?
204
204
  rescue StandardError => e
205
205
  logger.warn("Error deleting branch #{branch}: #{e.message}")
206
206
  end