diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5668b1a..158b6e5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "clodex", - "version": "0.1.1", + "version": "0.2.0", "description": "Claude Code and Codex CLI dual-agent workflow orchestration.", "author": { "name": "9thLevelSoftware", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d67c4e0..33bd91b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "clodex", - "version": "0.1.1", + "version": "0.2.0", "description": "Claude Code and Codex CLI dual-agent workflow orchestration.", "author": { "name": "9thLevelSoftware", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1bc5fc2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +## 0.2.0 + +A large reliability and capability release. The headline: Clodex could not complete a real run +against current `claude` / `codex` CLIs before 0.1.1, and the default Codex model retires on +2026-10-14. 0.2.0 fixes that and builds on it. + +### Upgrading from 0.1.x + +1. `clodex doctor`: checks your CLIs (login, supported flags), the live Codex model catalog and + your `CLODEX.md`, with a fix for every problem it finds. Add `--strict` to fail on warnings. +2. `clodex init --migrate --dry-run`, then without `--dry-run`: rewrites retired Codex models and + unsupported efforts in the `CLODEX.md` front matter (comments and line endings are kept). + Add `--split-claude` to move the old flat `claude.model` / `claude.effort` onto the new + `claude.plan` / `claude.audit` roles (the old layout keeps working without it). +3. `clodex init --force`: refreshes the managed blocks in `CLAUDE.md` / `AGENTS.md` with the new + delegation instructions. If you used `clodex init --global`, run it again: it now writes to + `~/.claude/CLAUDE.md` and `~/.codex/AGENTS.md`, and registers the Claude server through + `claude mcp`. You can delete the old `~/CLAUDE.md`, `~/AGENTS.md`, `~/CLODEX.md` and + `~/.mcp.json` that earlier versions created. +4. `clodex hooks install` if you used the hooks: the hook settings were rewritten (the old ones + used the wrong shape and some event names that do not exist). + +Behavior changes to be aware of: + +- Default Codex model is `gpt-6.1-sol` at `xhigh`. Claude planning defaults to `opus` / `max` and + audits to `opus` / `high` (separate settings now). +- `clodex build`, `audit` and `task start` refuse to run on a retired Codex model + (`CLODEX_ALLOW_RETIRED_MODEL=1` overrides). +- `clodex apply` only applies approved runs whose patch still matches the approved diff. +- Exit codes: `0` ok, `1` blocked/refused, `2` usage or configuration error, `3` failed, + `4` cancelled. Output without `--json` is now plain text. +- The MCP `tasks/update` method never existed in the spec and is gone; `tasks/*` now follow the + 2025-11-25 Tasks utility. `actor` and `owner` on handoffs must be `claude` or `codex`. +- State database schema v4 (additive; old databases upgrade in place). + +### Added + +- **Native delegation:** `clodex_delegate` runs Codex (implement / fix / audit) in the handoff's + isolated worktree and records the result; Codex can ask questions instead of guessing + (`clodex_clarify` / `clodex_answer` / `clodex_messages`), and answers reach its next prompt. + `clodex_handoff_decide` follows your configured reviewers and quorum + (`unanimous` / `majority` / N) and refuses while a delegation runs or a question is open. +- **Structured output:** plan and audit verdicts use JSON schemas via `claude --json-schema` and + `codex exec --output-schema`, validated and retried once. +- **MCP:** protocol version negotiation, spec Tasks (`tasks/get|result|list|cancel`, task-augmented + `clodex_build`), correct JSON-RPC errors, ordered handling with non-blocking long work. +- **CLI:** `audit --base REF | --commit SHA`, `clean `, `doctor --strict`, `init --migrate`, + `hooks install|uninstall --scope`, plain-text output, commands work from any subdirectory. +- **Config:** nested YAML (vendored PyYAML for npm installs), `claude.plan` / `claude.audit`, + `codex.audit`, `audit.max_diff_bytes`, `workspace.apply_mode: auto`, `mcp.async_tasks`. +- `clodex eval run` is now an offline self-test of the harness (no agents, nothing written to your repo). +- GitHub Actions CI on Ubuntu and Windows across Python 3.12 - 3.14. + +### Fixed + +- Real runs could not reach `approved`: `codex exec` dropped `--ask-for-approval`, Claude's JSON + result envelope was never unwrapped, `codex review` rejects a prompt, and `package.json` was + invalid JSON. +- An audit with no required reviewers counted as approved; a failing optional reviewer aborted the + whole run; unexpected errors left runs `running` forever with their worktree behind. +- Diffs were read and written with newline translation, corrupting CRLF and non-UTF-8 content. +- Cancelling or timing out killed only a shim while the agent kept running; workers that died were + never noticed; a late worker could overwrite a cancellation. +- The Claude plugin manifest was invalid and could not load; hooks used the wrong settings shape, + silently wrote into Claude's context and could exit with the "block" code; the installer nested + directories on re-run and left half-installs; launchers acted on the Clodex checkout instead of + your repo; user-scope MCP servers could not find the project. + +## 0.1.1 + +Hotfix: unblock real runs (`codex exec` flags, Claude result envelope, `package.json`), default to +`gpt-6.1-sol`, and warn when `CLODEX.md` still pins the retiring `gpt-5.5`. + +## 0.1.0 + +Initial release. diff --git a/CLODEX.md b/CLODEX.md index 04cb9c6..b3a86fc 100644 --- a/CLODEX.md +++ b/CLODEX.md @@ -23,7 +23,6 @@ codex: 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 diff --git a/README.md b/README.md index 2e3c341..0e5e552 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ clodex status | Git | diff hashing and repository state | | Claude Code CLI | planning and Claude audit | | Codex CLI | implementation and Codex audit | +| Node 18+ (optional) | the npm launcher and the Claude Code plugin's MCP server | +| PyYAML (bundled) | parses `CLODEX.md`; pip installs depend on it, npm installs ship a vendored copy | Subscription CLI auth is the default: @@ -171,7 +173,7 @@ PowerShell: | `clodex task start/get/cancel/list` | Manage durable async runs | | `clodex trace export ` | Print a run trace as JSONL | | `clodex hooks print/install/uninstall/ingest` | Manage the Claude Code hook integration (`install --scope local\|project\|user`) | -| `clodex eval run` | Run local harness smoke evals | +| `clodex eval run` | Offline self-test of the harness (config, command lines, schemas, state, worktrees, native setup, hooks); calls no agents and writes nothing to your repo | | `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 | @@ -197,7 +199,7 @@ codex: approval_profile: ci workspace: backend: git-worktree - apply_mode: manual + apply_mode: manual # auto: apply an approved worktree build to your checkout automatically max_fix_loops: 2 ``` @@ -307,7 +309,8 @@ The server speaks MCP `2025-11-25` (and negotiates down to `2025-06-18`, `2025-0 `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. + The pre-spec `tasks/update` method is gone. Set `mcp.async_tasks: false` in `CLODEX.md` to + turn tasks (and `clodex_task_start`) off. - `clodex_audit` accepts `base` (everything since that ref diverged) and `commit` (one commit) as well as the default uncommitted diff. diff --git a/clodex/__init__.py b/clodex/__init__.py index 402555d..0f8cdc4 100644 --- a/clodex/__init__.py +++ b/clodex/__init__.py @@ -1,3 +1,3 @@ """Clodex local Claude Code + Codex CLI orchestration.""" -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/clodex/cli.py b/clodex/cli.py index 82de9f2..caf531d 100644 --- a/clodex/cli.py +++ b/clodex/cli.py @@ -391,6 +391,8 @@ def print_output(data: dict[str, Any], as_json: bool) -> None: print(json.dumps(data, indent=2, sort_keys=True)) elif "diagnostics" in data and "python" in data: print("\n".join(format_doctor(data))) + elif "scenarios" in data and "passed" in data: + print("\n".join(format_evals(data))) else: print("\n".join(format_human(data)) or "(nothing to show)") @@ -427,6 +429,12 @@ def format_human(data: Any, indent: int = 0) -> list[str]: return lines +def format_evals(data: dict[str, Any]) -> list[str]: + lines = [f"{'[ok] ' if item['passed'] else '[FAIL]'} {item['name']}: {item['detail']}" for item in data["scenarios"]] + failed = sum(1 for item in data["scenarios"] if not item["passed"]) + return [*lines, "", "All checks passed." if data["passed"] else f"{failed} check(s) failed."] + + def format_doctor(data: dict[str, Any]) -> list[str]: def mark(ok: bool) -> str: return "[ok] " if ok else "[FAIL]" diff --git a/clodex/config.py b/clodex/config.py index 8dcce5f..4062786 100644 --- a/clodex/config.py +++ b/clodex/config.py @@ -40,7 +40,6 @@ "audit": { "quorum": "unanimous", "max_diff_bytes": 200000, - "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}, diff --git a/clodex/evals.py b/clodex/evals.py index 96d3384..a36bb70 100644 --- a/clodex/evals.py +++ b/clodex/evals.py @@ -1,18 +1,181 @@ +"""`clodex eval run`: an offline self-test of the harness itself. + +Everything runs in a throwaway directory and never calls Claude or Codex, so it is fast, free and +safe to run anywhere (CI included). It checks that the pieces Clodex relies on still fit together: +your CLODEX.md, the command lines it builds, the JSON schemas, the state store, workspaces, native +setup and hooks. +""" + from __future__ import annotations +import json +import subprocess +import tempfile +from collections.abc import Callable from pathlib import Path from typing import Any -from .config import load_config +from .commands import claude_audit_command, claude_plan_command, codex_exec_command, codex_review_command +from .config import ClodexConfig, load_config +from .hooks import HOOK_EVENTS, hook_config, merge_hooks +from .jsonutil import AgentEnvelopeError, extract_json_object +from .models import validate +from .native import plan_native_install +from .quorum import evaluate, quorum_met +from .schemas import SCHEMA_NAMES, load_schema, schema_path from .state import StateStore +from .workspace import WorkspaceManager + + +class EvalFailure(Exception): + pass + + +def _check(condition: bool, message: str) -> None: + if not condition: + raise EvalFailure(message) + + +def _config_valid(config: ClodexConfig, _tmp: Path) -> str: + errors = [d for d in validate(config) if d.level == "error"] + _check(not errors, "; ".join(f"{d.where}: {d.message}" for d in errors)) + return "CLODEX.md settings are valid" + + +def _reviewers_and_quorum(config: ClodexConfig, _tmp: Path) -> str: + required = [r for r in config.reviewers if r.get("required", True)] + _check(bool(required), "no required reviewers configured") + _check(not quorum_met(0, len(required), config.audit.get("quorum", "unanimous")), "an empty approval set must never satisfy the quorum") + _check(quorum_met(len(required), len(required), config.audit.get("quorum", "unanimous")), "all required reviewers approving must satisfy the quorum") + verdicts = [{"reviewer_id": str(r["id"]), "approved": True, "diff_hash": "h", "_required": bool(r.get("required", True))} for r in config.reviewers] + _check(evaluate(verdicts, "h", 0, config.audit.get("quorum", "unanimous"))["approved"], "unanimous approval was not accepted") + return f"{len(required)} required reviewer(s), quorum {config.audit.get('quorum', 'unanimous')}" + + +def _commands(config: ClodexConfig, tmp: Path) -> str: + plan, audit = claude_plan_command(config), claude_audit_command(config) + build, review = codex_exec_command(config, tmp), codex_review_command(config, tmp) + for command in (plan, audit): + _check(command.argv[:2] == ["claude", "-p"] and "--effort" in command.argv and "--output-format" in command.argv, f"{command.name}: unexpected argv") + _check("--ask-for-approval" not in build.argv, "codex exec no longer accepts --ask-for-approval") + _check(build.argv[:2] == ["codex", "exec"] and build.argv[-1] == "-", "codex-build: unexpected argv") + _check(review.argv[:2] == ["codex", "exec"] and "read-only" in review.argv, "codex-audit must run read-only") + if "--output-schema" in review.argv: + _check(Path(review.argv[review.argv.index("--output-schema") + 1]).is_file(), "the audit schema file is missing") + return "claude plan/audit and codex build/audit command lines are well-formed" + + +def _strict(schema: dict[str, Any], path: str = "$") -> None: + if schema.get("type") == "object": + _check(schema.get("additionalProperties") is False, f"{path}: additionalProperties must be false") + _check(set(schema.get("required", [])) == set(schema.get("properties", {})), f"{path}: every property must be required") + for name, sub in schema.get("properties", {}).items(): + _strict(sub, f"{path}.{name}") + if schema.get("type") == "array": + _strict(schema["items"], f"{path}[]") + + +def _schemas(_config: ClodexConfig, _tmp: Path) -> str: + for name in SCHEMA_NAMES: + _check(schema_path(name).is_file(), f"{name}: schema file missing") + schema = load_schema(name) + _check("$schema" not in schema, f"{name}: Claude rejects the draft 2020-12 $schema URI") + _strict(schema, name) + return f"{len(SCHEMA_NAMES)} schemas are strict-mode compatible" + + +def _output_parsing(_config: ClodexConfig, _tmp: Path) -> str: + envelope = {"type": "result", "is_error": False, "result": '{"ok": true}'} + _check(extract_json_object(json.dumps(envelope)) == {"ok": True}, "a Claude result envelope was not unwrapped") + _check(extract_json_object(json.dumps({**envelope, "structured_output": {"ok": 1}})) == {"ok": 1}, "structured_output is not preferred") + try: + extract_json_object(json.dumps({"type": "result", "is_error": True, "result": "Not logged in"})) + except AgentEnvelopeError: + pass + else: + raise EvalFailure("an error envelope was accepted") + return "Claude result envelopes and fenced JSON are handled" + + +def _state_roundtrip(_config: ClodexConfig, tmp: Path) -> str: + store = StateStore(tmp / "state.sqlite3") + store.create_handoff("h", "task") + question = store.add_clarification("h", "codex", "Which one?") + store.answer_clarification("h", question["id"], "This one", "claude") + store.update_handoff("h", actor="claude", diff_hash="d", report={"approved": True}) + delegation = store.start_delegation("h", "implement", "go") + store.update_delegation(delegation["id"], status="completed") + data = store.get_handoff("h") + _check(store.schema_version() >= 4, "state schema is out of date") + _check(bool(data and data["delegations"] and not data["open_clarifications"]), "the handoff ledger did not round-trip") + return f"schema v{store.schema_version()}: handoff, clarification and delegation round-trip" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, capture_output=True, check=True) + + +def _workspaces(config: ClodexConfig, tmp: Path) -> str: + repo = tmp / "ws-repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "eval@example.com") + _git(repo, "config", "user.name", "eval") + (repo / "a.txt").write_text("a\n", encoding="utf-8") + _git(repo, "add", "a.txt") + _git(repo, "commit", "-q", "-m", "seed") + scratch = ClodexConfig(repo_root=repo, raw={"workspace_root": ".clodex/workspaces"}) + manager = WorkspaceManager(repo, scratch) + workspace = manager.prepare("eval-run", "git-worktree") + _check(workspace.path.is_dir() and workspace.is_worktree, "git worktree was not created") + _check(manager.release(workspace.path) and not workspace.path.exists(), "git worktree was not removed") + return "git worktrees can be created and removed" + + +def _native_setup(_config: ClodexConfig, tmp: Path) -> str: + repo = tmp / "native-repo" + repo.mkdir() + plan = plan_native_install(repo, dry_run=True) + errors = [item for item in plan["files"] if item["action"] == "error"] + _check(not errors, "; ".join(item.get("error", "") for item in errors)) + _check(len(plan["files"]) >= 5, "expected CLAUDE.md, AGENTS.md, CLODEX.md, .mcp.json and .codex/config.toml") + return f"`clodex init` would manage {len(plan['files'])} files" + + +def _hooks(_config: ClodexConfig, _tmp: Path) -> str: + config = hook_config() + _check(set(config["hooks"]) == set(HOOK_EVENTS), "hook config does not cover the hook events") + once = merge_hooks({"theme": "dark"}) + _check(merge_hooks(once) == once and once["theme"] == "dark", "merging hooks is not idempotent or lost settings") + _check(merge_hooks(once, remove=True) == {"theme": "dark"}, "removing hooks left something behind") + return f"{len(HOOK_EVENTS)} hook events, merge/remove are idempotent" + + +SCENARIOS: list[tuple[str, Callable[[ClodexConfig, Path], str]]] = [ + ("config-valid", _config_valid), + ("reviewers-and-quorum", _reviewers_and_quorum), + ("command-lines", _commands), + ("json-schemas", _schemas), + ("agent-output-parsing", _output_parsing), + ("state-roundtrip", _state_roundtrip), + ("git-worktrees", _workspaces), + ("native-setup", _native_setup), + ("hooks", _hooks), +] def run_local_evals(repo_root: Path | None = None) -> dict[str, Any]: + """Run every scenario; one failing scenario never stops the others.""" config = load_config(repo_root) - store = StateStore(config.state_path) - scenarios = [ - {"name": "config-loads", "passed": config.workspace["backend"] in {"git-worktree", "local"}}, - {"name": "state-schema-v2", "passed": store.schema_version() >= 2}, - {"name": "reviewers-configured", "passed": len(config.reviewers) >= 2}, - ] + scenarios: list[dict[str, Any]] = [] + with tempfile.TemporaryDirectory(prefix="clodex-eval-") as tmp: + for name, check in SCENARIOS: + scratch = Path(tmp) / name + scratch.mkdir() + try: + scenarios.append({"name": name, "passed": True, "detail": check(config, scratch)}) + except EvalFailure as exc: + scenarios.append({"name": name, "passed": False, "detail": str(exc)}) + except Exception as exc: # noqa: BLE001 - report the scenario as failed, with why + scenarios.append({"name": name, "passed": False, "detail": f"{type(exc).__name__}: {exc}"}) return {"passed": all(item["passed"] for item in scenarios), "scenarios": scenarios} diff --git a/clodex/mcp_server.py b/clodex/mcp_server.py index 679ed8d..5f91504 100644 --- a/clodex/mcp_server.py +++ b/clodex/mcp_server.py @@ -13,7 +13,7 @@ from typing import Any from . import __version__ -from .config import resolve_repo_root +from .config import load_config, resolve_repo_root from .delegate import DelegationManager, WaitAborted from .quorum import evaluate_handoff, resolve_reviewer from .schemas import validate as validate_schema @@ -324,6 +324,7 @@ def __init__(self, out: Any = None, workers: int = 8): self.out = out or sys.stdout self.workers = workers self.version = LATEST_PROTOCOL_VERSION + self.async_tasks = True # mcp.async_tasks in CLODEX.md; read when the client initializes self._write_lock = threading.Lock() self._closing = threading.Event() self._inflight: dict[Any, threading.Event] = {} @@ -421,7 +422,7 @@ def run_request(self, request_id: Any, method: str, params: Any) -> None: @property def tasks_enabled(self) -> bool: - return self.version >= TASKS_MIN_VERSION + return self.version >= TASKS_MIN_VERSION and self.async_tasks def handle(self, method: str, params: Any, cancelled: threading.Event) -> dict[str, Any]: if params is not None and not isinstance(params, dict): @@ -452,6 +453,7 @@ def initialize(self, params: dict[str, Any]) -> dict[str, Any]: raise RpcError(INVALID_PARAMS, "initialize requires a protocolVersion") # Echo the client's version if we speak it, else answer with our newest. self.version = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else LATEST_PROTOCOL_VERSION + self.async_tasks = async_tasks_enabled(resolve_repo_root()) capabilities: dict[str, Any] = {"tools": {"listChanged": False}} if self.tasks_enabled: capabilities["tasks"] = {"list": {}, "cancel": {}, "requests": {"tools": {"call": {}}}} @@ -585,6 +587,8 @@ def tool_call(name: str, arguments: dict[str, Any], should_stop: Callable[[], bo workflow.state.update_task(str(arguments["id"]), str(arguments["status"])) return {"content": [{"type": "text", "text": "task updated"}], "isError": False} elif name == "clodex_task_start": + if not async_tasks_enabled(root): + return call_text("async tasks are disabled (mcp.async_tasks is false in CLODEX.md)", is_error=True) result = TaskManager(root).start( str(arguments["task"]), workspace_backend=arguments.get("workspace"), @@ -769,6 +773,14 @@ def tool_call(name: str, arguments: dict[str, Any], should_stop: Callable[[], bo return {"content": [{"type": "text", "text": json.dumps(result.__dict__, indent=2)}], "isError": result.status == "blocked"} +def async_tasks_enabled(root: Path) -> bool: + """`mcp.async_tasks` in CLODEX.md (default on). A broken config must not take the server down.""" + try: + return bool(load_config(root).mcp.get("async_tasks", True)) + except Exception: # noqa: BLE001 + return True + + def brief_handoff(state: Any, run_id: str) -> dict[str, Any]: """The few handoff fields an orchestrator needs right after delegating.""" data = state.get_handoff(run_id) diff --git a/clodex/models.py b/clodex/models.py index dc992bc..6a48fcd 100644 --- a/clodex/models.py +++ b/clodex/models.py @@ -240,6 +240,8 @@ def validate(config: Any, catalog: dict[str, dict[str, Any]] | None = None, on: found.append(Diagnostic("error", "codex.sandbox", f"unknown sandbox '{codex.get('sandbox')}'", "use read-only, workspace-write or danger-full-access")) if str(codex.get("approval_profile")) not in {"ci", "local", "auto_review"}: found.append(Diagnostic("error", "codex.approval_profile", f"unknown approval profile '{codex.get('approval_profile')}'", "use ci, local or auto_review")) + if str(config.workspace.get("apply_mode", "manual")) not in {"manual", "auto"}: + found.append(Diagnostic("error", "workspace.apply_mode", f"unknown apply mode '{config.workspace.get('apply_mode')}'", "use manual or auto")) if str(config.workspace.get("backend")) not in {"git-worktree", "local"}: found.append(Diagnostic("error", "workspace.backend", f"unknown workspace backend '{config.workspace.get('backend')}'", "use git-worktree or local")) diff --git a/clodex/workflow.py b/clodex/workflow.py index b7b2824..29674a4 100644 --- a/clodex/workflow.py +++ b/clodex/workflow.py @@ -269,7 +269,7 @@ def _build_steps( self._include_untracked(workspace.path) patch = artifacts.write_text("apply.patch", current_diff(workspace.path), exact=True) result.data["apply_patch"] = str(patch) - if apply_changes: + if apply_changes or str(self.config.workspace.get("apply_mode", "manual")) == "auto": return self.apply_run(run_id) return result diff --git a/install.sh b/install.sh index c114225..128fa9b 100644 --- a/install.sh +++ b/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SCRIPT_VERSION="0.1.1" +SCRIPT_VERSION="0.2.0" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="$HOME/.clodex" BIN_DIR="$HOME/.local/bin" diff --git a/package.json b/package.json index 5008330..802b13b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clodex", - "version": "0.1.1", + "version": "0.2.0", "description": "Native Claude Code and Codex collaboration for local-first agent workflows", "license": "UNLICENSED", "repository": { @@ -38,6 +38,7 @@ "dist", "CLODEX.md", "README.md", + "CHANGELOG.md", "pyproject.toml" ], "engines": { diff --git a/skills/clodex-workflow/SKILL.md b/skills/clodex-workflow/SKILL.md index 2d5c7fb..a569b2d 100644 --- a/skills/clodex-workflow/SKILL.md +++ b/skills/clodex-workflow/SKILL.md @@ -6,27 +6,43 @@ description: Use when coordinating Claude Code CLI planning with Codex CLI imple # Clodex Workflow Prefer native Clodex coordination when the repository has been initialized with -`clodex init`: use the available MCP handoff tools first, and use -`clodex task ...`, `clodex audit --diff`, and `clodex status` as fallbacks. The -older harness commands remain valid for scripted or non-MCP workflows. +`clodex init`: use the MCP handoff tools first, and the `clodex` CLI as the fallback. The +harness commands remain valid for scripted or non-MCP workflows. -Harness commands: +## Native handoffs (MCP) + +1. `clodex_handoff_create` with `workspace: "git-worktree"` for an isolated checkout. +2. Record the plan with `clodex_handoff_update`. +3. `clodex_delegate` mode `implement` (`instructions` = the plan; `wait: true` or poll + `clodex_handoff_get`). Codex works in the worktree and its result is recorded for you. +4. Review the diff and record your own verdict with `clodex_handoff_update` (actor `claude`, the + diff hash from `clodex_handoff_get`, `report.approved`, `report.required_fixes`). +5. `clodex_delegate` mode `audit` for Codex's verdict; if anyone rejected, mode `fix` and audit again. +6. If `clodex_handoff_get` shows `open_clarifications`, answer them with `clodex_answer` and + delegate again; answers are included in Codex's next prompt. +7. `clodex_handoff_decide`; only an `approved` decision means the configured reviewers agree on + this exact diff. Then `clodex apply `. + +## Harness commands 1. `clodex plan ""` asks Claude Code CLI to produce a structured plan. 2. `clodex build ""` runs Claude planning, Codex implementation, and multi-reviewer audit in an isolated worktree. -3. `clodex apply ` applies an approved worktree patch back to the source checkout. -4. `clodex task start/get/cancel/list` manages durable async runs. -5. `clodex audit --diff` audits the current uncommitted diff with both Claude and Codex. -6. `clodex status` shows recent local task and run state. +3. `clodex apply ` applies an approved worktree patch back to the source checkout (`--check` to dry-run). +4. `clodex clean ` removes the kept worktree of a finished run. +5. `clodex task start/get/cancel/list` manages durable async runs. +6. `clodex audit [--diff | --base REF | --commit SHA]` audits the uncommitted diff, a branch, or one commit with both Claude and Codex. +7. `clodex doctor` checks CLIs, login, models and settings; `clodex status` shows recent runs. + +Exit codes: `0` ok, `1` blocked or refused, `2` usage/config error, `3` failed, `4` cancelled. -Defaults: +## Defaults - Claude planner: `claude -p --model opus --effort max --permission-mode plan` (JSON via `--json-schema`). - Claude auditor: the same with `--effort high`. Set `claude.plan.*` / `claude.audit.*` in `CLODEX.md` to change either. -- Codex engineer/auditor: `codex exec` (builds and audits) with `gpt-6.1-sol` and `model_reasoning_effort="xhigh"`. +- Codex engineer/auditor: `codex exec` with `gpt-6.1-sol` and `model_reasoning_effort="xhigh"`. - Builds default to `.clodex/workspaces//`; use `--workspace local` only when in-place changes are intentional. - Approval profiles are `ci`, `local`, and `auto_review`; `ci` is the deterministic default. - Subscription CLI auth is preferred. API keys are fallback-only for CI/headless use. Do not mark a run complete unless `.clodex/runs//05-agreement.json` -contains `approved: true`. +contains `approved: true`, or `clodex_handoff_decide` returned `approved`. diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..79f8a85 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,139 @@ +"""Tests: release-level behavior (apply_mode, mcp.async_tasks, eval self-test, changelog).""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +from clodex import __version__, evals +from clodex.config import DEFAULT_CONFIG, load_config +from clodex.evals import SCENARIOS, run_local_evals +from clodex.models import validate +from clodex.workflow import ClodexWorkflow +from tests.support import ROOT, FakeCliPath, TempRepo +from tests.test_mcp_conformance import ServerCase + + +def write_contract(repo: Path, front_matter: str, commit: bool = False) -> None: + (repo / "CLODEX.md").write_text(f"---\n{front_matter}---\nbody\n", encoding="utf-8") + if commit: # the worktree backend refuses a checkout with uncommitted tracked changes + subprocess.run(["git", "add", "CLODEX.md"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", "contract"], cwd=repo, check=True, capture_output=True) + + +class ApplyModeTests(unittest.TestCase): + def test_auto_applies_an_approved_worktree_build_and_manual_does_not(self): + with TempRepo() as repo, FakeCliPath(): + write_contract(repo, "workspace:\n apply_mode: auto\n", commit=True) + result = ClodexWorkflow(repo).build("implement fixture", workspace_backend="git-worktree") + self.assertEqual(result.status, "applied") + self.assertTrue((repo / "implemented.txt").is_file()) + with TempRepo() as repo, FakeCliPath(): + write_contract(repo, "workspace:\n apply_mode: manual\n", commit=True) + result = ClodexWorkflow(repo).build("implement fixture", workspace_backend="git-worktree") + self.assertEqual(result.status, "approved") + self.assertFalse((repo / "implemented.txt").exists()) + + def test_auto_never_applies_a_blocked_build(self): + with TempRepo() as repo, FakeCliPath(reject_reviewers=("codex-architecture",)): + write_contract(repo, "max_fix_loops: 0\nworkspace:\n apply_mode: auto\n", commit=True) + result = ClodexWorkflow(repo).build("implement fixture", workspace_backend="git-worktree") + self.assertEqual(result.status, "blocked") + self.assertFalse((repo / "implemented.txt").exists()) + + def test_unknown_apply_mode_is_a_doctor_error(self): + with TempRepo() as repo: + write_contract(repo, "workspace:\n apply_mode: sometimes\n") + found = [d for d in validate(load_config(repo)) if d.level == "error"] + self.assertEqual([d.where for d in found], ["workspace.apply_mode"]) + + +class AsyncTasksSwitchTests(ServerCase): + def test_disabled_removes_the_tasks_capability_and_the_task_tool(self): + write_contract(self.repo, "mcp:\n async_tasks: false\n") + live = self.start(initialize=False) + result = live.call("initialize", {"protocolVersion": "2025-11-25"})["result"] + self.assertNotIn("tasks", result["capabilities"]) + self.assertEqual(live.call("tasks/list")["error"]["code"], -32601) + ignored = live.call("tools/call", {"name": "clodex_build", "arguments": {"task": "x", "dry_run": True}, "task": {"ttl": 1}}) + self.assertFalse(ignored["result"]["isError"], "task metadata is ignored, the call runs normally") + started = live.tool("clodex_task_start", {"task": "x"})["result"] + self.assertTrue(started["isError"]) + self.assertIn("mcp.async_tasks", started["content"][0]["text"]) + + def test_enabled_by_default_and_a_broken_config_does_not_take_the_server_down(self): + live = self.start(initialize=False) + self.assertIn("tasks", live.call("initialize", {"protocolVersion": "2025-11-25"})["result"]["capabilities"]) + (self.repo / "CLODEX.md").write_text("---\nclaude: [unterminated\n---\n", encoding="utf-8") + broken = self.start(initialize=False).call("initialize", {"protocolVersion": "2025-11-25"}) + self.assertIn("tasks", broken["result"]["capabilities"], "falls back to the default instead of failing initialize") + write_contract(self.repo, "") # leave a valid file: the test cleanup loads the config too + + +class EvalTests(unittest.TestCase): + def test_every_scenario_passes_on_a_clean_repo_and_nothing_is_written_to_it(self): + with TempRepo() as repo: + before = {p.name for p in repo.iterdir()} + data = run_local_evals(repo) + failing = [s for s in data["scenarios"] if not s["passed"]] + self.assertTrue(data["passed"], failing) + self.assertEqual([s["name"] for s in data["scenarios"]], [name for name, _ in SCENARIOS]) + self.assertTrue(all(s["detail"] for s in data["scenarios"])) + self.assertEqual({p.name for p in repo.iterdir()}, before, "the self-test must not touch the repo (no .clodex state)") + + def test_a_bad_config_fails_its_scenario_but_the_rest_still_run(self): + with TempRepo() as repo: + write_contract(repo, 'audit:\n reviewers: [{"id": "a", "backend": "claude", "required": false}]\n') + data = run_local_evals(repo) + by_name = {s["name"]: s for s in data["scenarios"]} + self.assertFalse(data["passed"]) + self.assertFalse(by_name["config-valid"]["passed"]) + self.assertIn("no required reviewers", by_name["config-valid"]["detail"]) + self.assertFalse(by_name["reviewers-and-quorum"]["passed"]) + self.assertTrue(by_name["json-schemas"]["passed"] and by_name["hooks"]["passed"]) + + def test_a_scenario_that_crashes_is_reported_not_raised(self): + def explode(config, tmp): + raise KeyError("boom") + + patched = [("explodes", explode), *SCENARIOS[:1]] + with TempRepo() as repo, mock.patch.object(evals, "SCENARIOS", patched): + data = run_local_evals(repo) + self.assertEqual([s["passed"] for s in data["scenarios"]], [False, True]) + self.assertIn("KeyError", data["scenarios"][0]["detail"]) + + def test_cli_output_and_exit_code(self): + with TempRepo() as repo: + result = subprocess.run([sys.executable, "-m", "clodex", "eval", "run"], cwd=repo, env={**os.environ, "PYTHONPATH": str(ROOT)}, capture_output=True, text=True, stdin=subprocess.DEVNULL) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("[ok] config-valid", result.stdout) + self.assertIn("All checks passed.", result.stdout) + write_contract(repo, "audit:\n quorum: most\n") + failed = subprocess.run([sys.executable, "-m", "clodex", "eval", "run"], cwd=repo, env={**os.environ, "PYTHONPATH": str(ROOT)}, capture_output=True, text=True, stdin=subprocess.DEVNULL) + self.assertEqual(failed.returncode, 1) + self.assertIn("[FAIL]", failed.stdout) + self.assertRegex(failed.stdout, r"\d+ check\(s\) failed") + + +class ReleaseHygieneTests(unittest.TestCase): + def test_unused_config_keys_are_gone(self): + self.assertNotIn("personas", DEFAULT_CONFIG["audit"]) + self.assertNotIn("personas", (ROOT / "CLODEX.md").read_text(encoding="utf-8")) + + def test_changelog_leads_with_the_current_version_and_covers_the_upgrade(self): + text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + headings = re.findall(r"(?m)^## (\S+)", text) + self.assertEqual(headings[0], __version__, "newest release first") + self.assertEqual(len(headings), len(set(headings))) + for must in ("clodex doctor", "init --migrate", "init --force", "gpt-6.1-sol"): + self.assertIn(must, text) + self.assertIn("CHANGELOG.md", (ROOT / "package.json").read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall.sh b/uninstall.sh index f09e5f3..c2c9f32 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SCRIPT_VERSION="0.1.1" +SCRIPT_VERSION="0.2.0" TARGET_DIR="$HOME/.clodex" BIN_DIR="$HOME/.local/bin" DRY_RUN=false