kaizen-loop 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v8.2.0
15
+ - run: uvx ruff check src/
16
+
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ matrix:
21
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: astral-sh/setup-uv@v8.2.0
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+ - run: uv venv
28
+ - run: uv pip install -e ".[dev]"
29
+ - run: uv run python -m pytest
@@ -0,0 +1,32 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v8.2.0
13
+ with:
14
+ python-version: "3.12"
15
+ - run: uv build
16
+ - uses: actions/upload-artifact@v4
17
+ with:
18
+ name: dist
19
+ path: dist/
20
+
21
+ publish:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/download-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ .venv/
6
+ venv/
7
+ .kaizen/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaizen-loop
3
+ Version: 0.1.0
4
+ Summary: Continuous code improvement: autonomous work → review → fix → ship
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest; extra == 'dev'
10
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,184 @@
1
+ # kaizen-loop
2
+
3
+ Continuous code improvement: autonomous work → review → fix → ship.
4
+
5
+ A zero-dependency Python harness that drives [opencode](https://opencode.ai) through the full cycle of writing, validating, and shipping code — fully autonomous.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # Install
11
+ pip install -e .
12
+
13
+ # Run
14
+ kaizen "add a --json flag to the status command"
15
+ ```
16
+
17
+ That's it. kaizen creates an isolated branch, the agent does the work, the pipeline reviews it, fixes what it can, pushes to origin, and opens a PR.
18
+
19
+ ### Prerequisites
20
+
21
+ - Python 3.10+
22
+ - [opencode](https://opencode.ai) (`curl -fsSL https://opencode.ai/install | bash`)
23
+ - `git`
24
+ - `gh` CLI (for PR creation)
25
+
26
+ ### Options
27
+
28
+ ```
29
+ kaizen "prompt" [-C /path/to/repo] [--max-iterations N] [--max-review-rounds N] [--no-worktree] [--server-url URL]
30
+ ```
31
+
32
+ | Option | Meaning |
33
+ |---|---|
34
+ | `-C, --directory` | Path to git repo (default: current dir) |
35
+ | `--max-iterations` | Max work iterations |
36
+ | `--max-review-rounds` | Max review rounds |
37
+ | `--no-worktree` | Work in current tree instead of a worktree |
38
+ | `--opencode-bin` | Path to opencode binary |
39
+ | `--server-url` | Reuse an existing `opencode serve` instance (e.g. `http://127.0.0.1:4096`) |
40
+
41
+ ## How it works
42
+
43
+ ```
44
+ kaizen "add --json flag"
45
+
46
+ ┌───────────────────────────┐
47
+ │ SETUP │
48
+ │ fetch origin/main │
49
+ │ create branch kaizen/<slug>│
50
+ │ create isolated worktree │
51
+ └────────────┬──────────────┘
52
+
53
+ ┌───────────────────────────────┐
54
+ │ WORK │
55
+ │ agent reads prompt + notes │
56
+ │ makes changes, commits │
57
+ │ repeats until done or limit │
58
+ └───────────────┬───────────────┘
59
+
60
+ ┌────────────────────────────┐
61
+ │ REVIEW │
62
+ │ agent reviews diff │
63
+ │ returns structured findings │
64
+ │ ┌────────────────────────┐ │
65
+ │ │ auto-fix findings? │ │
66
+ │ │ → agent fixes, re-review│ │
67
+ │ └────────────────────────┘ │
68
+ └────────────┬───────────────┘
69
+
70
+ ┌──────────────────┐
71
+ │ SHIP │
72
+ │ push to origin │
73
+ │ create PR (gh) │
74
+ └────────┬─────────┘
75
+
76
+ ┌──────────────────┐
77
+ │ CLEANUP │
78
+ │ remove worktree │
79
+ │ delete branch │
80
+ │ print PR URL │
81
+ └──────────────────┘
82
+ ```
83
+
84
+ ### Findings
85
+
86
+ The review step returns structured findings with actions:
87
+
88
+ | Action | Meaning | Who handles it |
89
+ |---|---|---|
90
+ | `no-op` | Informational | Silently accepted |
91
+ | `auto-fix` | Mechanical fix (typos, dead code, missing error handling, behavioral changes) | Agent fixes automatically |
92
+
93
+ ### Worktree isolation
94
+
95
+ By default kaizen creates a git worktree for each run. Your working directory stays clean while the agent operates in isolation. The worktree is removed after the run completes.
96
+
97
+ ```
98
+ my-project/ ← your tree, untouched
99
+ .kaizen/worktrees/<slug>/ ← agent works here
100
+ ```
101
+
102
+ The opencode server starts in the main repo directory; individual sessions point at the worktree. This lets opencode see the full project context while the agent modifies only the isolated branch.
103
+
104
+ ### Iteration memory
105
+
106
+ The work phase uses a `notes.md` file to carry context across iterations. Each iteration the agent reads prior notes, does one incremental piece of work, and appends its summary. Failed iterations still record learnings.
107
+
108
+ ## Configuration
109
+
110
+ `~/.kaizen/config.json` (created automatically on first run):
111
+
112
+ ```json
113
+ {
114
+ "max_work_iterations": null,
115
+ "max_review_rounds": 3,
116
+ "max_consecutive_failures": 3,
117
+ "opencode_bin": "opencode",
118
+ "use_worktree": true,
119
+ "server_url": null
120
+ }
121
+ ```
122
+
123
+ ### Shared server
124
+
125
+ By default each kaizen run spawns its own `opencode serve` process. For batch work, start a server once and reuse it across runs:
126
+
127
+ ```bash
128
+ opencode serve --hostname 127.0.0.1 --port 4096 &
129
+ kaizen "fix issue #1" --server-url http://127.0.0.1:4096
130
+ kaizen "fix issue #2" --server-url http://127.0.0.1:4096
131
+ ```
132
+
133
+ Or set it in config: `"server_url": "http://127.0.0.1:4096"`.
134
+
135
+ When `--server-url` is set, kaizen skips server startup/teardown — it creates and destroys sessions on the existing server instead.
136
+
137
+ **Checking for an existing server.** To find any running opencode servers on the machine (the TUI and `opencode serve` both start one):
138
+
139
+ ```bash
140
+ pgrep -a opencode
141
+ ```
142
+
143
+ To check a specific port (e.g. the default `4096`):
144
+
145
+ ```bash
146
+ curl -sf http://127.0.0.1:4096/global/health
147
+ ```
148
+
149
+ A `200` response means a server is running and ready. Note: the TUI starts its own internal server on a random port (unless overridden with `--port`).
150
+
151
+ **Closing the server.** When you're done, stop the background process:
152
+
153
+ ```bash
154
+ kill %1 # if launched with & in the current shell
155
+ ```
156
+
157
+ Or send `POST /instance/dispose` to shut the server down cleanly over HTTP:
158
+
159
+ ```bash
160
+ curl -X POST http://127.0.0.1:4096/instance/dispose
161
+ ```
162
+
163
+ Note: the TUI runs its own server internally — disposing it will also close the TUI. Only shut down a server you started yourself.
164
+
165
+ ## Project structure
166
+
167
+ ```
168
+ src/kaizen/
169
+ agent.py # opencode HTTP server integration
170
+ git.py # git operations
171
+ config.py # ~/.kaizen/config.json
172
+ run.py # run state + notes
173
+ orchestrator.py # work iteration loop
174
+ work_prompt.py # iteration prompt builder
175
+ findings.py # finding types and action classification
176
+ review_prompt.py # review + fix prompt builders
177
+ loop.py # coordinates work → review → fix → ship
178
+ cli.py # CLI entry point
179
+ steps/ # review, push, pr
180
+ ```
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,17 @@
1
+ build:
2
+ pip install -e .
3
+
4
+ test:
5
+ python -m pytest
6
+
7
+ lint:
8
+ python -m ruff check src/
9
+
10
+ fmt:
11
+ python -m ruff format src/
12
+
13
+ clean:
14
+ rm -rf build/ dist/ *.egg-info src/*.egg-info
15
+
16
+ run *ARGS:
17
+ python -m kaizen {{ARGS}}
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kaizen-loop"
7
+ version = "0.1.0"
8
+ description = "Continuous code improvement: autonomous work → review → fix → ship"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+
12
+ [project.optional-dependencies]
13
+ dev = ["pytest", "ruff"]
14
+
15
+ [project.scripts]
16
+ kaizen = "kaizen.cli:main"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/kaizen"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from kaizen.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,349 @@
1
+ import http.client
2
+ import json
3
+ import os
4
+ import signal
5
+ import socket
6
+ import subprocess
7
+ import time
8
+ from contextlib import contextmanager
9
+ from dataclasses import dataclass
10
+ from urllib.parse import quote, urlparse
11
+
12
+ _RETRYABLE_STATUS = frozenset({502, 503, 504})
13
+ _MAX_RETRIES = 2
14
+ _RETRY_BASE_DELAY = 0.5
15
+
16
+
17
+ def _validate_server_url(url: str | None) -> None:
18
+ if url is None:
19
+ return
20
+ parsed = urlparse(url)
21
+ if parsed.scheme not in ("http", "https"):
22
+ raise ValueError(
23
+ f"Invalid server URL: {url!r}. Must start with http:// or https://"
24
+ )
25
+ if not parsed.hostname:
26
+ raise ValueError(f"Invalid server URL: {url!r}. No hostname found.")
27
+
28
+
29
+ @dataclass
30
+ class AgentResult:
31
+ output: dict
32
+ text: str = ""
33
+ input_tokens: int = 0
34
+ output_tokens: int = 0
35
+
36
+
37
+ class _Session:
38
+ def __init__(self, agent: "OpenCodeAgent", session_id: str):
39
+ self._agent = agent
40
+ self._id = session_id
41
+
42
+ @property
43
+ def id(self) -> str:
44
+ return self._id
45
+
46
+ def send(self, prompt: str, schema: dict | None = None) -> AgentResult:
47
+ try:
48
+ return self._agent._send_message(self._id, prompt, schema)
49
+ except Exception:
50
+ self._agent._abort_session(self._id)
51
+ raise
52
+
53
+
54
+ class OpenCodeAgent:
55
+ def __init__(self, bin_path: str = "opencode", server_url: str | None = None):
56
+ _validate_server_url(server_url)
57
+ self.bin_path = bin_path
58
+ self._external_server = server_url
59
+ self._process: subprocess.Popen | None = None
60
+ self._base_url: str | None = server_url
61
+ self._port: int | None = None
62
+ self._conn: http.client.HTTPConnection | None = None
63
+
64
+ def _get_conn(self) -> http.client.HTTPConnection:
65
+ if self._conn is not None:
66
+ return self._conn
67
+ url = self._base_url
68
+ if not url:
69
+ raise RuntimeError("No server URL configured")
70
+ parsed = urlparse(url)
71
+ self._conn = http.client.HTTPConnection(
72
+ parsed.hostname or "127.0.0.1",
73
+ parsed.port or 80,
74
+ timeout=300,
75
+ )
76
+ return self._conn
77
+
78
+ def _reconnect(self) -> http.client.HTTPConnection:
79
+ self._close_conn()
80
+ return self._get_conn()
81
+
82
+ def _close_conn(self) -> None:
83
+ if self._conn:
84
+ try:
85
+ self._conn.close()
86
+ except Exception:
87
+ pass
88
+ self._conn = None
89
+
90
+ def _http_request(
91
+ self,
92
+ method: str,
93
+ path: str,
94
+ body: dict | None = None,
95
+ timeout: float | None = None,
96
+ max_retries: int = _MAX_RETRIES,
97
+ ) -> dict:
98
+ conn = self._get_conn()
99
+ if timeout is not None:
100
+ conn.timeout = timeout
101
+ headers = {"Accept": "application/json"}
102
+ data = None
103
+ if body is not None:
104
+ data = json.dumps(body).encode()
105
+ headers["Content-Type"] = "application/json"
106
+
107
+ last_err: Exception | None = None
108
+ for attempt in range(max_retries + 1):
109
+ try:
110
+ conn.request(method, path, body=data, headers=headers)
111
+ resp = conn.getresponse()
112
+ resp_data = resp.read()
113
+ if 200 <= resp.status < 300:
114
+ return json.loads(resp_data)
115
+ if resp.status in _RETRYABLE_STATUS and attempt < max_retries:
116
+ last_err = RuntimeError(f"HTTP {resp.status}")
117
+ time.sleep(_RETRY_BASE_DELAY * (2**attempt))
118
+ conn = self._reconnect()
119
+ continue
120
+ raise RuntimeError(
121
+ f"HTTP {resp.status}: {resp_data.decode(errors='replace')}"
122
+ )
123
+ except (
124
+ ConnectionError,
125
+ OSError,
126
+ http.client.HTTPException,
127
+ ) as e:
128
+ if attempt < max_retries:
129
+ last_err = e
130
+ time.sleep(_RETRY_BASE_DELAY * (2**attempt))
131
+ conn = self._reconnect()
132
+ continue
133
+ raise RuntimeError(
134
+ f"Request failed after {max_retries} retries: {e}"
135
+ ) from e
136
+ raise last_err # type: ignore[misc]
137
+
138
+ def _request(
139
+ self,
140
+ path: str,
141
+ method: str = "GET",
142
+ body: dict | None = None,
143
+ timeout: float = 300,
144
+ ) -> dict:
145
+ return self._http_request(method, path, body=body, timeout=timeout)
146
+
147
+ def _ensure_server(self, server_cwd: str) -> None:
148
+ if self._base_url:
149
+ self._check_external_server()
150
+ return
151
+
152
+ self._port = _get_free_port()
153
+ env = {**os.environ}
154
+ env.pop("OPENCODE_SERVER_USERNAME", None)
155
+ env.pop("OPENCODE_SERVER_PASSWORD", None)
156
+
157
+ self._process = subprocess.Popen(
158
+ [
159
+ self.bin_path,
160
+ "serve",
161
+ "--hostname",
162
+ "127.0.0.1",
163
+ "--port",
164
+ str(self._port),
165
+ "--print-logs",
166
+ ],
167
+ cwd=server_cwd,
168
+ stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.PIPE,
170
+ stderr=subprocess.PIPE,
171
+ env=env,
172
+ start_new_session=True,
173
+ )
174
+ self._base_url = f"http://127.0.0.1:{self._port}"
175
+ self._wait_healthy(timeout=30)
176
+
177
+ def _check_external_server(self) -> None:
178
+ url = self._base_url
179
+ if not url:
180
+ raise RuntimeError("No server URL configured")
181
+ try:
182
+ self._http_request(
183
+ "GET", "/global/health", timeout=5, max_retries=1
184
+ )
185
+ except (
186
+ RuntimeError,
187
+ ConnectionError,
188
+ OSError,
189
+ http.client.HTTPException,
190
+ ) as e:
191
+ port = url.split(":")[-1].rstrip("/")
192
+ raise RuntimeError(
193
+ f"Shared server at {url} is not reachable. "
194
+ f"Start it with: opencode serve --hostname 127.0.0.1 --port {port} | Error: {e}"
195
+ )
196
+
197
+ def _wait_healthy(self, timeout: float = 30) -> None:
198
+ deadline = time.monotonic() + timeout
199
+ while time.monotonic() < deadline:
200
+ if self._process and self._process.poll() is not None:
201
+ raise RuntimeError("opencode server exited during startup")
202
+ try:
203
+ conn = self._reconnect()
204
+ conn.timeout = 2
205
+ conn.request("GET", "/global/health")
206
+ resp = conn.getresponse()
207
+ resp.read()
208
+ if resp.status == 200:
209
+ return
210
+ except (ConnectionError, OSError, http.client.HTTPException):
211
+ pass
212
+ time.sleep(0.25)
213
+ raise RuntimeError(
214
+ f"opencode server did not become healthy on port {self._port}"
215
+ )
216
+
217
+ def _create_session(self, session_dir: str) -> str:
218
+ resp = self._request(
219
+ f"/session?directory={quote(session_dir, safe='')}",
220
+ method="POST",
221
+ body={},
222
+ timeout=10,
223
+ )
224
+ return resp.get("id", "")
225
+
226
+ @contextmanager
227
+ def session(self, work_dir: str, repo_dir: str | None = None):
228
+ server_cwd = repo_dir or work_dir
229
+ self._ensure_server(server_cwd)
230
+ session_id = self._create_session(work_dir)
231
+ try:
232
+ yield _Session(self, session_id)
233
+ finally:
234
+ self._delete_session(session_id)
235
+
236
+ def run(
237
+ self,
238
+ prompt: str,
239
+ work_dir: str,
240
+ schema: dict | None = None,
241
+ repo_dir: str | None = None,
242
+ ) -> AgentResult:
243
+ server_cwd = repo_dir or work_dir
244
+ self._ensure_server(server_cwd)
245
+ session_id = self._create_session(work_dir)
246
+ try:
247
+ return self._send_message(session_id, prompt, schema)
248
+ except Exception:
249
+ self._abort_session(session_id)
250
+ raise
251
+ finally:
252
+ self._delete_session(session_id)
253
+
254
+ def _send_message(
255
+ self, session_id: str, prompt: str, schema: dict | None = None
256
+ ) -> AgentResult:
257
+ body: dict = {
258
+ "role": "user",
259
+ "parts": [{"type": "text", "text": prompt}],
260
+ }
261
+ if schema:
262
+ body["format"] = {
263
+ "type": "json_schema",
264
+ "schema": schema,
265
+ "retryCount": 1,
266
+ }
267
+ result = self._request(
268
+ f"/session/{session_id}/message",
269
+ method="POST",
270
+ body=body,
271
+ timeout=600,
272
+ )
273
+
274
+ info = result.get("info", {})
275
+ structured = info.get("structured")
276
+ tokens = info.get("tokens", {})
277
+
278
+ if structured:
279
+ return AgentResult(
280
+ output=structured,
281
+ input_tokens=tokens.get("input", 0),
282
+ output_tokens=tokens.get("output", 0),
283
+ )
284
+
285
+ text = ""
286
+ for part in result.get("parts", []):
287
+ if part.get("type") == "text" and part.get("text"):
288
+ text = part["text"]
289
+
290
+ if not text:
291
+ raise RuntimeError("No structured output or text in agent response")
292
+
293
+ raise RuntimeError(f"Agent returned unstructured text: {text[:200]}")
294
+
295
+ def _abort_session(self, session_id: str) -> None:
296
+ try:
297
+ self._request(
298
+ f"/session/{session_id}/abort", method="POST", timeout=3
299
+ )
300
+ except Exception:
301
+ pass
302
+
303
+ def _delete_session(self, session_id: str) -> None:
304
+ try:
305
+ self._request(
306
+ f"/session/{session_id}", method="DELETE", timeout=3
307
+ )
308
+ except Exception:
309
+ pass
310
+
311
+ def close(self) -> None:
312
+ if self._external_server:
313
+ self._close_conn()
314
+ return
315
+ if self._base_url:
316
+ try:
317
+ self._http_request(
318
+ "POST", "/instance/dispose", timeout=5, max_retries=0
319
+ )
320
+ except Exception:
321
+ pass
322
+ self._close_conn()
323
+ if self._process and self._process.poll() is None:
324
+ try:
325
+ self._process.wait(timeout=5)
326
+ except subprocess.TimeoutExpired:
327
+ try:
328
+ os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
329
+ except OSError:
330
+ self._process.terminate()
331
+ try:
332
+ self._process.wait(timeout=5)
333
+ except subprocess.TimeoutExpired:
334
+ try:
335
+ os.killpg(
336
+ os.getpgid(self._process.pid), signal.SIGKILL
337
+ )
338
+ except OSError:
339
+ self._process.kill()
340
+ self._process.wait(timeout=2)
341
+ self._process = None
342
+ self._base_url = None
343
+ self._port = None
344
+
345
+
346
+ def _get_free_port() -> int:
347
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
348
+ s.bind(("127.0.0.1", 0))
349
+ return s.getsockname()[1]