pycastle 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.
pycastle/__init__.py ADDED
File without changes
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .config import DOCKERFILE, DOCKER_IMAGE_NAME
7
+ from .docker_service import DockerService
8
+ from .errors import DockerServiceError
9
+
10
+
11
+ def main(no_cache: bool = False, docker_service: DockerService | None = None) -> None:
12
+ if docker_service is None:
13
+ docker_service = DockerService()
14
+
15
+ python_version: str | None = None
16
+ python_version_file = Path(".python-version")
17
+ if python_version_file.exists():
18
+ version = python_version_file.read_text().strip()
19
+ parts = version.split(".")
20
+ python_version = ".".join(parts[:2]) if len(parts) >= 2 else version
21
+
22
+ try:
23
+ docker_service.build_image(
24
+ DOCKER_IMAGE_NAME,
25
+ DOCKERFILE,
26
+ Path("."),
27
+ no_cache=no_cache,
28
+ python_version=python_version,
29
+ )
30
+ except DockerServiceError as exc:
31
+ print(str(exc), file=sys.stderr)
32
+ sys.exit(1)
33
+
34
+ sys.exit(0)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from functools import lru_cache
5
+
6
+ from .errors import ClaudeCliNotFoundError
7
+
8
+ # claude CLI does not expose a list-models subcommand; this list is kept in sync manually.
9
+ _KNOWN_MODELS: tuple[str, ...] = (
10
+ "claude-haiku-4-5-20251001",
11
+ "claude-sonnet-4-6",
12
+ "claude-opus-4-7",
13
+ )
14
+
15
+
16
+ @lru_cache(maxsize=1)
17
+ def _list_models() -> tuple[str, ...]:
18
+ """Return known Claude model IDs, verifying the CLI is installed. Cached for the process lifetime."""
19
+ if shutil.which("claude") is None:
20
+ raise ClaudeCliNotFoundError(
21
+ "claude CLI not found; ensure it is installed and on PATH"
22
+ )
23
+ return _KNOWN_MODELS
24
+
25
+
26
+ class ClaudeService:
27
+ def list_models(self) -> tuple[str, ...]:
28
+ return _list_models()
pycastle/config.py ADDED
@@ -0,0 +1,34 @@
1
+ import importlib.util as _util
2
+ import sys as _sys
3
+ from pathlib import Path
4
+
5
+ from .defaults.config import ( # noqa: F401
6
+ DOCKERFILE,
7
+ DOCKER_IMAGE_NAME,
8
+ ENV_FILE,
9
+ HITL_LABEL,
10
+ IDLE_TIMEOUT,
11
+ IMPLEMENT_CHECKS,
12
+ ISSUE_LABEL,
13
+ LOGS_DIR,
14
+ MAX_ITERATIONS,
15
+ MAX_PARALLEL,
16
+ PREFLIGHT_CHECKS,
17
+ PROMPTS_DIR,
18
+ PYCASTLE_DIR,
19
+ STAGE_OVERRIDES,
20
+ USAGE_LIMIT_PATTERNS,
21
+ WORKTREE_TIMEOUT,
22
+ WORKTREES_DIR,
23
+ )
24
+
25
+ _local = Path(__file__).parent.parent.parent / "pycastle" / "config.py"
26
+ if _local.exists():
27
+ _spec = _util.spec_from_file_location("_pycastle_local_config", _local)
28
+ if _spec is not None and _spec.loader is not None:
29
+ _mod = _util.module_from_spec(_spec)
30
+ _spec.loader.exec_module(_mod)
31
+ _me = _sys.modules[__name__]
32
+ for _k, _v in vars(_mod).items():
33
+ if not _k.startswith("_"):
34
+ setattr(_me, _k, _v)
@@ -0,0 +1,465 @@
1
+ import asyncio
2
+ import io
3
+ import json
4
+ import os
5
+ import queue
6
+ import re
7
+ import shlex
8
+ import sys
9
+ import tarfile
10
+ import threading
11
+ from collections.abc import Callable
12
+ from contextlib import AsyncExitStack
13
+ from pathlib import Path
14
+
15
+ import docker
16
+ from docker.models.containers import Container as DockerContainer
17
+
18
+ from .config import (
19
+ DOCKER_IMAGE_NAME,
20
+ IDLE_TIMEOUT,
21
+ LOGS_DIR,
22
+ PREFLIGHT_CHECKS,
23
+ PYCASTLE_DIR,
24
+ USAGE_LIMIT_PATTERNS,
25
+ )
26
+ from .errors import (
27
+ AgentTimeoutError,
28
+ BranchCollisionError,
29
+ DockerError,
30
+ DockerTimeoutError,
31
+ PreflightError,
32
+ UsageLimitError,
33
+ )
34
+ from .git_service import GitService
35
+ from .worktree import (
36
+ CONTAINER_PARENT_GIT,
37
+ create_worktree,
38
+ patch_gitdir_for_container,
39
+ remove_worktree,
40
+ )
41
+
42
+ _branch_locks: dict[str, asyncio.Lock] = {}
43
+ _usage_limit_halt: asyncio.Event = asyncio.Event()
44
+
45
+
46
+ def reset_usage_limit_flag() -> None:
47
+ _usage_limit_halt.clear()
48
+
49
+
50
+ def _is_usage_limit_line(line: str) -> bool:
51
+ """Return True only if line is a plain-text usage-limit message, not JSON."""
52
+ try:
53
+ if isinstance(json.loads(line), dict):
54
+ return False
55
+ except json.JSONDecodeError:
56
+ pass
57
+ line_lower = line.lower()
58
+ return any(p.lower() in line_lower for p in USAGE_LIMIT_PATTERNS)
59
+
60
+
61
+ def _format_stream_line(line: str) -> str | None:
62
+ """Return a human-readable summary of a stream-json line, or None to suppress it."""
63
+ try:
64
+ obj = json.loads(line)
65
+ except json.JSONDecodeError:
66
+ return line
67
+ if not isinstance(obj, dict):
68
+ return line
69
+ line_type = obj.get("type")
70
+ if line_type == "system":
71
+ return None
72
+ if line_type == "result":
73
+ return None
74
+ if line_type == "assistant":
75
+ content = (obj.get("message") or {}).get("content", [])
76
+ parts: list[str] = []
77
+ for block in content:
78
+ if not isinstance(block, dict):
79
+ continue
80
+ if block.get("type") == "text":
81
+ text = block.get("text", "").strip()
82
+ if text:
83
+ parts.append(text)
84
+ elif block.get("type") == "tool_use":
85
+ parts.append(f"(tool: {block.get('name', 'unknown')})")
86
+ return " ".join(parts) if parts else None
87
+ return None
88
+
89
+
90
+ def _build_claude_command(model: str = "", effort: str = "") -> str:
91
+ flags = "--verbose --dangerously-skip-permissions --output-format stream-json -p -"
92
+ if model:
93
+ flags += f" --model {model}"
94
+ if effort:
95
+ flags += f" --effort {effort}"
96
+ return f"claude {flags} < /tmp/.pycastle_prompt"
97
+
98
+
99
+ class ContainerRunner:
100
+ def __init__(
101
+ self,
102
+ name: str,
103
+ mount_path: Path,
104
+ env: dict[str, str],
105
+ branch: str | None = None,
106
+ worktree_host_path: Path | None = None,
107
+ gitdir_overlay: Path | None = None,
108
+ model: str = "",
109
+ effort: str = "",
110
+ docker_client=None,
111
+ ):
112
+ self.name = name
113
+ self.mount_path = mount_path
114
+ self.env = env
115
+ self.branch = branch
116
+ self.worktree_host_path = worktree_host_path
117
+ self.gitdir_overlay = gitdir_overlay
118
+ self.model = model
119
+ self.effort = effort
120
+ self._client = docker_client if docker_client is not None else docker.from_env()
121
+ self._container: DockerContainer | None = None
122
+ self._container_env: dict[str, str] = {}
123
+ self._prompt: str = ""
124
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
125
+ self._log_path = LOGS_DIR / f"{slug}.log"
126
+ self._worktree_path = "/home/agent/workspace"
127
+
128
+ @property
129
+ def _active_container(self) -> DockerContainer:
130
+ if self._container is None:
131
+ raise DockerError("Container not started")
132
+ return self._container
133
+
134
+ def __enter__(self) -> "ContainerRunner":
135
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
136
+ repo_path = str(self.mount_path.resolve()).replace("\\", "/")
137
+
138
+ if self.worktree_host_path:
139
+ worktree_path = str(self.worktree_host_path.resolve()).replace("\\", "/")
140
+ parent_git_path = str((self.mount_path / ".git").resolve()).replace(
141
+ "\\", "/"
142
+ )
143
+ volumes = {
144
+ worktree_path: {"bind": "/home/agent/workspace", "mode": "rw"},
145
+ repo_path: {"bind": "/home/agent/repo", "mode": "ro"},
146
+ parent_git_path: {"bind": CONTAINER_PARENT_GIT, "mode": "rw"},
147
+ }
148
+ if self.gitdir_overlay:
149
+ overlay_path = str(self.gitdir_overlay.resolve()).replace("\\", "/")
150
+ volumes[overlay_path] = {
151
+ "bind": "/home/agent/workspace/.git",
152
+ "mode": "ro",
153
+ }
154
+ else:
155
+ volumes = {repo_path: {"bind": "/home/agent/workspace", "mode": "rw"}}
156
+ working_dir = "/home/agent/workspace"
157
+
158
+ # CLAUDE_ACCOUNT_JSON is written as a file inside the container, not passed as env var
159
+ self._container_env = {
160
+ k: v for k, v in self.env.items() if k != "CLAUDE_ACCOUNT_JSON"
161
+ }
162
+
163
+ self._container = self._client.containers.run(
164
+ DOCKER_IMAGE_NAME,
165
+ detach=True,
166
+ volumes=volumes,
167
+ environment=self._container_env,
168
+ working_dir=working_dir,
169
+ )
170
+
171
+ claude_json = self.env.get("CLAUDE_ACCOUNT_JSON")
172
+ if claude_json:
173
+ self.write_file(claude_json, "/home/agent/.claude.json")
174
+
175
+ return self
176
+
177
+ def __exit__(self, *_):
178
+ if self._container:
179
+ try:
180
+ self._container.stop(timeout=5)
181
+ except Exception:
182
+ pass
183
+ try:
184
+ self._container.remove(force=True)
185
+ except Exception:
186
+ pass
187
+
188
+ def exec_simple(self, command: str, timeout: float | None = None) -> str:
189
+ import threading
190
+
191
+ container = self._active_container
192
+ result_holder: list = [None]
193
+ exc_holder: list = [None]
194
+
195
+ def _run():
196
+ try:
197
+ result_holder[0] = container.exec_run(
198
+ ["bash", "-c", command],
199
+ demux=True,
200
+ workdir=self._worktree_path,
201
+ environment=self.env,
202
+ )
203
+ except Exception as exc:
204
+ exc_holder[0] = exc
205
+
206
+ thread = threading.Thread(target=_run, daemon=True)
207
+ thread.start()
208
+ thread.join(timeout=timeout)
209
+
210
+ if thread.is_alive():
211
+ raise DockerTimeoutError(f"Command timed out after {timeout}s: {command}")
212
+
213
+ if exc_holder[0]:
214
+ raise exc_holder[0]
215
+
216
+ result = result_holder[0]
217
+ stdout = (result.output[0] or b"").decode("utf-8", errors="replace")
218
+ stderr = (result.output[1] or b"").decode("utf-8", errors="replace")
219
+ if result.exit_code != 0:
220
+ raise DockerError(
221
+ f"Command failed (exit {result.exit_code}): {stderr.strip() or stdout.strip()}"
222
+ )
223
+ if stderr and not stdout:
224
+ print(f" [exec stderr] {stderr.strip()}", file=sys.stderr)
225
+ return stdout
226
+
227
+ def write_file(self, content: str, container_path: str):
228
+ data = content.encode("utf-8")
229
+ buf = io.BytesIO()
230
+ with tarfile.open(fileobj=buf, mode="w") as tar:
231
+ info = tarfile.TarInfo(name=os.path.basename(container_path))
232
+ info.size = len(data)
233
+ tar.addfile(info, io.BytesIO(data))
234
+ buf.seek(0)
235
+ self._active_container.put_archive(os.path.dirname(container_path), buf)
236
+
237
+ def run_streaming(self) -> str:
238
+ self.write_file(self._prompt, "/tmp/.pycastle_prompt")
239
+ result = self._active_container.exec_run(
240
+ ["bash", "-c", _build_claude_command(model=self.model, effort=self.effort)],
241
+ stream=True,
242
+ workdir=self._worktree_path,
243
+ )
244
+
245
+ q: queue.Queue = queue.Queue()
246
+ _sentinel = object()
247
+
248
+ def _feed():
249
+ try:
250
+ for chunk in result.output:
251
+ q.put(chunk)
252
+ finally:
253
+ q.put(_sentinel)
254
+
255
+ threading.Thread(target=_feed, daemon=True).start()
256
+
257
+ parts: list[str] = []
258
+ line_buf = ""
259
+ try:
260
+ with open(self._log_path, "wb") as log:
261
+ while True:
262
+ try:
263
+ chunk = q.get(timeout=IDLE_TIMEOUT)
264
+ except queue.Empty:
265
+ raise AgentTimeoutError(
266
+ f"Agent idle for more than {IDLE_TIMEOUT}s"
267
+ )
268
+ if chunk is _sentinel:
269
+ break
270
+ log.write(chunk)
271
+ log.flush()
272
+ text = chunk.decode("utf-8", errors="replace")
273
+ parts.append(text)
274
+ line_buf += text
275
+ while "\n" in line_buf:
276
+ line, line_buf = line_buf.split("\n", 1)
277
+ if _is_usage_limit_line(line):
278
+ raise UsageLimitError(line)
279
+ formatted = _format_stream_line(line)
280
+ if formatted is not None:
281
+ print(f"[{self.name}] {formatted}", flush=True)
282
+ finally:
283
+ try:
284
+ self._active_container.exec_run(
285
+ ["bash", "-c", "rm -f /tmp/.pycastle_prompt"],
286
+ workdir=self._worktree_path,
287
+ )
288
+ except Exception:
289
+ pass
290
+ return "".join(parts)
291
+
292
+
293
+ async def _preflight(
294
+ name: str,
295
+ runner: "ContainerRunner",
296
+ loop: asyncio.AbstractEventLoop,
297
+ exec_timeout: float | None,
298
+ checks: list[tuple[str, str]],
299
+ ) -> list[tuple[str, str, str]]:
300
+ print(f"[{name}] Phase: Pre-flight")
301
+ failures: list[tuple[str, str, str]] = []
302
+ for check_name, command in checks:
303
+ try:
304
+ await loop.run_in_executor(None, runner.exec_simple, command, exec_timeout)
305
+ except DockerError as exc:
306
+ failures.append((check_name, command, str(exc)))
307
+ return failures
308
+
309
+
310
+ async def _setup(
311
+ name: str,
312
+ runner: "ContainerRunner",
313
+ loop: asyncio.AbstractEventLoop,
314
+ exec_timeout: float | None,
315
+ git_service: GitService | None = None,
316
+ ) -> None:
317
+ print(f"[{name}] Phase: Setup")
318
+ await loop.run_in_executor(None, runner.__enter__)
319
+ if git_service is None:
320
+ git_service = GitService()
321
+ git_name = git_service.get_user_name()
322
+ git_email = git_service.get_user_email()
323
+ await loop.run_in_executor(
324
+ None,
325
+ runner.exec_simple,
326
+ f"git config --global user.name {shlex.quote(git_name)}",
327
+ exec_timeout,
328
+ )
329
+ await loop.run_in_executor(
330
+ None,
331
+ runner.exec_simple,
332
+ f"git config --global user.email {shlex.quote(git_email)}",
333
+ exec_timeout,
334
+ )
335
+
336
+
337
+ async def _prepare(
338
+ name: str,
339
+ runner: "ContainerRunner",
340
+ loop: asyncio.AbstractEventLoop,
341
+ exec_timeout: float | None,
342
+ prompt_file: Path,
343
+ prompt_args: dict[str, str],
344
+ ) -> None:
345
+ from .prompt_pipeline import prepare_prompt
346
+
347
+ print(f"[{name}] Phase: Prepare")
348
+ try:
349
+ await loop.run_in_executor(
350
+ None,
351
+ runner.exec_simple,
352
+ "pip install -e '.[dev]' || pip install -r requirements.txt",
353
+ exec_timeout,
354
+ )
355
+ except RuntimeError as exc:
356
+ print(f" [{name}] Warning: dependency install skipped: {exc}", file=sys.stderr)
357
+
358
+ async def container_exec(cmd: str) -> str:
359
+ return await loop.run_in_executor(None, runner.exec_simple, cmd, exec_timeout)
360
+
361
+ prompt = await prepare_prompt(prompt_file, prompt_args, container_exec)
362
+ runner._prompt = prompt
363
+
364
+
365
+ async def _work(
366
+ name: str,
367
+ runner: "ContainerRunner",
368
+ loop: asyncio.AbstractEventLoop,
369
+ ) -> str:
370
+ print(f"[{name}] Phase: Work")
371
+ return await loop.run_in_executor(None, runner.run_streaming)
372
+
373
+
374
+ async def run_agent(
375
+ name: str,
376
+ prompt_file: Path,
377
+ mount_path: Path,
378
+ env: dict[str, str],
379
+ prompt_args: dict[str, str] | None = None,
380
+ branch: str | None = None,
381
+ exec_timeout: float | None = None,
382
+ skip_preflight: bool = False,
383
+ model: str = "",
384
+ effort: str = "",
385
+ git_service: GitService | None = None,
386
+ stage: str = "",
387
+ sha: str | None = None,
388
+ create_worktree_fn: Callable[[Path, Path, str, str | None], None] = create_worktree,
389
+ remove_worktree_fn: Callable[[Path, Path], None] = remove_worktree,
390
+ ) -> str:
391
+ if _usage_limit_halt.is_set():
392
+ return ""
393
+
394
+ print(f"\n[{name}] Started")
395
+
396
+ lock: asyncio.Lock | None = None
397
+ if branch:
398
+ if branch not in _branch_locks:
399
+ _branch_locks[branch] = asyncio.Lock()
400
+ lock = _branch_locks[branch]
401
+ if lock.locked():
402
+ raise BranchCollisionError(
403
+ f"Branch {branch!r} already has an agent running"
404
+ )
405
+ await lock.acquire()
406
+
407
+ worktree_host_path: Path | None = None
408
+ gitdir_overlay: Path | None = None
409
+ try:
410
+ if branch:
411
+ m = re.search(r"issue-(\d+)", branch)
412
+ worktree_name = (
413
+ f"issue-{m.group(1)}"
414
+ if m
415
+ else re.sub(r"[^a-z0-9]+", "-", branch.lower()).strip("-")
416
+ )
417
+ worktree_host_path = (
418
+ mount_path / PYCASTLE_DIR / ".worktrees" / worktree_name
419
+ )
420
+ create_worktree_fn(mount_path, worktree_host_path, branch, sha)
421
+ gitdir_overlay = patch_gitdir_for_container(worktree_host_path)
422
+
423
+ loop = asyncio.get_event_loop()
424
+ runner = ContainerRunner(
425
+ name,
426
+ mount_path,
427
+ env,
428
+ branch=branch,
429
+ worktree_host_path=worktree_host_path,
430
+ gitdir_overlay=gitdir_overlay,
431
+ model=model,
432
+ effort=effort,
433
+ )
434
+ preserve_worktree = [False]
435
+
436
+ async with AsyncExitStack() as stack:
437
+ if gitdir_overlay:
438
+ stack.callback(gitdir_overlay.unlink, missing_ok=True)
439
+ if worktree_host_path:
440
+
441
+ def _cond_remove_worktree():
442
+ if not preserve_worktree[0]:
443
+ remove_worktree_fn(mount_path, worktree_host_path)
444
+
445
+ stack.callback(_cond_remove_worktree)
446
+ stack.callback(runner.__exit__, None, None, None)
447
+ await _setup(name, runner, loop, exec_timeout, git_service)
448
+ await _prepare(
449
+ name, runner, loop, exec_timeout, prompt_file, prompt_args or {}
450
+ )
451
+ if not skip_preflight:
452
+ failures = await _preflight(
453
+ name, runner, loop, exec_timeout, PREFLIGHT_CHECKS
454
+ )
455
+ if failures:
456
+ raise PreflightError(failures)
457
+ try:
458
+ return await _work(name, runner, loop)
459
+ except UsageLimitError:
460
+ _usage_limit_halt.set()
461
+ preserve_worktree[0] = True
462
+ raise
463
+ finally:
464
+ if lock is not None and lock.locked():
465
+ lock.release()
@@ -0,0 +1,3 @@
1
+ .env
2
+ .worktrees/
3
+ logs/
@@ -0,0 +1,34 @@
1
+ ARG PYTHON_VERSION=3.12
2
+ FROM python:${PYTHON_VERSION}-slim-bookworm
3
+
4
+ # Install system dependencies
5
+ RUN apt-get update && apt-get install -y \
6
+ git \
7
+ curl \
8
+ jq \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Install GitHub CLI
12
+ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
13
+ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
14
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
15
+ | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
16
+ && apt-get update && apt-get install -y gh \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Create agent user with UID 1000 for compatibility with --userns=keep-id and --user 1000:1000
20
+ RUN useradd -m -u 1000 -s /bin/bash agent
21
+ USER agent
22
+
23
+ # Trust any bind-mounted directory regardless of host ownership
24
+ RUN git config --global --add safe.directory '*'
25
+
26
+ # Install Claude Code CLI
27
+ RUN curl -fsSL https://claude.ai/install.sh | bash
28
+
29
+ # Add Claude to PATH
30
+ ENV PATH="/home/agent/.local/bin:$PATH"
31
+
32
+ WORKDIR /home/agent
33
+
34
+ ENTRYPOINT ["sleep", "infinity"]
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+
3
+ # --- Behaviour ---
4
+ MAX_ITERATIONS = 10
5
+ MAX_PARALLEL = 1
6
+ WORKTREE_TIMEOUT = 30
7
+ IDLE_TIMEOUT = 300
8
+
9
+ # --- Docker ---
10
+ DOCKER_IMAGE_NAME = ""
11
+
12
+ # --- Labels ---
13
+ ISSUE_LABEL = "ready-for-agent"
14
+ HITL_LABEL = "ready-for-human"
15
+
16
+ # --- Paths ---
17
+ PYCASTLE_DIR = Path("pycastle")
18
+ PROMPTS_DIR = Path("pycastle/prompts")
19
+ LOGS_DIR = Path("pycastle/logs")
20
+ WORKTREES_DIR = Path("worktrees")
21
+ ENV_FILE = Path("pycastle/.env")
22
+ DOCKERFILE = Path("pycastle/Dockerfile")
23
+
24
+ # --- Checks ---
25
+ PREFLIGHT_CHECKS: list[tuple[str, str]] = [
26
+ ("ruff", "ruff check ."),
27
+ ("mypy", "mypy ."),
28
+ ("pytest", "pytest"),
29
+ ]
30
+
31
+ IMPLEMENT_CHECKS: list[str] = [
32
+ "ruff check --fix",
33
+ "ruff format --check",
34
+ "mypy .",
35
+ "pytest",
36
+ ]
37
+
38
+ # --- Usage limit detection ---
39
+ USAGE_LIMIT_PATTERNS: list[str] = ["You've hit your", "Credit balance is too low"]
40
+
41
+ # --- Stage overrides ---
42
+ STAGE_OVERRIDES: dict[str, dict[str, str]] = {
43
+ "plan": {"model": "", "effort": ""},
44
+ "implement": {"model": "", "effort": ""},
45
+ "review": {"model": "", "effort": ""},
46
+ "merge": {"model": "", "effort": ""},
47
+ }