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/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
@@ -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)