codemate-team 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.
codemate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Codemate sequential AI team runner."""
2
+
3
+ __version__ = "0.1.0"
codemate/agents.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AgentRunInput:
13
+ run_id: str
14
+ step_id: str
15
+ cwd: Path
16
+ prompt: str
17
+ mode: str
18
+ expected_output: str
19
+ output_path: Path
20
+ raw_log_path: Path
21
+ schema_path: Path | None
22
+ config: dict[str, Any]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AgentRunResult:
27
+ ok: bool
28
+ output_path: Path
29
+ raw_log_path: Path
30
+ exit_code: int
31
+
32
+
33
+ class AgentAdapter:
34
+ def run(self, input: AgentRunInput) -> AgentRunResult:
35
+ raise NotImplementedError
36
+
37
+
38
+ class CodexCliAdapter(AgentAdapter):
39
+ def run(self, input: AgentRunInput) -> AgentRunResult:
40
+ command = str(input.config.get("command", "codex"))
41
+ _require_executable(command)
42
+ args = [
43
+ command,
44
+ "exec",
45
+ "--cd",
46
+ str(input.cwd),
47
+ "--sandbox",
48
+ str(input.config.get("sandbox", "workspace-write")),
49
+ "--ask-for-approval",
50
+ str(input.config.get("approval", "never")),
51
+ "--output-last-message",
52
+ str(input.output_path),
53
+ ]
54
+ if input.schema_path and input.schema_path.exists():
55
+ args.extend(["--output-schema", str(input.schema_path)])
56
+ args.append(input.prompt)
57
+ return _run_process(args, input)
58
+
59
+
60
+ class ClaudeCodeAdapter(AgentAdapter):
61
+ def run(self, input: AgentRunInput) -> AgentRunResult:
62
+ command = str(input.config.get("command", "claude"))
63
+ _require_executable(command)
64
+ permission_mode = _claude_permission_mode(input.mode, input.config)
65
+ output_format = str(input.config.get("output_format", "text"))
66
+ args = [command, "-p", input.prompt, "--permission-mode", permission_mode]
67
+ if output_format != "text":
68
+ args.extend(["--output-format", output_format])
69
+ result = _run_process(args, input)
70
+ # Claude Code streams its final answer to stdout (captured in the raw log)
71
+ # rather than to a dedicated output file, so derive the output ourselves.
72
+ if not input.output_path.read_text(errors="replace").strip():
73
+ raw = input.raw_log_path.read_text(errors="replace")
74
+ input.output_path.write_text(_extract_claude_message(raw, output_format))
75
+ return result
76
+
77
+
78
+ def adapter_for(config: dict[str, Any]) -> AgentAdapter:
79
+ provider = str(config.get("provider", "")).lower()
80
+ if provider == "codex-cli":
81
+ return CodexCliAdapter()
82
+ if provider == "claude-code":
83
+ return ClaudeCodeAdapter()
84
+ raise ValueError(f"Unsupported agent provider: {provider}")
85
+
86
+
87
+ def _extract_claude_message(raw: str, output_format: str) -> str:
88
+ """Pull the assistant's final message out of captured Claude Code output.
89
+
90
+ With `--output-format json` the CLI emits a result envelope; extract its
91
+ `result` field. Anything else (including malformed JSON) falls back to the
92
+ raw captured text so no output is ever silently dropped.
93
+ """
94
+ if output_format == "json":
95
+ try:
96
+ data = json.loads(raw.strip() or "{}")
97
+ except json.JSONDecodeError:
98
+ return raw
99
+ if isinstance(data, dict) and isinstance(data.get("result"), str):
100
+ return data["result"]
101
+ return raw
102
+
103
+
104
+ def _claude_permission_mode(mode: str, config: dict[str, Any]) -> str:
105
+ if mode in {"read_only", "review_only"}:
106
+ return "plan"
107
+ if mode == "write":
108
+ return str(config.get("write_permission_mode", "acceptEdits"))
109
+ return str(config.get("default_mode", "plan"))
110
+
111
+
112
+ def _run_process(args: list[str], input: AgentRunInput) -> AgentRunResult:
113
+ timeout = int(input.config.get("timeout_seconds", 900))
114
+ with input.raw_log_path.open("w") as raw_log:
115
+ try:
116
+ result = subprocess.run(
117
+ args,
118
+ cwd=input.cwd,
119
+ text=True,
120
+ stdout=raw_log,
121
+ stderr=subprocess.STDOUT,
122
+ timeout=timeout,
123
+ check=False,
124
+ )
125
+ exit_code = result.returncode
126
+ except subprocess.TimeoutExpired:
127
+ raw_log.write(f"\n[timeout after {timeout} seconds]\n")
128
+ exit_code = 124
129
+ if not input.output_path.exists():
130
+ input.output_path.write_text("")
131
+ return AgentRunResult(
132
+ ok=exit_code == 0,
133
+ output_path=input.output_path,
134
+ raw_log_path=input.raw_log_path,
135
+ exit_code=exit_code,
136
+ )
137
+
138
+
139
+ def _require_executable(command: str) -> None:
140
+ if shutil.which(command) is None:
141
+ raise FileNotFoundError(f"Required agent command not found: {command}")
codemate/artifacts.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def new_run_id() -> str:
12
+ return datetime.now().strftime("%Y-%m-%dT%H-%M-%S-%f")
13
+
14
+
15
+ def latest_run_dir(root: Path) -> Path:
16
+ runs = root / ".team" / "runs"
17
+ candidates = sorted([path for path in runs.glob("*") if path.is_dir()])
18
+ if not candidates:
19
+ raise FileNotFoundError("No runs found")
20
+ return candidates[-1]
21
+
22
+
23
+ @dataclass
24
+ class RunState:
25
+ run_id: str
26
+ task: str
27
+ flow: str
28
+ status: str
29
+ branch: str
30
+ base_branch: str
31
+ base_head: str
32
+ current_step: str | None = None
33
+ steps: list[dict[str, Any]] = field(default_factory=list)
34
+ changed_files: list[str] = field(default_factory=list)
35
+ reason: str | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "run_id": self.run_id,
40
+ "task": self.task,
41
+ "flow": self.flow,
42
+ "status": self.status,
43
+ "branch": self.branch,
44
+ "base_branch": self.base_branch,
45
+ "base_head": self.base_head,
46
+ "current_step": self.current_step,
47
+ "steps": self.steps,
48
+ "changed_files": self.changed_files,
49
+ "reason": self.reason,
50
+ }
51
+
52
+ @classmethod
53
+ def from_path(cls, path: Path) -> "RunState":
54
+ data = json.loads(path.read_text())
55
+ return cls(**data)
56
+
57
+
58
+ class RunArtifacts:
59
+ def __init__(self, root: Path, run_id: str) -> None:
60
+ self.root = root
61
+ self.run_id = run_id
62
+ self.dir = root / ".team" / "runs" / run_id
63
+ self.dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ @property
66
+ def state_path(self) -> Path:
67
+ return self.dir / "state.json"
68
+
69
+ def write_state(self, state: RunState) -> None:
70
+ self.state_path.write_text(json.dumps(state.to_dict(), indent=2) + "\n")
71
+
72
+ def write_text(self, name: str, content: str) -> Path:
73
+ path = self.dir / name
74
+ path.write_text(content)
75
+ return path
76
+
77
+ def read_text(self, name: str, default: str = "") -> str:
78
+ path = self.dir / name
79
+ return path.read_text() if path.exists() else default
80
+
81
+
82
+ class RunLock:
83
+ def __init__(self, root: Path, run_id: str) -> None:
84
+ self.path = root / ".team" / "lock.json"
85
+ self.run_id = run_id
86
+
87
+ def __enter__(self) -> "RunLock":
88
+ if self.path.exists():
89
+ if self._is_stale():
90
+ self.path.unlink()
91
+ else:
92
+ raise RuntimeError(f"Another codemate run appears active: {self.path}")
93
+ self.path.parent.mkdir(parents=True, exist_ok=True)
94
+ self.path.write_text(
95
+ json.dumps(
96
+ {
97
+ "run_id": self.run_id,
98
+ "pid": os.getpid(),
99
+ "created_at": datetime.now().isoformat(timespec="seconds"),
100
+ },
101
+ indent=2,
102
+ )
103
+ + "\n"
104
+ )
105
+ return self
106
+
107
+ def _is_stale(self) -> bool:
108
+ try:
109
+ data = json.loads(self.path.read_text())
110
+ except (OSError, json.JSONDecodeError):
111
+ return False
112
+ pid = data.get("pid")
113
+ if not isinstance(pid, int) or pid <= 0:
114
+ return False
115
+ try:
116
+ os.kill(pid, 0)
117
+ except ProcessLookupError:
118
+ return True
119
+ except PermissionError:
120
+ return False
121
+ return False
122
+
123
+ def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
124
+ if self.path.exists():
125
+ self.path.unlink()
codemate/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import shutil
5
+ import shlex
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .artifacts import RunState, latest_run_dir
10
+ from .config import init_project, load_config
11
+ from .git_tools import add_and_commit, changed_files, diff, ensure_repo, restore_paths
12
+ from .workflow import run_task
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(prog="codemate")
17
+ parser.add_argument("--root", default=".", help="Project root")
18
+ subparsers = parser.add_subparsers(dest="command", required=True)
19
+
20
+ init_parser = subparsers.add_parser("init", help="Create team.yml and .team files")
21
+ init_parser.add_argument("--force", action="store_true")
22
+
23
+ doctor_parser = subparsers.add_parser("doctor", help="Check local harness dependencies")
24
+ doctor_parser.add_argument("--flow")
25
+
26
+ run_parser = subparsers.add_parser("run", help="Run a task through the configured flow")
27
+ run_parser.add_argument("task", nargs="?")
28
+ run_parser.add_argument("--file")
29
+ run_parser.add_argument("--flow")
30
+
31
+ status_parser = subparsers.add_parser("status", help="Show run status")
32
+ status_parser.add_argument("--run-id")
33
+
34
+ logs_parser = subparsers.add_parser("logs", help="Show run logs")
35
+ logs_parser.add_argument("--run-id")
36
+ logs_parser.add_argument("--step")
37
+
38
+ subparsers.add_parser("diff", help="Show current git diff")
39
+
40
+ accept_parser = subparsers.add_parser("accept", help="Accept current run changes")
41
+ accept_parser.add_argument("--commit", action="store_true")
42
+ accept_parser.add_argument("--message", default=None)
43
+
44
+ reset_parser = subparsers.add_parser("reset", help="Reset files changed by latest run")
45
+ reset_parser.add_argument("--run-id")
46
+
47
+ args = parser.parse_args(argv)
48
+ root = Path(args.root).resolve()
49
+
50
+ try:
51
+ if args.command == "init":
52
+ return _init(root, args.force)
53
+ if args.command == "doctor":
54
+ return _doctor(root, args.flow)
55
+ if args.command == "run":
56
+ task = _read_task(args)
57
+ config = load_config(root)
58
+ state = run_task(config, task, args.flow)
59
+ print(_format_state(state))
60
+ print(f"Run artifacts: .team/runs/{state.run_id}")
61
+ return 0
62
+ if args.command == "status":
63
+ state = _load_state(root, args.run_id)
64
+ print(_format_state(state))
65
+ return 0
66
+ if args.command == "logs":
67
+ return _logs(root, args.run_id, args.step)
68
+ if args.command == "diff":
69
+ ensure_repo(root)
70
+ print(diff(root), end="")
71
+ return 0
72
+ if args.command == "accept":
73
+ return _accept(root, args.commit, args.message)
74
+ if args.command == "reset":
75
+ return _reset(root, args.run_id)
76
+ except Exception as exc:
77
+ print(f"codemate: error: {exc}", file=sys.stderr)
78
+ return 1
79
+ return 1
80
+
81
+
82
+ def _init(root: Path, force: bool) -> int:
83
+ created = init_project(root, force=force)
84
+ if not created:
85
+ print("Team configuration is up to date.")
86
+ return 0
87
+ print("Created team configuration:")
88
+ for path in created:
89
+ print(f"- {path.relative_to(root)}")
90
+ return 0
91
+
92
+
93
+ def _doctor(root: Path, flow: str | None) -> int:
94
+ config = load_config(root)
95
+ ensure_repo(root)
96
+ missing: list[str] = []
97
+ warnings: list[str] = []
98
+ seen: set[str] = set()
99
+ for step in config.flow_steps(flow):
100
+ if step.get("type") != "agent":
101
+ if step.get("type") == "command":
102
+ group = str(step.get("command_group"))
103
+ commands = config.command_group(group)
104
+ if not commands:
105
+ warnings.append(f"command group `{group}` is empty")
106
+ for command in commands:
107
+ executable = _first_command_token(command)
108
+ if executable and shutil.which(executable) is None:
109
+ missing.append(f"command group `{group}`: {executable}")
110
+ continue
111
+ agent_name = str(step.get("agent"))
112
+ if agent_name in seen:
113
+ continue
114
+ seen.add(agent_name)
115
+ command = str(config.agent(agent_name).get("command", agent_name))
116
+ if shutil.which(command) is None:
117
+ missing.append(f"{agent_name}: {command}")
118
+ if missing:
119
+ print("Missing agent commands:")
120
+ for item in missing:
121
+ print(f"- {item}")
122
+ return 1
123
+ if warnings:
124
+ print("Doctor passed with warnings:")
125
+ for item in warnings:
126
+ print(f"- {item}")
127
+ return 0
128
+ print("Doctor passed")
129
+ return 0
130
+
131
+
132
+ def _read_task(args: argparse.Namespace) -> str:
133
+ if args.file:
134
+ return Path(args.file).read_text().strip()
135
+ if args.task:
136
+ return args.task.strip()
137
+ raise ValueError("Provide a task string or --file")
138
+
139
+
140
+ def _load_state(root: Path, run_id: str | None) -> RunState:
141
+ run_dir = root / ".team" / "runs" / run_id if run_id else latest_run_dir(root)
142
+ return RunState.from_path(run_dir / "state.json")
143
+
144
+
145
+ def _logs(root: Path, run_id: str | None, step: str | None) -> int:
146
+ run_dir = root / ".team" / "runs" / run_id if run_id else latest_run_dir(root)
147
+ state = RunState.from_path(run_dir / "state.json")
148
+ matches = state.steps
149
+ if step:
150
+ matches = [item for item in matches if item.get("id") == step]
151
+ if not matches:
152
+ print("No matching logs")
153
+ return 1
154
+ for item in matches:
155
+ path_value = item.get("raw_log") or item.get("log") or item.get("output")
156
+ if not path_value:
157
+ continue
158
+ path = root / str(path_value)
159
+ if not path.exists():
160
+ print(f"Missing log artifact: {path.relative_to(root)}")
161
+ continue
162
+ print(f"==> {path.relative_to(root)} <==")
163
+ content = path.read_text(errors="replace")
164
+ print(content, end="")
165
+ if not content.endswith("\n"):
166
+ print()
167
+ return 0
168
+
169
+
170
+ def _accept(root: Path, commit: bool, message: str | None) -> int:
171
+ state = _load_state(root, None)
172
+ if not commit:
173
+ print(f"Run {state.run_id} accepted. Changes remain in the working tree.")
174
+ return 0
175
+ paths = [path for path in changed_files(root) if not path.startswith(".team/")]
176
+ if not paths:
177
+ print(f"Run {state.run_id} has no source changes to commit.")
178
+ return 0
179
+ add_and_commit(root, paths, message or f"team: accept run {state.run_id}")
180
+ print(f"Committed run {state.run_id}")
181
+ return 0
182
+
183
+
184
+ def _reset(root: Path, run_id: str | None) -> int:
185
+ state = _load_state(root, run_id)
186
+ paths = [path for path in state.changed_files if not path.startswith(".team/")]
187
+ if not paths:
188
+ print(f"Run {state.run_id} recorded no source files to reset.")
189
+ return 0
190
+ restore_paths(root, paths)
191
+ for path in paths:
192
+ full_path = root / path
193
+ if full_path.exists() and path in changed_files(root):
194
+ try:
195
+ full_path.unlink()
196
+ except IsADirectoryError:
197
+ pass
198
+ print(f"Reset files recorded for run {state.run_id}")
199
+ return 0
200
+
201
+
202
+ def _first_command_token(command: str) -> str | None:
203
+ try:
204
+ parts = shlex.split(command)
205
+ except ValueError:
206
+ return None
207
+ if not parts:
208
+ return None
209
+ if any(token in parts[0] for token in ("=", "/", "\\")):
210
+ return None
211
+ return parts[0]
212
+
213
+
214
+ def _format_state(state: RunState) -> str:
215
+ changed = ", ".join(state.changed_files) if state.changed_files else "none"
216
+ reason = f"\nReason: {state.reason}" if state.reason else ""
217
+ return (
218
+ f"Run: {state.run_id}\n"
219
+ f"Flow: {state.flow}\n"
220
+ f"Status: {state.status}\n"
221
+ f"Branch: {state.branch}\n"
222
+ f"Changed files: {changed}"
223
+ f"{reason}"
224
+ )
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
codemate/commands.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def run_command_group(
8
+ root: Path, commands: list[str], log_path: Path, timeout_seconds: int = 900
9
+ ) -> tuple[bool, str | None, int]:
10
+ if not commands:
11
+ log_path.write_text("No commands configured.\n")
12
+ return True, None, 0
13
+
14
+ with log_path.open("w") as log:
15
+ for command in commands:
16
+ log.write(f"$ {command}\n")
17
+ log.flush()
18
+ try:
19
+ result = subprocess.run(
20
+ command,
21
+ cwd=root,
22
+ shell=True,
23
+ text=True,
24
+ stdout=log,
25
+ stderr=subprocess.STDOUT,
26
+ timeout=timeout_seconds,
27
+ check=False,
28
+ )
29
+ exit_code = result.returncode
30
+ except subprocess.TimeoutExpired:
31
+ exit_code = 124
32
+ log.write(f"\n[timeout after {timeout_seconds} seconds]\n")
33
+ log.write(f"\n[exit_code={exit_code}]\n")
34
+ if exit_code != 0:
35
+ return False, command, exit_code
36
+ return True, None, 0