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.
- checksums.yaml +4 -4
- data/README.md +101 -21
- data/lib/ocak/agent_generator.rb +11 -1
- data/lib/ocak/batch_processing.rb +132 -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 +28 -28
- data/lib/ocak/commands/init.rb +37 -0
- 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 +11 -9
- data/lib/ocak/commands/status.rb +29 -12
- data/lib/ocak/config.rb +72 -1
- data/lib/ocak/conflict_resolution.rb +73 -0
- data/lib/ocak/failure_reporting.rb +6 -3
- data/lib/ocak/git_utils.rb +18 -11
- data/lib/ocak/instance_builders.rb +54 -0
- data/lib/ocak/issue_backend.rb +31 -0
- data/lib/ocak/issue_fetcher.rb +9 -0
- data/lib/ocak/issue_state_machine.rb +36 -0
- data/lib/ocak/local_issue_fetcher.rb +165 -0
- data/lib/ocak/local_merge_manager.rb +104 -0
- data/lib/ocak/merge_manager.rb +30 -103
- data/lib/ocak/merge_orchestration.rb +36 -24
- data/lib/ocak/merge_verification.rb +40 -0
- data/lib/ocak/parallel_execution.rb +36 -0
- data/lib/ocak/pipeline_executor.rb +17 -185
- data/lib/ocak/pipeline_runner.rb +32 -180
- data/lib/ocak/planner.rb +16 -1
- data/lib/ocak/project_key.rb +38 -0
- data/lib/ocak/reready_processor.rb +11 -11
- data/lib/ocak/run_report.rb +5 -2
- data/lib/ocak/shutdown_handling.rb +67 -0
- data/lib/ocak/state_management.rb +104 -0
- data/lib/ocak/step_execution.rb +66 -0
- data/lib/ocak/stream_parser.rb +1 -1
- data/lib/ocak/target_resolver.rb +41 -0
- data/lib/ocak/templates/agents/auditor.md.erb +38 -9
- data/lib/ocak/templates/agents/implementer.md.erb +35 -8
- data/lib/ocak/templates/agents/merger.md.erb +24 -5
- data/lib/ocak/templates/agents/pipeline.md.erb +22 -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/gitignore_additions.txt +1 -0
- data/lib/ocak/templates/ocak.yml.erb +24 -0
- data/lib/ocak/verification.rb +6 -1
- data/lib/ocak/worktree_manager.rb +9 -6
- data/lib/ocak.rb +1 -1
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21e35193d8d3fc3b9c24f7e75502749ce4f9a57352da20d9bb0c980c2191ad9a
|
|
4
|
+
data.tar.gz: 2204cd3797a57348f3a3742fa17385375050d8b66fe37d4bdb466a4f603f31dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|
|
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**
|
|
87
|
-
- **Full**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 |
|
|
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
|
|
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**
|
|
357
|
-
- **Acceptance Criteria**
|
|
358
|
-
- **Implementation Guide**
|
|
359
|
-
- **Patterns to Follow**
|
|
360
|
-
- **Security Considerations**
|
|
361
|
-
- **Test Requirements**
|
|
362
|
-
- **Out of Scope**
|
|
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.
|
data/lib/ocak/agent_generator.rb
CHANGED
|
@@ -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',
|
|
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
|
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
|
@@ -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 '../
|
|
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:
|
|
28
|
-
{ agent: 'reviewer', role: 'review', model:
|
|
29
|
-
{ agent: 'security-reviewer', role: 'security', model:
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
123
|
-
raise "Failed to create branch #{branch}: #{
|
|
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
|
-
|
|
132
|
-
unless
|
|
133
|
-
logger.error("Push failed: #{
|
|
134
|
-
handle_failure(issue_number, 'push',
|
|
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
|
-
|
|
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
|
|
152
|
-
pr_url =
|
|
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: #{
|
|
157
|
-
handle_failure(issue_number, 'pr-create',
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
logger.warn("Failed to delete branch #{branch}: #{
|
|
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
|