kaizen-loop 0.1.0__py3-none-any.whl
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.
- kaizen/__init__.py +1 -0
- kaizen/__main__.py +4 -0
- kaizen/agent.py +349 -0
- kaizen/cli.py +92 -0
- kaizen/config.py +31 -0
- kaizen/findings.py +53 -0
- kaizen/git.py +208 -0
- kaizen/loop.py +248 -0
- kaizen/orchestrator.py +159 -0
- kaizen/review_prompt.py +66 -0
- kaizen/run.py +93 -0
- kaizen/steps/__init__.py +11 -0
- kaizen/steps/pr.py +96 -0
- kaizen/steps/push.py +23 -0
- kaizen/steps/review.py +55 -0
- kaizen/work_prompt.py +43 -0
- kaizen_loop-0.1.0.dist-info/METADATA +10 -0
- kaizen_loop-0.1.0.dist-info/RECORD +21 -0
- kaizen_loop-0.1.0.dist-info/WHEEL +4 -0
- kaizen_loop-0.1.0.dist-info/entry_points.txt +2 -0
- kaizen_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
kaizen/git.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _git(args: list[str], cwd: str) -> str:
|
|
7
|
+
result = subprocess.run(
|
|
8
|
+
["git"] + args,
|
|
9
|
+
cwd=cwd,
|
|
10
|
+
capture_output=True,
|
|
11
|
+
text=True,
|
|
12
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
|
|
13
|
+
)
|
|
14
|
+
if result.returncode != 0:
|
|
15
|
+
parts = [result.stderr.strip(), result.stdout.strip()]
|
|
16
|
+
raise RuntimeError(" | ".join(p for p in parts if p))
|
|
17
|
+
return result.stdout.strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_git_repo(cwd: str) -> bool:
|
|
21
|
+
try:
|
|
22
|
+
_git(["rev-parse", "--git-dir"], cwd)
|
|
23
|
+
return True
|
|
24
|
+
except RuntimeError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_root(cwd: str) -> str:
|
|
29
|
+
return _git(["rev-parse", "--show-toplevel"], cwd)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def current_branch(cwd: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
return _git(["symbolic-ref", "--short", "HEAD"], cwd)
|
|
35
|
+
except RuntimeError:
|
|
36
|
+
return _git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def head_commit(cwd: str) -> str:
|
|
40
|
+
return _git(["rev-parse", "HEAD"], cwd)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_clean_tree(cwd: str) -> None:
|
|
44
|
+
status = _git(["status", "--porcelain"], cwd)
|
|
45
|
+
if status:
|
|
46
|
+
raise RuntimeError("Working tree is not clean. Commit or stash changes first.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_default_branch(cwd: str) -> str:
|
|
50
|
+
try:
|
|
51
|
+
ref = _git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd)
|
|
52
|
+
return ref.split("/")[-1]
|
|
53
|
+
except RuntimeError:
|
|
54
|
+
try:
|
|
55
|
+
refs = _git(["branch", "-r"], cwd)
|
|
56
|
+
for line in refs.splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if line == "origin/main":
|
|
59
|
+
return "main"
|
|
60
|
+
if line == "origin/master":
|
|
61
|
+
return "master"
|
|
62
|
+
except RuntimeError:
|
|
63
|
+
pass
|
|
64
|
+
return "main"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_ref(cwd: str, ref: str) -> str:
|
|
68
|
+
return _git(["rev-parse", ref], cwd)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_diff(base: str, head: str, cwd: str) -> str:
|
|
72
|
+
return _git(["diff", f"{base}..{head}"], cwd)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fetch(cwd: str, remote: str = "origin") -> None:
|
|
76
|
+
_git(["fetch", remote], cwd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_branch(name: str, cwd: str) -> None:
|
|
80
|
+
_git(["checkout", "-b", name], cwd)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def checkout(branch: str, cwd: str) -> None:
|
|
84
|
+
_git(["checkout", branch], cwd)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def branch_exists(cwd: str, branch: str) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
_git(["rev-parse", "--verify", branch], cwd)
|
|
90
|
+
return True
|
|
91
|
+
except RuntimeError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def delete_branch(branch: str, cwd: str) -> None:
|
|
96
|
+
try:
|
|
97
|
+
_git(["branch", "-D", branch], cwd)
|
|
98
|
+
except RuntimeError:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def commit_all(message: str, cwd: str) -> None:
|
|
103
|
+
_git(["add", "-A"], cwd)
|
|
104
|
+
diff = subprocess.run(
|
|
105
|
+
["git", "diff", "--cached", "--quiet"],
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
)
|
|
110
|
+
if diff.returncode == 0:
|
|
111
|
+
return
|
|
112
|
+
_git(
|
|
113
|
+
["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "commit", "-m", message],
|
|
114
|
+
cwd,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def reset_hard(cwd: str) -> None:
|
|
119
|
+
_git(["reset", "--hard", "HEAD"], cwd)
|
|
120
|
+
_git(["clean", "-fdx", "-e", ".kaizen"], cwd)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def branch_commit_count(base: str, cwd: str) -> int:
|
|
124
|
+
if not base:
|
|
125
|
+
return 0
|
|
126
|
+
return int(_git(["rev-list", "--count", "--first-parent", f"{base}..HEAD"], cwd))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def branch_diff_stats(base: str, cwd: str) -> dict:
|
|
130
|
+
if not base:
|
|
131
|
+
return {"files_changed": 0, "lines_added": 0, "lines_deleted": 0}
|
|
132
|
+
rng = f"{base}..HEAD"
|
|
133
|
+
name_status = _git(["diff", "--name-status", rng], cwd)
|
|
134
|
+
numstat = _git(["diff", "--numstat", rng], cwd)
|
|
135
|
+
files_changed = len([line for line in name_status.splitlines() if line.strip()])
|
|
136
|
+
lines_added = 0
|
|
137
|
+
lines_deleted = 0
|
|
138
|
+
for line in numstat.splitlines():
|
|
139
|
+
parts = line.split("\t")
|
|
140
|
+
if len(parts) >= 2 and parts[0] != "-":
|
|
141
|
+
lines_added += int(parts[0] or 0)
|
|
142
|
+
lines_deleted += int(parts[1] or 0)
|
|
143
|
+
return {"files_changed": files_changed, "lines_added": lines_added, "lines_deleted": lines_deleted}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def slugify_prompt(text: str) -> str:
|
|
147
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
148
|
+
slug = re.sub(r"-+", "-", slug)[:50].strip("-")
|
|
149
|
+
return f"kaizen/{slug}" if slug else "kaizen/run"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def create_worktree(repo_cwd: str, branch_name: str, target_path: str) -> str:
|
|
153
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
154
|
+
if os.path.isdir(target_path):
|
|
155
|
+
remove_worktree(repo_cwd, target_path)
|
|
156
|
+
try:
|
|
157
|
+
_git(["worktree", "add", target_path, "-b", branch_name, "HEAD"], repo_cwd)
|
|
158
|
+
except RuntimeError as e:
|
|
159
|
+
if "already exists" not in str(e).lower():
|
|
160
|
+
raise
|
|
161
|
+
remove_worktree(repo_cwd, target_path)
|
|
162
|
+
try:
|
|
163
|
+
_git(["branch", "-D", branch_name], repo_cwd)
|
|
164
|
+
except RuntimeError:
|
|
165
|
+
pass
|
|
166
|
+
_git(["worktree", "prune"], repo_cwd)
|
|
167
|
+
_git(["worktree", "add", target_path, "-b", branch_name, "HEAD"], repo_cwd)
|
|
168
|
+
return target_path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def create_worktree_from_ref(repo_cwd: str, target_path: str, branch_name: str, ref: str) -> str:
|
|
172
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
173
|
+
if os.path.isdir(target_path):
|
|
174
|
+
remove_worktree(repo_cwd, target_path)
|
|
175
|
+
_git(["worktree", "add", "-b", branch_name, target_path, ref], repo_cwd)
|
|
176
|
+
return target_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def remove_worktree(repo_cwd: str, target_path: str) -> None:
|
|
180
|
+
try:
|
|
181
|
+
_git(["worktree", "remove", "--force", target_path], repo_cwd)
|
|
182
|
+
except RuntimeError:
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
_git(["worktree", "prune"], repo_cwd)
|
|
186
|
+
except RuntimeError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def push_branch(cwd: str, remote: str = "origin", branch: str | None = None) -> None:
|
|
191
|
+
args = ["push", remote]
|
|
192
|
+
if branch:
|
|
193
|
+
args.extend(["-u", branch])
|
|
194
|
+
_git(args, cwd)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def force_push_with_lease(cwd: str, remote: str, branch: str) -> None:
|
|
198
|
+
_git(["push", "--force-with-lease", remote, f"HEAD:refs/heads/{branch}"], cwd)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def copy_user_identity(src_cwd: str, dst_cwd: str) -> None:
|
|
202
|
+
for key in ["user.name", "user.email"]:
|
|
203
|
+
try:
|
|
204
|
+
val = _git(["config", key], src_cwd)
|
|
205
|
+
if val:
|
|
206
|
+
_git(["config", key, val], dst_cwd)
|
|
207
|
+
except RuntimeError:
|
|
208
|
+
pass
|
kaizen/loop.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from kaizen.agent import OpenCodeAgent
|
|
9
|
+
from kaizen.config import load_config
|
|
10
|
+
from kaizen.findings import FindingsResult, Finding
|
|
11
|
+
from kaizen.git import (
|
|
12
|
+
checkout,
|
|
13
|
+
commit_all,
|
|
14
|
+
copy_user_identity,
|
|
15
|
+
create_branch,
|
|
16
|
+
create_worktree_from_ref,
|
|
17
|
+
delete_branch,
|
|
18
|
+
fetch,
|
|
19
|
+
get_default_branch,
|
|
20
|
+
head_commit,
|
|
21
|
+
remove_worktree,
|
|
22
|
+
resolve_ref,
|
|
23
|
+
slugify_prompt,
|
|
24
|
+
)
|
|
25
|
+
from kaizen.orchestrator import Orchestrator
|
|
26
|
+
from kaizen.review_prompt import build_fix_prompt
|
|
27
|
+
from kaizen.run import RunInfo, setup_run, update_run_head, update_run_pr_url, update_run_status
|
|
28
|
+
from kaizen.steps.pr import PRStep
|
|
29
|
+
from kaizen.steps.push import PushStep
|
|
30
|
+
from kaizen.steps.review import ReviewStep
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _WorkContext:
|
|
35
|
+
branch: str
|
|
36
|
+
work_dir: str
|
|
37
|
+
worktree_path: str | None
|
|
38
|
+
base_commit: str
|
|
39
|
+
run_info: RunInfo
|
|
40
|
+
default_branch: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _setup_work_context(
|
|
44
|
+
prompt: str,
|
|
45
|
+
cwd: str,
|
|
46
|
+
use_worktree: bool = True,
|
|
47
|
+
) -> _WorkContext:
|
|
48
|
+
kaizen_dir = os.path.join(cwd, ".kaizen")
|
|
49
|
+
os.makedirs(kaizen_dir, exist_ok=True)
|
|
50
|
+
gitignore = os.path.join(kaizen_dir, ".gitignore")
|
|
51
|
+
if not os.path.exists(gitignore):
|
|
52
|
+
Path(gitignore).write_text("worktrees/\nruns/\n")
|
|
53
|
+
|
|
54
|
+
root_gitignore = os.path.join(cwd, ".gitignore")
|
|
55
|
+
if os.path.exists(root_gitignore):
|
|
56
|
+
content = Path(root_gitignore).read_text()
|
|
57
|
+
if ".kaizen" not in content.splitlines():
|
|
58
|
+
Path(root_gitignore).write_text(content.rstrip() + "\n.kaizen/\n")
|
|
59
|
+
else:
|
|
60
|
+
Path(root_gitignore).write_text(".kaizen/\n")
|
|
61
|
+
|
|
62
|
+
fetch(cwd)
|
|
63
|
+
default_branch = get_default_branch(cwd)
|
|
64
|
+
try:
|
|
65
|
+
base_commit = resolve_ref(cwd, f"origin/{default_branch}")
|
|
66
|
+
except RuntimeError:
|
|
67
|
+
base_commit = head_commit(cwd)
|
|
68
|
+
|
|
69
|
+
branch = slugify_prompt(prompt)
|
|
70
|
+
work_dir = cwd
|
|
71
|
+
worktree_path: str | None = None
|
|
72
|
+
|
|
73
|
+
if use_worktree:
|
|
74
|
+
worktree_path = os.path.join(cwd, ".kaizen", "worktrees", branch.replace("/", "-"))
|
|
75
|
+
print(f" creating worktree: {worktree_path}")
|
|
76
|
+
try:
|
|
77
|
+
create_worktree_from_ref(cwd, worktree_path, branch, f"origin/{default_branch}")
|
|
78
|
+
except RuntimeError as e:
|
|
79
|
+
print(f" worktree creation failed: {e}", file=sys.stderr)
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
copy_user_identity(cwd, worktree_path)
|
|
82
|
+
work_dir = worktree_path
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
create_branch(branch, cwd)
|
|
86
|
+
except RuntimeError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
current_head = head_commit(work_dir)
|
|
90
|
+
run_info = setup_run(
|
|
91
|
+
prompt=prompt,
|
|
92
|
+
branch=branch,
|
|
93
|
+
base_commit=base_commit,
|
|
94
|
+
head_commit=current_head,
|
|
95
|
+
worktree_path=worktree_path,
|
|
96
|
+
repo_cwd=cwd,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return _WorkContext(
|
|
100
|
+
branch=branch,
|
|
101
|
+
work_dir=work_dir,
|
|
102
|
+
worktree_path=worktree_path,
|
|
103
|
+
base_commit=base_commit,
|
|
104
|
+
run_info=run_info,
|
|
105
|
+
default_branch=default_branch,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_loop(
|
|
110
|
+
prompt: str,
|
|
111
|
+
cwd: str,
|
|
112
|
+
agent: OpenCodeAgent,
|
|
113
|
+
max_work_iterations: int | None = None,
|
|
114
|
+
max_review_rounds: int = 3,
|
|
115
|
+
use_worktree: bool = True,
|
|
116
|
+
) -> str:
|
|
117
|
+
ctx = _setup_work_context(prompt, cwd, use_worktree)
|
|
118
|
+
|
|
119
|
+
print(f" run {ctx.run_info.run_id} on branch {ctx.branch}")
|
|
120
|
+
print(f" base: {ctx.default_branch} ({ctx.base_commit[:8]})")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# ── Phase 1: WORK ──
|
|
124
|
+
print(f"\n{'=' * 50}")
|
|
125
|
+
print(" PHASE: WORK")
|
|
126
|
+
print(f"{'=' * 50}")
|
|
127
|
+
|
|
128
|
+
config = load_config()
|
|
129
|
+
orch = Orchestrator(
|
|
130
|
+
agent=agent,
|
|
131
|
+
run_info=ctx.run_info,
|
|
132
|
+
prompt=prompt,
|
|
133
|
+
cwd=ctx.work_dir,
|
|
134
|
+
max_iterations=max_work_iterations or config.get("max_work_iterations"),
|
|
135
|
+
repo_dir=cwd,
|
|
136
|
+
)
|
|
137
|
+
orch.run()
|
|
138
|
+
|
|
139
|
+
current_head = head_commit(ctx.work_dir)
|
|
140
|
+
update_run_head(ctx.run_info.run_dir, current_head)
|
|
141
|
+
|
|
142
|
+
# ── Phase 2: REVIEW (with fix loop) ──
|
|
143
|
+
print(f"\n{'=' * 50}")
|
|
144
|
+
print(" PHASE: REVIEW")
|
|
145
|
+
print(f"{'=' * 50}")
|
|
146
|
+
|
|
147
|
+
review_step = ReviewStep()
|
|
148
|
+
final_findings: FindingsResult | None = None
|
|
149
|
+
|
|
150
|
+
for round_num in range(max_review_rounds):
|
|
151
|
+
print(f"\n review round {round_num + 1}/{max_review_rounds}")
|
|
152
|
+
|
|
153
|
+
current_head = head_commit(ctx.work_dir)
|
|
154
|
+
outcome = review_step.execute(
|
|
155
|
+
work_dir=ctx.work_dir,
|
|
156
|
+
base_commit=ctx.base_commit,
|
|
157
|
+
head_commit=current_head,
|
|
158
|
+
agent=agent,
|
|
159
|
+
intent=prompt,
|
|
160
|
+
repo_dir=cwd,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if outcome.skipped:
|
|
164
|
+
print(" review skipped")
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if outcome.findings:
|
|
168
|
+
final_findings = outcome.findings
|
|
169
|
+
|
|
170
|
+
if not outcome.findings or not outcome.findings.items:
|
|
171
|
+
print(" review clean")
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
findings = outcome.findings
|
|
175
|
+
auto_fix_items = findings.auto_fix_items
|
|
176
|
+
|
|
177
|
+
if auto_fix_items:
|
|
178
|
+
print(f"\n auto-fixing {len(auto_fix_items)} issues...")
|
|
179
|
+
fix_prompt = build_fix_prompt([_finding_to_dict(f) for f in auto_fix_items])
|
|
180
|
+
try:
|
|
181
|
+
agent.run(fix_prompt, ctx.work_dir, repo_dir=cwd)
|
|
182
|
+
commit_all(f"kaizen: fix {len(auto_fix_items)} review findings", ctx.work_dir)
|
|
183
|
+
current_head = head_commit(ctx.work_dir)
|
|
184
|
+
update_run_head(ctx.run_info.run_dir, current_head)
|
|
185
|
+
print(" fixes committed")
|
|
186
|
+
continue
|
|
187
|
+
except Exception as e:
|
|
188
|
+
print(f" auto-fix failed: {e}")
|
|
189
|
+
else:
|
|
190
|
+
print(" no actionable findings")
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
# ── Phase 3: SHIP ──
|
|
194
|
+
print(f"\n{'=' * 50}")
|
|
195
|
+
print(" PHASE: SHIP")
|
|
196
|
+
print(f"{'=' * 50}")
|
|
197
|
+
|
|
198
|
+
push_step = PushStep()
|
|
199
|
+
push_outcome = push_step.execute(work_dir=ctx.work_dir, branch=ctx.branch)
|
|
200
|
+
if push_outcome.skipped:
|
|
201
|
+
print(" push skipped, cannot create PR")
|
|
202
|
+
update_run_status(ctx.run_info.run_dir, "failed")
|
|
203
|
+
return "failed"
|
|
204
|
+
|
|
205
|
+
pr_step = PRStep()
|
|
206
|
+
pr_outcome = pr_step.execute(
|
|
207
|
+
work_dir=ctx.work_dir,
|
|
208
|
+
branch=ctx.branch,
|
|
209
|
+
base_branch=ctx.default_branch,
|
|
210
|
+
findings=final_findings,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if pr_outcome.pr_url:
|
|
214
|
+
update_run_pr_url(ctx.run_info.run_dir, pr_outcome.pr_url)
|
|
215
|
+
|
|
216
|
+
update_run_status(ctx.run_info.run_dir, "completed")
|
|
217
|
+
return "passed"
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
print(f"\n [FATAL] {e}", file=sys.stderr)
|
|
221
|
+
update_run_status(ctx.run_info.run_dir, "failed")
|
|
222
|
+
return "failed"
|
|
223
|
+
|
|
224
|
+
finally:
|
|
225
|
+
# ── Phase 4: CLEANUP ──
|
|
226
|
+
print("\n cleaning up...")
|
|
227
|
+
if ctx.worktree_path:
|
|
228
|
+
remove_worktree(cwd, ctx.worktree_path)
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
checkout(ctx.default_branch, cwd)
|
|
232
|
+
except RuntimeError:
|
|
233
|
+
pass
|
|
234
|
+
delete_branch(ctx.branch, cwd)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _finding_to_dict(f: Finding) -> dict:
|
|
238
|
+
d: dict = {
|
|
239
|
+
"id": f.id,
|
|
240
|
+
"severity": f.severity,
|
|
241
|
+
"description": f.description,
|
|
242
|
+
"action": f.action,
|
|
243
|
+
}
|
|
244
|
+
if f.file:
|
|
245
|
+
d["file"] = f.file
|
|
246
|
+
if f.line:
|
|
247
|
+
d["line"] = f.line
|
|
248
|
+
return d
|
kaizen/orchestrator.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from kaizen.agent import OpenCodeAgent
|
|
4
|
+
from kaizen.config import load_config
|
|
5
|
+
from kaizen.git import (
|
|
6
|
+
branch_commit_count,
|
|
7
|
+
commit_all,
|
|
8
|
+
push_branch,
|
|
9
|
+
reset_hard,
|
|
10
|
+
)
|
|
11
|
+
from kaizen.run import RunInfo, append_notes
|
|
12
|
+
from kaizen.work_prompt import build_iteration_prompt
|
|
13
|
+
|
|
14
|
+
WORK_SCHEMA = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": False,
|
|
17
|
+
"properties": {
|
|
18
|
+
"success": {"type": "boolean"},
|
|
19
|
+
"summary": {"type": "string"},
|
|
20
|
+
"key_changes_made": {"type": "array", "items": {"type": "string"}},
|
|
21
|
+
"key_learnings": {"type": "array", "items": {"type": "string"}},
|
|
22
|
+
"should_fully_stop": {"type": "boolean"},
|
|
23
|
+
},
|
|
24
|
+
"required": ["success", "summary", "key_changes_made", "key_learnings"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Orchestrator:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
agent: OpenCodeAgent,
|
|
32
|
+
run_info: RunInfo,
|
|
33
|
+
prompt: str,
|
|
34
|
+
cwd: str,
|
|
35
|
+
start_iteration: int = 0,
|
|
36
|
+
max_iterations: int | None = None,
|
|
37
|
+
stop_when: str | None = None,
|
|
38
|
+
push_remote: str | None = None,
|
|
39
|
+
repo_dir: str | None = None,
|
|
40
|
+
):
|
|
41
|
+
self.agent = agent
|
|
42
|
+
self.run_info = run_info
|
|
43
|
+
self.prompt = prompt
|
|
44
|
+
self.cwd = cwd
|
|
45
|
+
self.repo_dir = repo_dir
|
|
46
|
+
self.config = load_config()
|
|
47
|
+
self.iteration = start_iteration
|
|
48
|
+
self.max_iterations = max_iterations
|
|
49
|
+
self.stop_when = stop_when
|
|
50
|
+
self.push_remote = push_remote
|
|
51
|
+
self.success_count = 0
|
|
52
|
+
self.fail_count = 0
|
|
53
|
+
self.consecutive_failures = 0
|
|
54
|
+
self.total_input_tokens = 0
|
|
55
|
+
self.total_output_tokens = 0
|
|
56
|
+
self.commit_count = branch_commit_count(run_info.base_commit, cwd)
|
|
57
|
+
self.start_time: float = 0
|
|
58
|
+
self._stop_requested = False
|
|
59
|
+
|
|
60
|
+
def request_stop(self) -> None:
|
|
61
|
+
self._stop_requested = True
|
|
62
|
+
|
|
63
|
+
def run(self) -> str:
|
|
64
|
+
self.start_time = time.time()
|
|
65
|
+
status = "stopped"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with self.agent.session(self.cwd, repo_dir=self.repo_dir) as sess:
|
|
69
|
+
while not self._stop_requested:
|
|
70
|
+
if self.max_iterations and self.iteration >= self.max_iterations:
|
|
71
|
+
print(f" max iterations reached ({self.max_iterations})")
|
|
72
|
+
status = "aborted"
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
self.iteration += 1
|
|
76
|
+
print(f"\n --- work iteration {self.iteration} ---")
|
|
77
|
+
|
|
78
|
+
iter_prompt = build_iteration_prompt(
|
|
79
|
+
self.iteration, self.run_info.run_id,
|
|
80
|
+
self.prompt, self.stop_when,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = sess.send(iter_prompt, schema=WORK_SCHEMA)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f" [ERROR] {e}")
|
|
87
|
+
self.fail_count += 1
|
|
88
|
+
self.consecutive_failures += 1
|
|
89
|
+
reset_hard(self.cwd)
|
|
90
|
+
if self.consecutive_failures >= self.config.get("max_consecutive_failures", 3):
|
|
91
|
+
print(f" {self.consecutive_failures} consecutive failures, aborting")
|
|
92
|
+
status = "aborted"
|
|
93
|
+
break
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
success = bool(result.output.get("success"))
|
|
97
|
+
summary = str(result.output.get("summary", ""))
|
|
98
|
+
changes = result.output.get("key_changes_made") or []
|
|
99
|
+
learnings = result.output.get("key_learnings") or []
|
|
100
|
+
should_stop = bool(result.output.get("should_fully_stop", False))
|
|
101
|
+
|
|
102
|
+
self.total_input_tokens += result.input_tokens
|
|
103
|
+
self.total_output_tokens += result.output_tokens
|
|
104
|
+
|
|
105
|
+
if success:
|
|
106
|
+
commit_msg = f"kaizen {self.iteration}: {summary}"
|
|
107
|
+
try:
|
|
108
|
+
commit_all(commit_msg, self.cwd)
|
|
109
|
+
except RuntimeError as e:
|
|
110
|
+
print(f" [COMMIT FAILED] {e}")
|
|
111
|
+
self.fail_count += 1
|
|
112
|
+
self.consecutive_failures += 1
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
self.commit_count = branch_commit_count(self.run_info.base_commit, self.cwd)
|
|
116
|
+
self.success_count += 1
|
|
117
|
+
self.consecutive_failures = 0
|
|
118
|
+
append_notes(
|
|
119
|
+
self.run_info.run_dir + "/notes.md",
|
|
120
|
+
self.iteration, summary, changes, learnings,
|
|
121
|
+
)
|
|
122
|
+
print(f" committed: {summary}")
|
|
123
|
+
|
|
124
|
+
if self.push_remote:
|
|
125
|
+
try:
|
|
126
|
+
push_branch(self.cwd, self.push_remote)
|
|
127
|
+
print(f" pushed to {self.push_remote}")
|
|
128
|
+
except RuntimeError as e:
|
|
129
|
+
print(f" [PUSH FAILED] {e}")
|
|
130
|
+
else:
|
|
131
|
+
self.fail_count += 1
|
|
132
|
+
self.consecutive_failures += 1
|
|
133
|
+
reset_hard(self.cwd)
|
|
134
|
+
append_notes(
|
|
135
|
+
self.run_info.run_dir + "/notes.md",
|
|
136
|
+
self.iteration, f"[FAIL] {summary}", [], learnings,
|
|
137
|
+
)
|
|
138
|
+
print(f" failed: {summary}")
|
|
139
|
+
|
|
140
|
+
if self.stop_when and should_stop:
|
|
141
|
+
print(f" stop condition met: {self.stop_when}")
|
|
142
|
+
status = "stopped"
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if self.consecutive_failures >= self.config.get("max_consecutive_failures", 3):
|
|
146
|
+
print(f" {self.consecutive_failures} consecutive failures, aborting")
|
|
147
|
+
status = "aborted"
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
print("\n interrupted")
|
|
152
|
+
status = "stopped"
|
|
153
|
+
|
|
154
|
+
return status
|
|
155
|
+
|
|
156
|
+
def elapsed(self) -> float:
|
|
157
|
+
if not self.start_time:
|
|
158
|
+
return 0
|
|
159
|
+
return time.time() - self.start_time
|
kaizen/review_prompt.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
REVIEW_SCHEMA = {
|
|
2
|
+
"type": "object",
|
|
3
|
+
"additionalProperties": False,
|
|
4
|
+
"properties": {
|
|
5
|
+
"findings": {
|
|
6
|
+
"type": "array",
|
|
7
|
+
"items": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": False,
|
|
10
|
+
"properties": {
|
|
11
|
+
"id": {"type": "string"},
|
|
12
|
+
"severity": {"type": "string", "enum": ["info", "warning", "error"]},
|
|
13
|
+
"file": {"type": "string"},
|
|
14
|
+
"line": {"type": "integer"},
|
|
15
|
+
"description": {"type": "string"},
|
|
16
|
+
"action": {"type": "string", "enum": ["no-op", "auto-fix"]},
|
|
17
|
+
},
|
|
18
|
+
"required": ["id", "severity", "description", "action"],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
"summary": {"type": "string"},
|
|
22
|
+
"risk_level": {"type": "string", "enum": ["low", "medium", "high"]},
|
|
23
|
+
"risk_rationale": {"type": "string"},
|
|
24
|
+
},
|
|
25
|
+
"required": ["findings", "summary", "risk_level"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_review_prompt(diff: str, intent: str = "") -> str:
|
|
30
|
+
intent_section = ""
|
|
31
|
+
if intent:
|
|
32
|
+
intent_section = f"\n## User Intent\n\n{intent}\n"
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
"You are reviewing a git diff for bugs, security issues, and code quality.\n\n"
|
|
36
|
+
"## Instructions\n\n"
|
|
37
|
+
"1. Analyze the diff for correctness, security, and quality\n"
|
|
38
|
+
"2. For each issue classify severity (info/warning/error) and action:\n"
|
|
39
|
+
" - no-op: informational, no action needed\n"
|
|
40
|
+
" - auto-fix: mechanical fix (typos, missing error handling, dead code, obvious bugs, behavioral changes)\n"
|
|
41
|
+
"3. Provide an overall risk assessment\n\n"
|
|
42
|
+
"## Output\n\n"
|
|
43
|
+
"Return structured output with:\n"
|
|
44
|
+
"- findings: array of issues found (empty array if clean)\n"
|
|
45
|
+
"- summary: one-sentence overall assessment\n"
|
|
46
|
+
"- risk_level: low, medium, or high\n"
|
|
47
|
+
"- risk_rationale: brief explanation of the risk level\n"
|
|
48
|
+
f"{intent_section}\n"
|
|
49
|
+
"## Diff\n\n"
|
|
50
|
+
f"```diff\n{diff}\n```\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_fix_prompt(findings_items: list[dict]) -> str:
|
|
55
|
+
lines = [
|
|
56
|
+
"Fix the following issues found during code review.\n",
|
|
57
|
+
"Do NOT commit. Just make the code changes.\n",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for f in findings_items:
|
|
61
|
+
loc = f" ({f['file']}:{f.get('line', '?')})" if f.get("file") else ""
|
|
62
|
+
lines.append(f"1. [{f['severity']}]{loc} — {f['description']} ({f['action']})")
|
|
63
|
+
|
|
64
|
+
lines.append("\nAfter fixing, run any available tests or linters to verify.")
|
|
65
|
+
|
|
66
|
+
return "\n".join(lines)
|