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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ Native coordination uses these MCP tools:
- `clodex_handoff_get`
- `clodex_handoff_decide`
- `clodex_delegate`
- `clodex_clarify`
- `clodex_answer`
- `clodex_messages`

If MCP is unavailable, use the CLI fallbacks:

Expand Down Expand Up @@ -348,6 +351,21 @@ non-interactive `codex exec` has no MCP access to report for itself:
the actor (`claude` / `codex`) stands for the first *required* reviewer of that backend, so the
default two-reviewer flow needs no extra fields. An unknown `reviewer_id` is rejected. The
decision lists `required_pending` and `approved_reviewers` so the orchestrator knows whom to ask.
- **Clarifications.** Either agent can ask the other a question instead of guessing:
`clodex_clarify` (`run_id`, `actor`, `question`) opens it, `clodex_answer` (`run_id`,
`message_id`, `answer`, `actor`; the asker cannot answer its own question) closes it, and
`clodex_messages` lists them (`status`: open / answered / delivered). While a question is open
`clodex_handoff_get` shows it under `open_clarifications` and the turn passes to whoever must
answer, and `clodex_handoff_decide` returns `needs_fix` even if the reviewers agree. When
Codex is run by `clodex_delegate` it is told to end its final message with
`{"clarifications": ["..."]}` instead of guessing; the worker posts those as questions, and the
answers are put into Codex's next delegation prompt (then marked `delivered`). A question
handed back counts as a handoff against the budget.
- **Guards.** `clodex_handoff_decide` refuses while a delegation is still running, so you never
decide on a diff that is changing. `actor` and `owner` must be `claude` or `codex` (case is
normalized); anything else is rejected rather than recorded. Turns are *not* strictly
alternated: an orchestrator legitimately records several updates in a row, so only the worker's
own handoffs spend budget automatically (interactive agents still pass `increment_handoff`).
- **Workspaces.** `clodex_handoff_create` takes `workspace: "git-worktree" | "local" | "none"`
(default `none`). A worktree is an isolated checkout under `.clodex/workspaces/<run-id>` (the
repo must have no uncommitted changes to tracked files); if the handoff cannot be recorded the
Expand Down
42 changes: 41 additions & 1 deletion clodex/delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import time
Expand All @@ -29,12 +31,41 @@

POLL_SECONDS = 0.5
SUMMARY_LIMIT = 4000
MAX_CLARIFICATIONS = 10
_FENCED_JSON = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)


class WaitAborted(Exception):
"""The caller stopped waiting (cancelled request or closed connection)."""


def extract_clarifications(text: str) -> list[str]:
"""Questions Codex asked instead of guessing: `{"clarifications": ["...", ...]}` in its final message.

Entries may be strings or `{"question": "..."}`. Anything else (including other JSON objects
in a report that quotes code) is ignored; at most MAX_CLARIFICATIONS are kept.
"""
candidates = list(_FENCED_JSON.findall(text))
for match in re.finditer(r"\{", text): # the last JSON object in the message, fenced or not
candidates.append(text[match.start():])
for candidate in reversed(candidates):
try:
payload, _ = json.JSONDecoder().raw_decode(candidate.lstrip())
except ValueError:
continue
items = payload.get("clarifications") if isinstance(payload, dict) else None
if not isinstance(items, list):
continue
questions = []
for item in items:
question = item.get("question") if isinstance(item, dict) else item
if isinstance(question, str) and question.strip():
questions.append(question.strip())
if questions:
return questions[:MAX_CLARIFICATIONS]
return []


def delegation_worker_state(delegation: dict[str, Any]) -> str:
return worker_state(delegation.get("pid"), delegation.get("heartbeat_at"), delegation.get("started_at") or delegation.get("created_at"))

Expand Down Expand Up @@ -236,14 +267,22 @@ def _execute(config: ClodexConfig, state: StateStore, run_id: str, delegation: d
return _audit(config, state, run_id, delegation, run, runner, artifacts, workspace, instructions, task)

fixes = open_fixes(data, config.reviewers) if mode == "fix" else None
prompt = delegate_prompt(mode, task, instructions, fixes)
answered = state.answered_clarifications(run_id)
prompt = delegate_prompt(mode, task, instructions, fixes, [(item["question"], item["answer"]) for item in answered])
result = runner.run(codex_exec_command(config, workspace, approval_profile=delegation.get("approval_profile")), prompt)
report_path = artifacts.write_text(f"delegation-{delegation['id']}-codex.md", _report_text(result.stdout, result.stderr))
if not result.ok:
raise RuntimeError(f"codex exited with code {result.returncode}: {(result.stderr or result.stdout).strip()[:500]}")
diff, diff_hash = _prepare_diff(workspace, config.repo_root)
diff_path = artifacts.write_text("changes.diff", diff, exact=True)
if answered:
state.mark_delivered([int(item["id"]) for item in answered]) # Codex has now seen them
summary = result.stdout.strip() or "(codex printed no report)"
questions = extract_clarifications(result.stdout)
for question in questions: # before the handoff update: that may block the handoff, which then takes no messages
state.add_clarification(run_id, "codex", question)
if questions and not diff.strip():
summary = "Codex needs clarification: " + "; ".join(questions)
state.update_handoff(
run_id,
phase="implementation" if mode == "implement" else "fix",
Expand All @@ -253,6 +292,7 @@ def _execute(config: ClodexConfig, state: StateStore, run_id: str, delegation: d
report={
"summary": summary[:SUMMARY_LIMIT],
"changed": bool(diff.strip()),
**({"clarifications": questions} if questions else {}),
"delegation": delegation["id"],
"mode": mode,
"artifacts": [str(report_path), str(diff_path)],
Expand Down
71 changes: 71 additions & 0 deletions clodex/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .delegate import DelegationManager, WaitAborted
from .quorum import evaluate_handoff, resolve_reviewer
from .schemas import validate as validate_schema
from .state import TERMINAL_STATUSES
from .tasks import TaskManager
from .workflow import ClodexWorkflow
from .workspace import DirtyWorkspaceError, WorkspaceManager
Expand Down Expand Up @@ -166,6 +167,40 @@
"required": ["run_id"],
},
},
{
"name": "clodex_clarify",
"title": "Ask a clarifying question",
"description": "Ask the other agent a question about a handoff instead of guessing. It stays open (and blocks approval) until answered with clodex_answer; answers are given to Codex in its next delegation.",
"inputSchema": {
"type": "object",
"properties": {"run_id": {"type": "string"}, "actor": {"type": "string", "enum": ["claude", "codex"]}, "question": {"type": "string"}},
"required": ["run_id", "actor", "question"],
},
},
{
"name": "clodex_answer",
"title": "Answer a clarifying question",
"description": "Answer an open clarification (by message_id, as listed by clodex_handoff_get or clodex_messages). The asker's agent cannot answer its own question.",
"inputSchema": {
"type": "object",
"properties": {"run_id": {"type": "string"}, "message_id": {"type": "integer"}, "answer": {"type": "string"}, "actor": {"type": "string", "enum": ["claude", "codex"]}},
"required": ["run_id", "message_id", "answer", "actor"],
},
},
{
"name": "clodex_messages",
"title": "List handoff messages",
"description": "List a handoff's clarification messages, optionally filtered by status (open, answered, delivered, final) or kind (clarification, answer).",
"inputSchema": {
"type": "object",
"properties": {
"run_id": {"type": "string"},
"status": {"type": "string", "enum": ["open", "answered", "delivered", "final"]},
"kind": {"type": "string", "enum": ["clarification", "answer"]},
},
"required": ["run_id"],
},
},
{
"name": "clodex_handoff_decide",
"title": "Decide Clodex handoff",
Expand Down Expand Up @@ -618,6 +653,26 @@ def tool_call(name: str, arguments: dict[str, Any], should_stop: Callable[[], bo
except (KeyError, TypeError, ValueError, sqlite3.IntegrityError) as exc:
return call_text(expected_handoff_error(exc), is_error=True)
return call_json(run, is_error=run.get("status") == "blocked")
elif name == "clodex_clarify":
try:
message = workflow.state.add_clarification(str(arguments["run_id"]), str(arguments["actor"]), str(arguments["question"]))
except ValueError as exc:
return call_text(str(exc), is_error=True)
return call_json(message)
elif name == "clodex_answer":
try:
message = workflow.state.answer_clarification(str(arguments["run_id"]), int(arguments["message_id"]), str(arguments["answer"]), str(arguments["actor"]))
except ValueError as exc:
return call_text(str(exc), is_error=True)
return call_json(message)
elif name == "clodex_messages":
run_id = str(arguments["run_id"])
if workflow.state.get_run(run_id) is None:
return call_text(f"Unknown run: {run_id}", is_error=True)
try:
return call_json({"messages": workflow.state.list_messages(run_id, arguments.get("status"), arguments.get("kind"))})
except ValueError as exc:
return call_text(str(exc), is_error=True)
elif name == "clodex_delegate":
manager = DelegationManager(root)
try:
Expand Down Expand Up @@ -652,8 +707,11 @@ def tool_call(name: str, arguments: dict[str, Any], should_stop: Callable[[], bo
if data is None:
return call_text(f"Unknown run: {run_id}", is_error=True)

DelegationManager(root).reconcile_active(run_id)
run = data["run"]
status = run.get("status")
if status not in TERMINAL_STATUSES and workflow.state.active_delegation(run_id) is not None:
return call_text("a delegation is still running for this handoff; wait for it to finish before deciding", is_error=True)
agreement = evaluate_handoff(data, workflow.config.reviewers, workflow.config.audit.get("quorum", "unanimous"))
if status == "approved":
decision = {"decision": "approved", "run_id": run["id"], "diff_hash": run.get("diff_hash")}
Expand All @@ -666,6 +724,19 @@ def tool_call(name: str, arguments: dict[str, Any], should_stop: Callable[[], bo
"blocked_reason": run.get("blocked_reason") or run.get("error"),
}
is_error = True
elif data["open_clarifications"]:
# Reviewers may agree, but a question nobody has answered means the work is not settled.
decision = {
"decision": "needs_fix",
"run_id": run["id"],
"reason": "open clarifications must be answered first",
"open_clarifications": [{"message_id": m["id"], "asked_by": m["actor"], "question": m["body"]} for m in data["open_clarifications"]],
"next_expected_actor": data["next_expected_actor"],
"required_pending": agreement["required_pending"],
"approved_reviewers": agreement["approved_reviewers"],
"quorum": agreement["quorum"],
}
is_error = False
elif agreement["approved"]:
try:
approved_run = workflow.state.approve_handoff(run["id"], agreement["diff_hash"], approved_by=agreement["approved_by"])
Expand Down
4 changes: 2 additions & 2 deletions clodex/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def build_claude_block() -> str:
When a user asks for non-trivial design, implementation, debugging, refactoring, or review work:

1. Prefer the MCP tool `clodex_handoff_create` to create or resume a durable handoff. Pass `workspace: "git-worktree"` so Codex works in an isolated checkout.
2. Use `clodex_handoff_get` before acting on an existing handoff; it shows the budget, the next expected actor, and any running delegation.
2. Use `clodex_handoff_get` before acting on an existing handoff; it shows the budget, the next expected actor, any running delegation, and `open_clarifications`. Answer questions Codex asked with `clodex_answer`, then delegate again: answers are included in Codex's next prompt.
3. Produce the plan, design, acceptance criteria, risks, and test commands before implementation, and record them with `clodex_handoff_update`.
4. Delegate implementation to Codex with `clodex_delegate` (mode `implement`, `instructions` = the accepted plan) instead of telling the user to run Codex. Pass `wait: true`, or poll `clodex_handoff_get`.
5. Review the diff in the handoff's workspace, then record your verdict with `clodex_handoff_update` (actor `claude`, the diff hash from `clodex_handoff_get`, `report.approved`, `report.required_fixes`).
Expand Down Expand Up @@ -94,7 +94,7 @@ def build_agents_block() -> str:
4. Use `clodex_handoff_update` to record changed files, tests run, unresolved issues, audit verdicts, and diff hash.
5. Use `clodex_handoff_create` only when the user asks Codex to start a native collaboration directly.
6. Use `clodex_handoff_decide` before claiming completion after implementation or audit work.
7. Ask for clarification through Clodex when product intent or acceptance criteria are unclear.
7. Ask for clarification with `clodex_clarify` instead of guessing when product intent or acceptance criteria are unclear (when Clodex runs you for a delegation, end your final message with a JSON object `{{"clarifications": ["..."]}}` instead).
8. Respect owner, phase, and handoff budget state. When the budget is exhausted, summarize the disagreement for the user.
9. When Clodex delegates work to you it runs `codex exec` in the handoff's workspace and records your result itself, so you do not need to call the handoff tools in that case; just do the work and finish with a concise report.

Expand Down
12 changes: 10 additions & 2 deletions clodex/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def audit_diff_excerpt(diff: str, max_bytes: int) -> str:
)


def delegate_prompt(mode: str, task: str, instructions: str | None, fixes: list[str] | None = None) -> str:
def delegate_prompt(mode: str, task: str, instructions: str | None, fixes: list[str] | None = None, answers: list[tuple[str, str]] | None = None) -> str:
"""The prompt for a Codex job a native handoff delegated (implement or fix)."""
lines = [
"You are the Codex engineering wave for Clodex, working a native Claude/Codex handoff.",
Expand All @@ -123,11 +123,19 @@ def delegate_prompt(mode: str, task: str, instructions: str | None, fixes: list[
]
if instructions:
lines += ["", "Instructions from Claude:", instructions]
if answers:
lines += ["", "Clarifications from Claude (your earlier questions, now answered):"]
for question, answer in answers:
lines += [f"- Q: {question}", f" A: {answer}"]
if mode == "fix":
lines += ["", "Required fixes:", json.dumps(fixes or ["Resolve the open review findings."], indent=2), "", "Apply only the required fixes. Do not broaden scope."]
lines += [
"",
"When finished, print a concise Markdown report with:",
"If you cannot proceed without a decision from Claude (product intent or acceptance criteria are unclear), "
"do not guess: stop and end your final message with a single JSON object "
'{"clarifications": ["one specific question", "..."]} and nothing after it.',
"",
"Otherwise, when finished, print a concise Markdown report with:",
"- files changed",
"- tests run and pass/fail status",
"- unresolved issues, if any",
Expand Down
Loading
Loading