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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
run: |
git config --global user.email "ci@example.com"
git config --global user.name "CI"
- name: Install the MCP SDK (enables wire-schema tests; they skip if unavailable)
run: python -m pip install mcp || echo "mcp SDK unavailable on this Python; wire-schema tests will skip"
- name: Unit tests
run: python -m unittest discover -s tests -v
- name: package.json is valid JSON
Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ PowerShell:
| `clodex init --migrate [--split-claude] [--dry-run]` | Update `CLODEX.md` settings that no longer work (retired models, unsupported efforts) |
| `clodex plan "<task>"` | Run Claude planning only |
| `clodex build "<task>"` | Run plan, implementation, and dual audit loop in an isolated worktree |
| `clodex audit --diff` | Audit current uncommitted changes |
| `clodex audit [--diff \| --base REF \| --commit SHA]` | Audit the uncommitted diff (default), everything since `REF` diverged from `HEAD`, or one commit |
| `clodex run "<task>"` | Alias for `build` |
| `clodex apply <run-id>` | Apply an approved worktree patch back to the source checkout (`--check` to dry-run, `--force` for unapproved runs) |
| `clodex clean <run-id>` | Remove the kept git worktree of a finished run (artifacts and patch stay) |
Expand Down Expand Up @@ -176,6 +176,14 @@ approved diff hash; a local-workspace run is reported as already in the working
that fails unexpectedly is marked `failed` (traceback in `error.txt`) and its worktree is
removed; approved and blocked runs keep theirs until `clodex clean <run-id>`.

### Exit codes and output

`0` success; `1` the work was refused or rejected (`blocked`, `apply-refused`, ...); `2` usage or
configuration error (including unknown run ids and bad git refs, reported as one line; set
`CLODEX_DEBUG=1` for the traceback); `3` the run failed unexpectedly; `4` it was cancelled.
Without `--json`, output is plain text; `--json` is the stable machine-readable form. Commands
find the repository from any subdirectory (or `CLODEX_REPO_ROOT`).

### Models and `clodex doctor`

`clodex doctor` validates the configuration against what is actually installed:
Expand Down Expand Up @@ -226,15 +234,34 @@ The MCP server exposes:
- `clodex_handoff_get`
- `clodex_handoff_decide`

The server also handles MCP-style `tasks/get`, `tasks/update`, and
`tasks/cancel` JSON-RPC methods using the Clodex `run_id` as the task id.

Start it with:

```bash
python -m clodex mcp-server
```

The server speaks MCP `2025-11-25` (and negotiates down to `2025-06-18`, `2025-03-26` or
`2024-11-05`). It works on the repository containing its working directory, or the one named by
`CLODEX_REPO_ROOT`, which is the reliable way to point a client at a project.

- **Tool errors vs protocol errors:** wrong or missing tool arguments come back as a tool result
with `isError: true` (so the model can correct itself); an unknown tool or malformed JSON-RPC is
a protocol error.
- **Responsiveness:** requests are handled in arrival order, so a client may pipeline
`handoff_create` -> `handoff_update` -> `handoff_get`. Only long-running work (a synchronous
plan/build/audit, or waiting in `tasks/result`) runs on a worker thread, so pings, polling and
cancellation are never blocked.
- **Tasks (experimental in the spec):** `clodex_build` supports task augmentation. Call
`tools/call` with a `task` object and it returns a task immediately, backed by a durable worker;
then use `tasks/get`, `tasks/result` (blocks until done, returns the tool result), `tasks/list`
and `tasks/cancel`. The Clodex `run_id` is the task id. Run statuses map to task statuses:
`queued`/`running`/`planning`/`auditing`/`needs-fix` = `working`, `approved`/`applied` =
`completed`, `blocked`/`failed` = `failed`, `cancelled` = `cancelled`. Task ids are not
access-controlled: this is a local, single-user server, so only expose it to clients you trust.
The pre-spec `tasks/update` method is gone.
- `clodex_audit` accepts `base` (everything since that ref diverged) and `commit` (one commit) as
well as the default uncommitted diff.

## Safety

Clodex defaults to git worktree isolation, Codex `workspace-write` sandboxing,
Expand Down
32 changes: 32 additions & 0 deletions clodex/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,37 @@ def current_diff(repo_root: Path) -> str:
return result.stdout.decode("utf-8", errors="surrogateescape")


def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(["git", *args], cwd=repo_root, capture_output=True, check=False)


def _verified_commit(repo_root: Path, ref: str) -> str:
result = _git(repo_root, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
if result.returncode != 0:
raise ValueError(f"Unknown git ref: {ref}")
return result.stdout.decode("utf-8", errors="replace").strip()


def audit_diff(repo_root: Path, base: str | None = None, commit: str | None = None) -> str:
"""The change to audit: the uncommitted diff (default), everything since `base` diverged, or one commit."""
if base and commit:
raise ValueError("Use either a base ref or a commit, not both")
if commit:
sha = _verified_commit(repo_root, commit)
result = _git(repo_root, "show", "--binary", "--format=", sha)
elif base:
_verified_commit(repo_root, base)
merge_base = _git(repo_root, "merge-base", base, "HEAD")
if merge_base.returncode != 0:
raise ValueError(f"No common ancestor between {base} and HEAD")
# Working tree vs the merge-base: the branch's commits plus any uncommitted edits.
result = _git(repo_root, "diff", "--binary", merge_base.stdout.decode("utf-8", errors="replace").strip())
else:
return current_diff(repo_root)
if result.returncode != 0:
raise ValueError(f"git could not produce the diff: {result.stderr.decode('utf-8', errors='replace').strip()}")
return result.stdout.decode("utf-8", errors="surrogateescape")


def hash_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", errors="surrogateescape")).hexdigest()
123 changes: 102 additions & 21 deletions clodex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

import argparse
import json
import os
import sys
from pathlib import Path
from typing import Any

from .config import ConfigError
from .config import ConfigError, resolve_repo_root
from .doctor import run_doctor
from .evals import run_local_evals
from .hooks import hook_config, ingest_hook_event
Expand All @@ -27,13 +28,16 @@
def main(argv: list[str] | None = None) -> int:
try:
return _main(argv)
except (ConfigError, ModelRetiredError) as exc:
except (ConfigError, ModelRetiredError, ValueError) as exc:
if os.environ.get("CLODEX_DEBUG"):
raise
# Deliberate user-facing errors (unknown run id, bad ref, bad config): one line, no traceback.
print(f"clodex: error: {exc}", file=sys.stderr)
return 2
return EXIT_USAGE


def handle_migrate(args: argparse.Namespace) -> int:
contract = Path.cwd() / "CLODEX.md"
contract = resolve_repo_root() / "CLODEX.md"
if not contract.is_file():
print_output({"ok": False, "error": f"{contract} not found"}, args.json)
return 1
Expand Down Expand Up @@ -82,9 +86,12 @@ def _main(argv: list[str] | None = None) -> int:
build = sub.add_parser("build", help="Run plan, implementation, and dual audit")
add_build_args(build)

audit = sub.add_parser("audit", help="Audit current uncommitted changes")
audit = sub.add_parser("audit", help="Audit current uncommitted changes, everything since a base ref, or one commit")
audit.add_argument("--dry-run", action="store_true")
audit.add_argument("--diff", action="store_true", help="Accepted for compatibility; audit always uses git diff")
audit_target = audit.add_mutually_exclusive_group()
audit_target.add_argument("--diff", action="store_true", help="Audit the current uncommitted diff (the default)")
audit_target.add_argument("--base", metavar="REF", help="Audit everything since this ref diverged from HEAD, including uncommitted edits")
audit_target.add_argument("--commit", metavar="SHA", help="Audit the changes introduced by one commit")

run = sub.add_parser("run", help="Alias for build")
add_build_args(run)
Expand Down Expand Up @@ -150,7 +157,7 @@ def _main(argv: list[str] | None = None) -> int:
if args.command == "mcp-server":
return mcp_main()
if args.command == "doctor":
exit_code, data = run_doctor(strict=args.strict)
exit_code, data = run_doctor(resolve_repo_root(), strict=args.strict)
print_output(data, args.json)
return exit_code
if args.command == "init" and args.migrate:
Expand Down Expand Up @@ -200,12 +207,13 @@ def _main(argv: list[str] | None = None) -> int:
print_output(data, args.json)
return exit_code

workflow = ClodexWorkflow(Path.cwd())
repo = resolve_repo_root()
workflow = ClodexWorkflow(repo)

if args.command == "plan":
result = workflow.plan(" ".join(args.task), dry_run=args.dry_run)
print_result(result.__dict__, args.json)
return 0
return exit_code_for(result.status)
if args.command in {"build", "run"}:
result = workflow.build(
" ".join(args.task),
Expand All @@ -215,25 +223,25 @@ def _main(argv: list[str] | None = None) -> int:
apply_changes=args.apply_changes,
)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"blocked"} else 1
return exit_code_for(result.status)
if args.command == "audit":
result = workflow.audit(dry_run=args.dry_run)
result = workflow.audit(dry_run=args.dry_run, base=args.base, commit=args.commit)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"blocked"} else 1
return exit_code_for(result.status)
if args.command == "task":
return handle_task(args, workflow, args.json)
if args.command == "apply":
result = workflow.apply_run(args.run_id, check=args.check, force=args.force)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"apply-failed", "apply-check-failed", "apply-refused"} else 1
return exit_code_for(result.status)
if args.command == "clean":
result = workflow.clean_run(args.run_id)
print_result(result.__dict__, args.json)
return 0 if result.status != "clean-refused" else 1
return exit_code_for(result.status)
if args.command == "trace":
return handle_trace(args, workflow, args.json)
if args.command == "eval":
data = run_local_evals(Path.cwd())
data = run_local_evals(repo)
print_output(data, args.json)
return 0 if data["passed"] else 1
if args.command == "hooks":
Expand Down Expand Up @@ -266,7 +274,7 @@ def add_build_args(parser: argparse.ArgumentParser) -> None:


def handle_task(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: bool) -> int:
manager = TaskManager(Path.cwd())
manager = TaskManager(resolve_repo_root())
if args.task_command == "start":
result = manager.start(" ".join(args.task), workspace_backend=args.workspace, approval_profile=args.approval_profile, dry_run=args.dry_run)
print_result(result.__dict__, as_json)
Expand All @@ -289,7 +297,7 @@ def handle_task(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: boo
with Heartbeat(workflow.state, args.run_id):
result = workflow.run_existing(args.run_id, workspace_backend=args.workspace, approval_profile=args.approval_profile)
print_result(result.__dict__, as_json)
return 0 if result.status not in {"blocked"} else 1
return exit_code_for(result.status)
return 2


Expand All @@ -308,18 +316,37 @@ def handle_trace(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: bo

def handle_hooks(args: argparse.Namespace, as_json: bool) -> int:
if args.hooks_command == "print":
print_output(hook_config(Path.cwd()), as_json)
print_output(hook_config(resolve_repo_root()), as_json)
return 0
if args.hooks_command == "install":
print_output({"dry_run": True, "config": hook_config(Path.cwd())}, as_json)
print_output({"dry_run": True, "config": hook_config(resolve_repo_root())}, as_json)
return 0
if args.hooks_command == "ingest":
payload = json.loads(sys.stdin.read() or "{}")
print_output(ingest_hook_event(Path.cwd(), args.run_id, payload), as_json)
print_output(ingest_hook_event(resolve_repo_root(), args.run_id, payload), as_json)
return 0
return 2


# Exit codes: 0 success, 1 the work was refused or rejected (blocked, nothing applied),
# 2 usage or configuration error, 3 failed unexpectedly, 4 cancelled.
EXIT_BLOCKED = 1
EXIT_USAGE = 2
EXIT_FAILED = 3
EXIT_CANCELLED = 4
_BLOCKED_STATUSES = {"blocked", "apply-refused", "apply-failed", "apply-check-failed", "clean-refused"}


def exit_code_for(status: str) -> int:
if status in _BLOCKED_STATUSES:
return EXIT_BLOCKED
if status == "failed":
return EXIT_FAILED
if status == "cancelled":
return EXIT_CANCELLED
return 0


def print_result(data: dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(data, indent=2, sort_keys=True))
Expand All @@ -338,5 +365,59 @@ def print_result(data: dict[str, Any], as_json: bool) -> None:
def print_output(data: dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(data, indent=2, sort_keys=True))
elif "diagnostics" in data and "python" in data:
print("\n".join(format_doctor(data)))
else:
print(json.dumps(data, indent=2, sort_keys=True))
print("\n".join(format_human(data)) or "(nothing to show)")


def _scalar(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, bool):
return "yes" if value else "no"
return str(value)


def format_human(data: Any, indent: int = 0) -> list[str]:
"""Plain-text rendering of nested dicts/lists for people (machine consumers use --json)."""
pad = " " * indent
lines: list[str] = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)) and value:
lines.append(f"{pad}{key}:")
lines.extend(format_human(value, indent + 1))
else:
lines.append(f"{pad}{key}: {_scalar(value) if not isinstance(value, (dict, list)) else '(none)'}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
head = next((str(item[key]) for key in ("id", "name", "run_id", "taskId") if key in item), "")
lines.append(f"{pad}- {head}".rstrip())
lines.extend(format_human({k: v for k, v in item.items() if str(v) != head}, indent + 1))
else:
lines.append(f"{pad}- {_scalar(item)}")
else:
lines.append(f"{pad}{_scalar(data)}")
return lines


def format_doctor(data: dict[str, Any]) -> list[str]:
def mark(ok: bool) -> str:
return "[ok] " if ok else "[FAIL]"

lines = [f"{mark(data['python']['ok'])} python {data['python']['version']}", f"{mark(data['contract']['ok'])} CLODEX.md ({data['contract']['path']})"]
for name in ("git", "claude", "codex"):
info = data[name]
lines.append(f"{mark(info['ok'])} {name}: {info.get('version') or 'not found'}")
for name, auth in (data.get("auth") or {}).items():
lines.append(f"{mark(auth['status'] != 'logged-out')} {name} login: {auth['status']}")
for item in data["diagnostics"]:
tag = "[FAIL]" if item["level"] == "error" else "[warn]"
lines.append(f"{tag} {item['where']}: {item['message']}")
if item.get("fix"):
lines.append(f" fix: {item['fix']}")
lines.append("")
lines.append("Everything looks good." if data["ok"] else "Problems found; see above.")
return lines
7 changes: 7 additions & 0 deletions clodex/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -153,6 +154,12 @@ def tracing(self) -> dict[str, Any]:
return dict(DEFAULT_CONFIG["tracing"] | self.raw.get("tracing", {}))


def resolve_repo_root() -> Path:
"""The repo Clodex should work on: $CLODEX_REPO_ROOT if set (MCP clients start servers from anywhere), else the git root above the cwd."""
override = os.environ.get("CLODEX_REPO_ROOT")
return find_repo_root(Path(override)) if override else find_repo_root()


def find_repo_root(start: Path | None = None) -> Path:
current = (start or Path.cwd()).resolve()
for candidate in [current, *current.parents]:
Expand Down
Loading
Loading