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
12 changes: 12 additions & 0 deletions CLODEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ max_fix_loops: 2
workspace_root: .clodex/workspaces
runs_root: .clodex/runs
state_path: .clodex/state.sqlite3
workspace:
backend: git-worktree
apply_mode: manual
claude:
model: opus
effort: max
Expand All @@ -13,9 +16,15 @@ codex:
model: gpt-5.5
reasoning_effort: xhigh
sandbox: workspace-write
approval_profile: ci
audit:
quorum: unanimous
personas: [security, performance, portability, test-gap]
reviewers: [{"id": "claude-plan", "backend": "claude", "persona": "plan-adherence", "required": true, "timeout": 600}, {"id": "codex-architecture", "backend": "codex", "persona": "architecture", "required": true, "timeout": 600}, {"id": "security", "backend": "codex", "persona": "security", "required": false, "timeout": 600}, {"id": "performance", "backend": "codex", "persona": "performance", "required": false, "timeout": 600}, {"id": "portability", "backend": "codex", "persona": "portability", "required": false, "timeout": 600}, {"id": "test-gap", "backend": "claude", "persona": "test-gap", "required": false, "timeout": 600}]
mcp:
async_tasks: true
tracing:
enabled: true
---
# Clodex Workflow Contract

Expand All @@ -31,6 +40,9 @@ remains unresolved.
Both agents then perform adversarial audit against the same final diff hash.
Clodex advances only when both agents return explicit approval.

Default builds run in isolated git worktrees and leave source checkouts
unchanged until a run is explicitly applied.

Default behavior is local-first. Subscription CLI authentication is preferred
for both Claude Code and Codex. API keys are optional fallback mechanisms for
CI or headless environments, not the default local workflow.
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ API keys or long-lived tokens are fallback-only for CI/headless automation.
clodex doctor
clodex plan --dry-run "Add a small feature"
clodex build "Add a small feature"
clodex apply <run-id>
```

From this checkout without installing:
Expand All @@ -61,9 +62,14 @@ PowerShell:
| --- | --- |
| `clodex doctor` | Check Python, git, Claude Code, Codex, and `CLODEX.md` |
| `clodex plan "<task>"` | Run Claude planning only |
| `clodex build "<task>"` | Run plan, implementation, and dual audit loop |
| `clodex build "<task>"` | Run plan, implementation, and dual audit loop in an isolated worktree |
| `clodex audit --diff` | Audit current uncommitted changes |
| `clodex run "<task>"` | Alias for `build` |
| `clodex apply <run-id>` | Apply an approved worktree patch back to the source checkout |
| `clodex task start/get/cancel/list` | Manage durable async runs |
| `clodex trace export <run-id>` | Print a run trace as JSONL |
| `clodex hooks print/install/ingest` | Generate or ingest Claude Code hook events |
| `clodex eval run` | Run local harness smoke evals |
| `clodex queue add/list/update` | Manage the local task ledger |
| `clodex status` | Show recent tasks and runs |
| `clodex mcp-server` | Run the stdio MCP server |
Expand All @@ -82,6 +88,10 @@ codex:
model: gpt-5.5
reasoning_effort: xhigh
sandbox: workspace-write
approval_profile: ci
workspace:
backend: git-worktree
apply_mode: manual
max_fix_loops: 2
```

Expand All @@ -93,9 +103,17 @@ Run artifacts are written to `.clodex/runs/<run-id>/`:
- `04-codex-audit.json`
- `05-agreement.json`
- `changes.diff`
- `apply.patch`
- `trace.jsonl`
- `workspace.json`
- `reviewers/*.json`

Local task/run state is stored in `.clodex/state.sqlite3`.

By default, `clodex build` executes inside `.clodex/workspaces/<run-id>/`.
The source checkout is not modified until `clodex apply <run-id>` succeeds.
Use `--workspace local` for compatibility with the earlier in-place behavior.

## MCP Tools

The MCP server exposes:
Expand All @@ -106,6 +124,12 @@ The MCP server exposes:
- `clodex_status`
- `clodex_task_create`
- `clodex_task_update`
- `clodex_task_start`
- `clodex_task_get`
- `clodex_task_cancel`

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.
Comment on lines +131 to +132

Start it with:

Expand All @@ -115,7 +139,7 @@ python -m clodex mcp-server

## Safety

Clodex defaults to Codex `workspace-write` sandboxing and Claude plan mode.
Dangerous full-access workflows are intentionally not the default. A run is
complete only when `05-agreement.json` has `approved: true` for the final diff
hash.
Clodex defaults to git worktree isolation, Codex `workspace-write` sandboxing,
and Claude plan mode. Dangerous full-access workflows are intentionally not the
default. A run is complete only when `05-agreement.json` has `approved: true`
for the final diff hash.
3 changes: 2 additions & 1 deletion clodex/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class AgentRunner:
def __init__(self, repo_root: Path):
self.repo_root = repo_root

def run(self, command: AgentCommand, prompt: str) -> AgentResult:
def run(self, command: AgentCommand, prompt: str, timeout: int | None = None) -> AgentResult:
argv = list(command.argv)
resolved = shutil.which(argv[0])
if resolved:
Expand All @@ -38,6 +38,7 @@ def run(self, command: AgentCommand, prompt: str) -> AgentResult:
encoding="utf-8",
errors="replace",
check=False,
timeout=timeout,
)
return AgentResult(
command=command,
Expand Down
2 changes: 2 additions & 0 deletions clodex/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ def __init__(self, config: ClodexConfig, run_id: str):

def write_json(self, name: str, data: dict[str, Any]) -> Path:
path = self.path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path

def write_text(self, name: str, text: str) -> Path:
path = self.path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path

Expand Down
133 changes: 128 additions & 5 deletions clodex/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

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

from .doctor import run_doctor
from .evals import run_local_evals
from .hooks import hook_config, ingest_hook_event
from .mcp_server import main as mcp_main
from .tasks import TaskManager
from .workflow import ClodexWorkflow


Expand All @@ -22,16 +26,31 @@ def main(argv: list[str] | None = None) -> int:
plan.add_argument("--dry-run", action="store_true")

build = sub.add_parser("build", help="Run plan, implementation, and dual audit")
build.add_argument("task", nargs="+")
build.add_argument("--dry-run", action="store_true")
add_build_args(build)

audit = sub.add_parser("audit", help="Audit current uncommitted changes")
audit.add_argument("--dry-run", action="store_true")
audit.add_argument("--diff", action="store_true", help="Accepted for compatibility; audit always uses git diff")

run = sub.add_parser("run", help="Alias for build")
run.add_argument("task", nargs="+")
run.add_argument("--dry-run", action="store_true")
add_build_args(run)

task = sub.add_parser("task", help="Manage durable Clodex runs")
task_sub = task.add_subparsers(dest="task_command", required=True)
task_start = task_sub.add_parser("start")
task_start.add_argument("task", nargs="+")
task_start.add_argument("--workspace", choices=["git-worktree", "local"])
task_start.add_argument("--approval-profile", choices=["ci", "local", "auto_review"])
task_start.add_argument("--dry-run", action="store_true")
task_get = task_sub.add_parser("get")
task_get.add_argument("run_id")
task_cancel = task_sub.add_parser("cancel")
task_cancel.add_argument("run_id")
task_sub.add_parser("list")
task_worker = task_sub.add_parser("worker")
task_worker.add_argument("run_id")
task_worker.add_argument("--workspace", choices=["git-worktree", "local"])
task_worker.add_argument("--approval-profile", choices=["ci", "local", "auto_review"])

queue = sub.add_parser("queue", help="Manage the local task ledger")
queue_sub = queue.add_subparsers(dest="queue_command", required=True)
Expand All @@ -43,6 +62,28 @@ def main(argv: list[str] | None = None) -> int:
queue_update.add_argument("id")
queue_update.add_argument("status")

apply_cmd = sub.add_parser("apply", help="Apply an approved worktree run patch")
apply_cmd.add_argument("run_id")
apply_cmd.add_argument("--check", action="store_true")

trace = sub.add_parser("trace", help="Inspect run traces")
trace_sub = trace.add_subparsers(dest="trace_command", required=True)
trace_export = trace_sub.add_parser("export")
trace_export.add_argument("run_id")
trace_export.add_argument("--format", choices=["jsonl"], default="jsonl")

eval_cmd = sub.add_parser("eval", help="Run local Clodex harness evals")
eval_sub = eval_cmd.add_subparsers(dest="eval_command", required=True)
eval_sub.add_parser("run")

hooks = sub.add_parser("hooks", help="Print or ingest Claude Code hook events")
hooks_sub = hooks.add_subparsers(dest="hooks_command", required=True)
hooks_sub.add_parser("print")
hooks_install = hooks_sub.add_parser("install")
hooks_install.add_argument("--dry-run", action="store_true", required=True)
hooks_ingest = hooks_sub.add_parser("ingest")
Comment on lines +82 to +84
hooks_ingest.add_argument("--run-id", default="manual-hook-event")

sub.add_parser("status", help="Show recent tasks and runs")
sub.add_parser("mcp-server", help="Run the Clodex MCP stdio server")

Expand All @@ -62,13 +103,33 @@ def main(argv: list[str] | None = None) -> int:
print_result(result.__dict__, args.json)
return 0
if args.command in {"build", "run"}:
result = workflow.build(" ".join(args.task), dry_run=args.dry_run)
result = workflow.build(
" ".join(args.task),
dry_run=args.dry_run,
workspace_backend=args.workspace,
approval_profile=args.approval_profile,
apply_changes=args.apply_changes,
)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"blocked"} else 1
if args.command == "audit":
result = workflow.audit(dry_run=args.dry_run)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"blocked"} else 1
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)
print_result(result.__dict__, args.json)
return 0 if result.status not in {"apply-failed", "apply-check-failed"} else 1
if args.command == "trace":
return handle_trace(args, workflow, args.json)
if args.command == "eval":
data = run_local_evals(Path.cwd())
print_output(data, args.json)
return 0 if data["passed"] else 1
if args.command == "hooks":
return handle_hooks(args, args.json)
if args.command == "status":
print_output({"tasks": workflow.state.list_tasks(), "runs": workflow.state.list_runs()}, args.json)
return 0
Expand All @@ -88,6 +149,68 @@ def main(argv: list[str] | None = None) -> int:
return 2


def add_build_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("task", nargs="+")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--workspace", choices=["git-worktree", "local"])
parser.add_argument("--approval-profile", choices=["ci", "local", "auto_review"])
parser.add_argument("--apply", action="store_true", dest="apply_changes")


def handle_task(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: bool) -> int:
manager = TaskManager(Path.cwd())
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)
return 0
if args.task_command == "get":
data = manager.get(args.run_id)
if data is None:
print_output({"error": f"unknown run: {args.run_id}"}, as_json)
return 1
print_output(data, as_json)
return 0
if args.task_command == "cancel":
result = manager.cancel(args.run_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: manager.cancel(args.run_id) will raise a raw ValueError if the run doesn't exist, which means clodex task cancel <random-string> dumps a Python traceback to the user's terminal instead of a clean error message. The MCP tasks/cancel handler wraps this in try/except — the CLI deserves the same courtesy.

🩹 The Fix: Wrap in try/except and print a structured error:

Suggested change
result = manager.cancel(args.run_id)
if args.task_command == "cancel":
try:
result = manager.cancel(args.run_id)
except ValueError as exc:
print_output({"error": str(exc)}, as_json)
return 1
print_result(result.__dict__, as_json)
return 0

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

print_result(result.__dict__, as_json)
return 0
if args.task_command == "list":
print_output(manager.list(), as_json)
return 0
if args.task_command == "worker":
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 2


def handle_trace(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: bool) -> int:
if args.trace_command == "export":
run = workflow.state.get_run(args.run_id)
artifacts_dir = Path(str((run or {}).get("artifacts_dir") or workflow.config.runs_root / args.run_id))
trace_path = artifacts_dir / "trace.jsonl"
if not trace_path.exists():
print_output({"error": f"trace not found: {trace_path}"}, as_json)
return 1
print(trace_path.read_text(encoding="utf-8"), end="")
return 0
return 2


def handle_hooks(args: argparse.Namespace, as_json: bool) -> int:
if args.hooks_command == "print":
print_output(hook_config(Path.cwd()), as_json)
return 0
if args.hooks_command == "install":
print_output({"dry_run": True, "config": hook_config(Path.cwd())}, as_json)
return 0
Comment on lines +204 to +206
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)
return 0
return 2


def print_result(data: dict[str, Any], as_json: bool) -> None:
if as_json:
print(json.dumps(data, indent=2, sort_keys=True))
Expand Down
30 changes: 18 additions & 12 deletions clodex/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,32 @@ def claude_audit_command(config: ClodexConfig) -> AgentCommand:
return claude_plan_command(config)


def codex_exec_command(config: ClodexConfig, repo_root: Path) -> AgentCommand:
def codex_exec_command(config: ClodexConfig, repo_root: Path, approval_profile: str | None = None) -> AgentCommand:
codex = config.codex
return AgentCommand(
name="codex-build",
argv=[
"codex",
"exec",
"-m",
str(codex["model"]),
"-c",
f'model_reasoning_effort="{codex["reasoning_effort"]}"',
profile = approval_profile or str(codex.get("approval_profile", "ci"))
approval = "never" if profile == "ci" else "on-request"
argv = [
"codex",
"exec",
"-m",
str(codex["model"]),
"-c",
f'model_reasoning_effort="{codex["reasoning_effort"]}"',
]
if profile == "auto_review":
argv.extend(["-c", 'approvals_reviewer="auto_review"'])
argv.extend(
[
"--sandbox",
str(codex["sandbox"]),
"--ask-for-approval",
"never",
approval,
"-C",
str(repo_root),
"-",
],
]
)
return AgentCommand(name="codex-build", argv=argv)


def codex_review_command(config: ClodexConfig) -> AgentCommand:
Expand Down
Loading