Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLODEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ workspace:
backend: git-worktree
apply_mode: manual
claude:
model: opus
effort: max
permission_mode: plan
plan:
model: opus
effort: max
audit:
model: opus
effort: high
codex:
model: gpt-6.1-sol
reasoning_effort: xhigh
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Clodex is a native collaboration layer for **Claude Code CLI** and **Codex CLI**
It installs repo instructions and MCP tools that let either agent hand work to
the other without the user manually driving every step.

- **Claude Code** plans first with Opus at max effort.
- **Claude Code** plans first with Opus at max effort and audits at high effort.
- **Codex** implements from that accepted plan with GPT-6.1 Sol at xhigh reasoning.
- **Both agents audit the same final diff hash** and must agree before the task
is considered complete.
Expand Down Expand Up @@ -132,9 +132,13 @@ prompt body. Defaults:

```yaml
claude:
model: opus
effort: max
permission_mode: plan
plan:
model: opus
effort: max
audit:
model: opus
effort: high
codex:
model: gpt-6.1-sol
reasoning_effort: xhigh
Expand Down
78 changes: 56 additions & 22 deletions clodex/agents.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import os
import subprocess
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path

Expand All @@ -15,11 +17,18 @@ class AgentResult:
stderr: str
returncode: int
timed_out: bool = False
# Final agent message read from `-o <file>` when the command asked for it.
last_message: str | None = None

@property
def ok(self) -> bool:
return self.returncode == 0

@property
def output(self) -> str:
"""The text to parse: the captured final message if any, else stdout."""
return self.last_message if self.last_message else self.stdout


class AgentRunner:
def __init__(self, repo_root: Path):
Expand All @@ -30,32 +39,57 @@ def run(self, command: AgentCommand, prompt: str, timeout: int | None = None) ->
resolved = shutil.which(argv[0])
if resolved:
argv[0] = resolved
last_message_path: str | None = None
if command.capture_last_message:
handle, last_message_path = tempfile.mkstemp(prefix="clodex-last-message-", suffix=".txt")
os.close(handle)
# Keep the trailing "-" (read prompt from stdin) last.
at = len(argv) - 1 if argv and argv[-1] == "-" else len(argv)
argv[at:at] = ["-o", last_message_path]
try:
result = subprocess.run(
argv,
cwd=self.repo_root,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
try:
result = subprocess.run(
argv,
cwd=self.repo_root,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
return AgentResult(
command=command,
stdout=_as_text(exc.stdout),
stderr=_as_text(exc.stderr) + f"\ntimed out after {timeout}s",
returncode=124,
timed_out=True,
)
return AgentResult(
command=command,
stdout=_as_text(exc.stdout),
stderr=_as_text(exc.stderr) + f"\ntimed out after {timeout}s",
returncode=124,
timed_out=True,
stdout=result.stdout,
stderr=result.stderr,
returncode=result.returncode,
last_message=_read_text(last_message_path),
)
return AgentResult(
command=command,
stdout=result.stdout,
stderr=result.stderr,
returncode=result.returncode,
)
finally:
if last_message_path:
try:
os.unlink(last_message_path)
except OSError:
pass


def _read_text(path: str | None) -> str | None:
if not path:
return None
try:
text = Path(path).read_text(encoding="utf-8", errors="replace")
except OSError:
return None
return text.strip() or None


def _as_text(value: str | bytes | None) -> str:
Expand Down
83 changes: 62 additions & 21 deletions clodex/commands.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
from __future__ import annotations

import shutil
from dataclasses import dataclass
from pathlib import Path

from .config import ClodexConfig
from .schemas import compact_schema, schema_path


@dataclass(frozen=True)
class AgentCommand:
name: str
argv: list[str]
# When set, the agent's JSON output is validated against this packaged schema.
schema_name: str | None = None
# When set, the runner adds `-o <file>` so the final message is read from a file
# instead of being scraped out of stdout (codex exec).
capture_last_message: bool = False

def display(self) -> str:
return " ".join(quote_arg(arg) for arg in self.argv)
shown: list[str] = []
for index, arg in enumerate(self.argv):
shown.append(f"<{self.schema_name} schema>" if index and self.argv[index - 1] == "--json-schema" else arg)
return " ".join(quote_arg(arg) for arg in shown)


def quote_arg(arg: str) -> str:
Expand All @@ -23,27 +33,53 @@ def quote_arg(arg: str) -> str:
return arg


def inline_schema_supported(setting: object = "auto", resolved: str | None = None) -> bool:
"""Whether to pass `claude --json-schema <inline json>`.

The flag takes inline JSON only. A Windows .cmd/.bat shim re-parses the command
line with cmd.exe and mangles the quotes, so `auto` turns the flag off for shims.
"""
if isinstance(setting, bool):
return setting
text = str(setting).strip().lower()
if text in {"true", "yes", "on"}:
return True
if text in {"false", "no", "off"}:
return False
resolved = resolved or shutil.which("claude")
return not (resolved and resolved.lower().endswith((".cmd", ".bat")))


def _claude_command(name: str, config: ClodexConfig, role: str, schema_name: str) -> AgentCommand:
settings = config.claude_role(role)
argv = [
"claude",
"-p",
"--model",
str(settings["model"]),
"--effort",
str(settings["effort"]),
"--permission-mode",
str(settings["permission_mode"]),
"--output-format",
"json",
]
fallback = settings.get("fallback_model")
if fallback:
argv.extend(["--fallback-model", ",".join(fallback) if isinstance(fallback, (list, tuple)) else str(fallback)])
if settings.get("max_budget_usd") not in (None, ""):
argv.extend(["--max-budget-usd", str(settings["max_budget_usd"])])
if inline_schema_supported(settings.get("json_schema", "auto")):
argv.extend(["--json-schema", compact_schema(schema_name)])
return AgentCommand(name=name, argv=argv, schema_name=schema_name)


def claude_plan_command(config: ClodexConfig) -> AgentCommand:
claude = config.claude
return AgentCommand(
name="claude-plan",
argv=[
"claude",
"-p",
"--model",
str(claude["model"]),
"--effort",
str(claude["effort"]),
"--permission-mode",
str(claude["permission_mode"]),
"--output-format",
"json",
],
)
return _claude_command("claude-plan", config, "plan", "plan")


def claude_audit_command(config: ClodexConfig) -> AgentCommand:
return claude_plan_command(config)
return _claude_command("claude-audit", config, "audit", "audit_verdict")


def codex_exec_command(config: ClodexConfig, repo_root: Path, approval_profile: str | None = None) -> AgentCommand:
Expand All @@ -67,9 +103,10 @@ def codex_exec_command(config: ClodexConfig, repo_root: Path, approval_profile:


def codex_review_command(config: ClodexConfig, repo_root: Path) -> AgentCommand:
# `codex review --uncommitted` rejects a prompt argument, so audits run as a
# read-only `codex exec`; the audit prompt already embeds the diff.
codex = config.codex
# `codex exec review` cannot take a prompt together with --uncommitted and ignores
# --output-schema, so audits run as a read-only `codex exec`; the audit prompt
# already embeds the diff.
codex = config.codex_role("audit")
return AgentCommand(
name="codex-audit",
argv=[
Expand All @@ -86,6 +123,10 @@ def codex_review_command(config: ClodexConfig, repo_root: Path) -> AgentCommand:
"--ephemeral",
"-C",
str(repo_root),
"--output-schema",
str(schema_path("audit_verdict")),
"-",
],
schema_name="audit_verdict",
capture_last_message=True,
)
33 changes: 30 additions & 3 deletions clodex/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
"apply_mode": "manual",
},
"claude": {
"model": "opus",
"effort": "max",
"permission_mode": "plan",
# Planning gets the most reasoning; audits run often, so they default lower.
"plan": {"model": "opus", "effort": "max"},
"audit": {"model": "opus", "effort": "high"},
},
"codex": {
"model": "gpt-6.1-sol",
Expand Down Expand Up @@ -55,6 +56,9 @@
}


ROLES = ("plan", "audit")


class ConfigError(ValueError):
"""CLODEX.md could not be loaded."""

Expand Down Expand Up @@ -86,6 +90,9 @@ class ClodexConfig:
repo_root: Path
raw: dict[str, Any] = field(default_factory=dict)
prompt_body: str = ""
# Only what CLODEX.md set explicitly (no defaults), so legacy flat keys can
# outrank the per-role defaults.
user: dict[str, Any] = field(default_factory=dict)

@property
def max_fix_loops(self) -> int:
Expand All @@ -111,10 +118,30 @@ def workspace(self) -> dict[str, Any]:
def claude(self) -> dict[str, Any]:
return dict(DEFAULT_CONFIG["claude"] | self.raw.get("claude", {}))

def claude_role(self, role: str) -> dict[str, Any]:
"""Effective Claude settings for `plan` or `audit`.

Precedence, lowest to highest: built-in role defaults, flat `claude.*` keys in
CLODEX.md (the pre-0.2 layout, which drove both roles), then `claude.<role>.*`.
"""
defaults = DEFAULT_CONFIG["claude"]
user = self.user.get("claude")
user = user if isinstance(user, dict) else {}
flat = {key: value for key, value in user.items() if key not in ROLES}
explicit = user.get(role)
explicit = explicit if isinstance(explicit, dict) else {}
return {"permission_mode": defaults["permission_mode"], **defaults[role], **flat, **explicit}

@property
def codex(self) -> dict[str, Any]:
return dict(DEFAULT_CONFIG["codex"] | self.raw.get("codex", {}))

def codex_role(self, role: str) -> dict[str, Any]:
"""Codex settings for a role; `codex.audit.*` overrides model/effort for audits."""
base = {key: value for key, value in self.codex.items() if key not in ROLES}
override = self.codex.get(role)
return {**base, **(override if isinstance(override, dict) else {})}

@property
def audit(self) -> dict[str, Any]:
return dict(DEFAULT_CONFIG["audit"] | self.raw.get("audit", {}))
Expand Down Expand Up @@ -152,7 +179,7 @@ def load_config(repo_root: Path | None = None) -> ClodexConfig:
parsed = parse_front_matter(front_matter, str(contract))
merged = deep_merge(DEFAULT_CONFIG, parsed)
warn_if_model_retiring(str(merged["codex"].get("model", "")))
return ClodexConfig(repo_root=root, raw=merged, prompt_body=body.strip())
return ClodexConfig(repo_root=root, raw=merged, prompt_body=body.strip(), user=parsed)


def split_front_matter(text: str) -> tuple[str, str]:
Expand Down
2 changes: 1 addition & 1 deletion clodex/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def audit_prompt(agent_name: str, plan: dict[str, Any], diff: str, diff_hash: st
"persona": "{selected_persona}",
"summary": "short verdict",
"findings": [
{{"severity": "high|medium|low", "file": "path", "line": 1, "message": "finding"}}
{{"severity": "critical|high|medium|low|info", "file": "path or null", "line": 1, "message": "finding"}}
],
"required_fixes": ["specific fix"]
}}
Expand Down
Loading
Loading