diff --git a/.codex/skills/ralph-loop-governor/SKILL.md b/.codex/skills/ralph-loop-governor/SKILL.md index 773baf7..fff68d1 100644 --- a/.codex/skills/ralph-loop-governor/SKILL.md +++ b/.codex/skills/ralph-loop-governor/SKILL.md @@ -69,6 +69,8 @@ Use the roadmap directory for active run artifacts: the executor prompt, status, If the user's Claude Code exposes the plugin command as `/ralph-loop`, tell them to use that command name with the same prompt and flags. +Optional Hermes path: when Hermes is available and the user wants unattended TUI unblocking, configure `scripts/ralph-loop-hermes-bridge.py` with the Claude settings and Codex notify fragments in `assets/`. The bridge must be optional: missing Hermes or a missing webhook URL is a successful no-op, and the manual copy/paste flow still works. Hermes is a passive unblocker, not a second active coding governor: it should answer blocking TUI prompts, retry after usage/rate limits, or handle max-iteration/restart handoffs with the minimum safe action needed to keep Codex/Ralph running, then return control to the normal governor/executor loop. + 6. If restarting Ralph to consume corrections, use a quoted prompt: ```text @@ -77,6 +79,7 @@ If the user's Claude Code exposes the plugin command as `/ralph-loop`, tell them ## Monitoring Workflow +- If the optional Hermes bridge is configured, treat Hermes as a passive TUI unblocker between the executor and governor, not as a second active coding governor. It may alert on early stops, blocked language, permission requests, tool failures, usage/rate limits, max-iteration stops, or `COMANDO_RALPH:` handoffs. It may answer a blocking prompt, retry/continue after an appropriate wait, or hand restart state to Codex. It must not proactively review or correct code, choose the next implementation slice merely because a commit landed, replace final gatekeeping, close corrections without evidence, or accept `RALPH_DONE`. - Write the monitor outside the target repo unless the user asks otherwise, for example `~/workspace/--ralph-loop-monitor.md`. - When the user reports "Ralph started", "Loop started", or equivalent, enter the active monitor loop immediately. Do not only record the start. - Use a 5-minute interval by default unless the user requested a different interval. The interval is a real idle wait: stop monitoring work, run no intermediate checks, send no progress updates, and do no parallel review or implementation work until the wait expires. diff --git a/.codex/skills/ralph-loop-governor/assets/hermes-bridge-claude-settings.fragment.json b/.codex/skills/ralph-loop-governor/assets/hermes-bridge-claude-settings.fragment.json new file mode 100644 index 0000000..8668ce4 --- /dev/null +++ b/.codex/skills/ralph-loop-governor/assets/hermes-bridge-claude-settings.fragment.json @@ -0,0 +1,48 @@ +{ + "hooks": { + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py --source claude-code --event Notification --project --session --status-file docs/roadmap//status.md --correction-queue docs/roadmap//correction-queue.md --gates-file docs/roadmap//gates.md" + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py --source claude-code --event PermissionRequest --project --session --status-file docs/roadmap//status.md --correction-queue docs/roadmap//correction-queue.md --gates-file docs/roadmap//gates.md" + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py --source claude-code --event PostToolUseFailure --project --session --status-file docs/roadmap//status.md --correction-queue docs/roadmap//correction-queue.md --gates-file docs/roadmap//gates.md" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 .codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py --source claude-code --event Stop --project --session --status-file docs/roadmap//status.md --correction-queue docs/roadmap//correction-queue.md --gates-file docs/roadmap//gates.md" + } + ] + } + ] + } +} diff --git a/.codex/skills/ralph-loop-governor/assets/hermes-bridge-codex-notify.fragment.toml b/.codex/skills/ralph-loop-governor/assets/hermes-bridge-codex-notify.fragment.toml new file mode 100644 index 0000000..0c60163 --- /dev/null +++ b/.codex/skills/ralph-loop-governor/assets/hermes-bridge-codex-notify.fragment.toml @@ -0,0 +1,16 @@ +# Optional Codex-side notification hook for Ralph Loop governor runs. +# Merge this into the Codex config used for the run, then replace placeholders. +# If RALPH_HERMES_WEBHOOK_URL and .ralph-loop/hermes-webhook-url are absent, +# the bridge exits 0 and the manual workflow is unchanged. + +notify = [ + "python3", + ".codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py", + "--source", "codex", + "--event", "agent-turn-complete", + "--project", "", + "--session", "", + "--status-file", "docs/roadmap//status.md", + "--correction-queue", "docs/roadmap//correction-queue.md", + "--gates-file", "docs/roadmap//gates.md", +] diff --git a/.codex/skills/ralph-loop-governor/assets/ralph-loop-prompt-template.md b/.codex/skills/ralph-loop-governor/assets/ralph-loop-prompt-template.md index b14f79a..9def012 100644 --- a/.codex/skills/ralph-loop-governor/assets/ralph-loop-prompt-template.md +++ b/.codex/skills/ralph-loop-governor/assets/ralph-loop-prompt-template.md @@ -4,6 +4,8 @@ You are implementing `` in this repository. Codex is acting as architect and gatekeeper. It may update correction and gate files while you work. Treat those files as blocking input. +If the optional Hermes negotiator bridge is configured, Claude/Codex hooks may notify Hermes when you stop early, ask a question, hit a permission/tool failure, or appear blocked. Hermes is allowed to help translate between executor instructions and governor decisions, but it is not the final Done judge. + Codex will actively review your code with focused subagents and steer this run through `correction-queue.md`, `gates.md`, and updates to this prompt. Those review findings are part of the implementation contract, not optional advice. Expect Codex to reject `RALPH_DONE` if subagent findings remain open. ## Read First diff --git a/.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py b/.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py new file mode 100755 index 0000000..fbd4315 --- /dev/null +++ b/.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Optional Hermes negotiator bridge for Ralph Loop hooks. + +The bridge is intentionally safe-by-default: +- if Hermes/webhook configuration is absent, it exits 0 and leaves the + normal manual Ralph Loop workflow untouched; +- it only emits attention-worthy events; +- duplicate events are suppressed for a short TTL to prevent hook loops. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ATTENTION_EVENTS = { + "notification", + "permissionrequest", + "posttoolusefailure", + "subagentstop", + "stop", + "agent-turn-complete", +} + +BLOCKED_TERMS = ( + "blocked", + "stuck", + "paused", + "pause", + "waiting for", + "need input", + "needs input", + "needs codex", + "needs governor", + "needs user", + "permission", + "cannot continue", + "can't continue", + "failed", + "error", + "exception", + "unclear", + "question", + "choose option", + "accept or reject", + "comando_ralph", +) + +DONE_MARKERS = ("ralph_done", "ralph_done") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Forward Ralph Loop attention events to Hermes when configured.") + parser.add_argument("--source", default=os.getenv("RALPH_LOOP_SOURCE", "unknown")) + parser.add_argument("--event", default=os.getenv("RALPH_LOOP_EVENT", "unknown")) + parser.add_argument("--summary", default=os.getenv("RALPH_LOOP_SUMMARY", "")) + parser.add_argument("--project", default=os.getenv("RALPH_LOOP_PROJECT")) + parser.add_argument("--session", default=os.getenv("RALPH_LOOP_SESSION")) + parser.add_argument("--workdir", default=os.getenv("RALPH_LOOP_WORKDIR", os.getcwd())) + parser.add_argument("--status-file", default=os.getenv("RALPH_LOOP_STATUS_FILE")) + parser.add_argument("--correction-queue", default=os.getenv("RALPH_LOOP_CORRECTION_QUEUE")) + parser.add_argument("--gates-file", default=os.getenv("RALPH_LOOP_GATES_FILE")) + parser.add_argument("--webhook-url", default=os.getenv("RALPH_HERMES_WEBHOOK_URL") or os.getenv("HERMES_WEBHOOK_URL")) + parser.add_argument("--state-dir", default=os.getenv("RALPH_LOOP_BRIDGE_STATE_DIR")) + parser.add_argument("--ttl-seconds", type=int, default=int(os.getenv("RALPH_LOOP_BRIDGE_TTL_SECONDS", "600"))) + parser.add_argument("--timeout-seconds", type=int, default=int(os.getenv("RALPH_LOOP_BRIDGE_TIMEOUT_SECONDS", "10"))) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--print-payload", action="store_true") + return parser.parse_args(argv) + + +def load_stdin_event(stdin_text: str) -> dict[str, Any]: + text = stdin_text.strip() + if not text: + return {} + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return {"raw": text} + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + + +def first_present(data: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = data.get(key) + if value not in (None, ""): + return value + return None + + +def text_excerpt(data: dict[str, Any], fallback: str, limit: int = 2000) -> str: + candidates = [ + fallback, + first_present(data, "message", "summary", "text", "transcript", "last_message", "notification"), + json.dumps(data, ensure_ascii=False, sort_keys=True) if data else "", + ] + joined = "\n".join(str(c) for c in candidates if c) + return joined[:limit] + + +def infer_project(workdir: Path, explicit: str | None) -> str: + if explicit: + return explicit + return workdir.resolve().name + + +def event_reasons(event: str, excerpt: str) -> list[str]: + normalized_event = event.lower() + normalized_excerpt = excerpt.lower() + if any(marker in normalized_excerpt for marker in DONE_MARKERS): + return [] + + reasons: list[str] = [] + if normalized_event == "stop": + reasons.append("stop_without_completion") + elif normalized_event in ATTENTION_EVENTS: + reasons.append(f"event:{event}") + + if any(term in normalized_excerpt for term in BLOCKED_TERMS): + reasons.append("blocked_language") + + return sorted(set(reasons)) + + +def stable_key(payload_core: dict[str, Any]) -> str: + material = json.dumps(payload_core, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def build_payload( + *, + source: str, + event: str, + summary: str, + stdin_event: dict[str, Any], + project: str | None, + session: str | None, + workdir: Path, + status_file: str | None, + correction_queue: str | None, + gates_file: str | None, +) -> dict[str, Any]: + source = str(first_present(stdin_event, "source") or source) + event = str(first_present(stdin_event, "event", "hook_event_name", "type") or event) + summary = str(first_present(stdin_event, "summary", "message") or summary) + project_name = infer_project(workdir, str(first_present(stdin_event, "project") or project or "")) + session_name = str(first_present(stdin_event, "session", "tmux_session", "pane") or session or "") + excerpt = text_excerpt(stdin_event, summary) + reasons = event_reasons(event, excerpt) + + core = { + "source": source, + "event": event, + "project": project_name, + "session": session_name, + "summary": summary, + "excerpt": excerpt, + } + + payload_summary = summary or (excerpt.splitlines()[0] if excerpt else "Ralph Loop attention event") + + return { + "schema_version": "ralph-loop-hermes-bridge/v1", + "role": "negotiator", + "source": source, + "event": event, + "project": project_name, + "session": session_name, + "workdir": str(workdir), + "status_file": status_file, + "correction_queue": correction_queue, + "gates_file": gates_file, + "summary": payload_summary, + "excerpt": excerpt, + "attention_required": bool(reasons), + "reasons": reasons, + "negotiation_contract": { + "goal": "Help the governor and executor unblock the run without replacing final human/governor acceptance.", + "safe_actions": [ + "inspect roadmap status, correction queue, gates, and recent transcript", + "summarize the blocker as a concrete executor instruction or governor decision", + "ask for a binary or small multiple-choice decision when needed", + "send a bounded steering message back to the appropriate pane only when configured", + ], + "guardrails": [ + "do not accept RALPH_DONE on behalf of the governor", + "do not close corrections without evidence", + "avoid hook-to-agent recursion with idempotency and rate limits", + ], + }, + "idempotency_key": stable_key(core), + "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + + +def default_state_dir(workdir: Path) -> Path: + return workdir / ".ralph-loop" / "hermes-bridge" + + +def load_state(path: Path) -> dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except (FileNotFoundError, json.JSONDecodeError): + return {"events": {}} + if not isinstance(data, dict): + return {"events": {}} + data.setdefault("events", {}) + return data + + +def is_duplicate(state_path: Path, payload: dict[str, Any], *, now: float | None = None, ttl_seconds: int = 600) -> bool: + now = time.time() if now is None else now + state = load_state(state_path) + seen_at = state.get("events", {}).get(payload["idempotency_key"]) + return seen_at is not None and now - float(seen_at) < ttl_seconds + + +def remember_event(state_path: Path, payload: dict[str, Any], *, now: float | None = None) -> None: + now = time.time() if now is None else now + state = load_state(state_path) + events = state.setdefault("events", {}) + events[payload["idempotency_key"]] = now + cutoff = now - 24 * 60 * 60 + state["events"] = {key: value for key, value in events.items() if float(value) >= cutoff} + state_path.parent.mkdir(parents=True, exist_ok=True) + with state_path.open("w", encoding="utf-8") as handle: + json.dump(state, handle, indent=2, sort_keys=True) + handle.write("\n") + + +def resolve_webhook_url(args: argparse.Namespace, workdir: Path) -> str | None: + if args.webhook_url: + return args.webhook_url + for path in ( + workdir / ".ralph-loop" / "hermes-webhook-url", + Path.home() / ".ralph-loop" / "hermes-webhook-url", + ): + try: + value = path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + continue + if value: + return value + return None + + +def post_json(url: str, payload: dict[str, Any], timeout_seconds: int) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json", "User-Agent": "ralph-loop-hermes-bridge/1"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + response.read() + + +def append_local_event(state_dir: Path, payload: dict[str, Any]) -> None: + state_dir.mkdir(parents=True, exist_ok=True) + with (state_dir / "events.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + + +def run(args: argparse.Namespace, stdin_text: str | None = None) -> int: + workdir = Path(args.workdir).resolve() + stdin_event = load_stdin_event(sys.stdin.read() if stdin_text is None else stdin_text) + payload = build_payload( + source=args.source, + event=args.event, + summary=args.summary, + stdin_event=stdin_event, + project=args.project, + session=args.session, + workdir=workdir, + status_file=args.status_file, + correction_queue=args.correction_queue, + gates_file=args.gates_file, + ) + + if args.print_payload: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + + if not payload["attention_required"]: + return 0 + + state_dir = Path(args.state_dir).resolve() if args.state_dir else default_state_dir(workdir) + state_path = state_dir / "state.json" + if is_duplicate(state_path, payload, ttl_seconds=args.ttl_seconds): + return 0 + + webhook_url = resolve_webhook_url(args, workdir) + if args.dry_run: + append_local_event(state_dir, payload) + remember_event(state_path, payload) + return 0 + + if webhook_url: + try: + post_json(webhook_url, payload, args.timeout_seconds) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + append_local_event(state_dir, {**payload, "delivery_error": str(exc)}) + return 0 + remember_event(state_path, payload) + return 0 + + if shutil.which("hermes"): + append_local_event(state_dir, {**payload, "delivery": "no_webhook_configured"}) + return 0 + + +def main() -> int: + return run(parse_args()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/CLAUDE.md b/CLAUDE.md index 643c521..db63613 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Read `AGENTS.md` first. It is the canonical instruction layer for this repositor ## Common Skills - `.codex/skills/ralph-loop-governor/SKILL.md` prepares, monitors, steers, and gates Ralph Loop implementation runs. +- `.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py` is the optional Hermes negotiator hook bridge; it must be safe when Hermes or webhook configuration is absent, and Hermes should act only as a passive TUI unblocker rather than an active coding governor. - `.codex/skills/repo-commit-and-push/SKILL.md` handles focused commits and explicit push policy. - `.codex/skills/parallel-delegation/SKILL.md` describes safe subagent decomposition. - `.codex/skills/sync-claude-md/SKILL.md` keeps Claude-facing guidance aligned. diff --git a/README.md b/README.md index 2ca72ea..d2651bb 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ The workflow is intentionally tool-light: agents/ # Codex reviewer subagents prompts/ # Reusable kickoff/restart/review prompts skills/ - ralph-loop-governor/ # Main workflow skill and templates + ralph-loop-governor/ # Main workflow skill, templates, optional bridge scripts repo-commit-and-push/ # Generic commit/push policy parallel-delegation/ # Subagent decomposition policy sync-claude-md/ # Keep Claude-facing guidance aligned @@ -26,6 +26,7 @@ The workflow is intentionally tool-light: agents/ # Claude Code mirrors of the reviewer agents docs/ ralph-loop-governor.md # Full operating model + hermes-negotiator-bridge.md # Optional Hermes hook bridge examples/generic-feature-run.md # Sanitized example run ``` @@ -86,6 +87,8 @@ Then run the executor in Claude/Ralph: This command requires the [Ralph Loop plugin](https://claude.com/plugins/ralph-loop) in Claude Code. +Optional: if Hermes is installed and you want it to passively unblock TUI stalls, limits, or restart handoffs between the executor and governor, configure the hook bridge in [Hermes Negotiator Bridge](docs/hermes-negotiator-bridge.md). Hermes should perform only the minimum safe unblock action and then return control to Codex/Ralph. Without Hermes or a webhook URL, the bridge is a no-op and the manual copy/paste workflow still works. + Before the executor emits `RALPH_DONE`, it must complete a final stabilization wait: five consecutive clean cycles of `sleep 180 seconds`, then reread correction queue, gates, status, `git status --short --branch`, and recent commits. Any open blocker, failed gate, stale evidence, new commit, or unexplained dirty worktree resets the count to zero. When corrections are needed: diff --git a/docs/hermes-negotiator-bridge.md b/docs/hermes-negotiator-bridge.md new file mode 100644 index 0000000..830fa8b --- /dev/null +++ b/docs/hermes-negotiator-bridge.md @@ -0,0 +1,113 @@ +# Hermes Negotiator Bridge + +The Hermes negotiator bridge makes Hermes optional in a Ralph Loop run. When Hermes is available and a webhook URL is configured, Claude/Ralph and Codex hooks can send attention-worthy events to Hermes. When Hermes is absent, the hook script exits successfully and the manual workflow is unchanged. + +Hermes is a passive TUI unblocker, not a second active coding governor. Its job is to keep the existing Claude/Ralph executor and Codex governor able to run when a user-like TUI action is required. It should make the smallest safe unblock action, preserve the intended completeness of the work, and hand control back to the normal governor/executor loop. + +## Flow + +```text +Codex governor prepares the run + -> user starts Claude/Ralph executor + -> Claude/Codex hooks call ralph-loop-hermes-bridge.py on rare attention events + -> bridge classifies the event and suppresses duplicates + -> if a Hermes webhook is configured, POST a compact negotiator payload + -> Hermes performs the minimum safe unblock action when the TUI flow is blocked + -> executor and governor continue; final acceptance still belongs to the governor +``` + +Hermes may answer a blocking TUI prompt, retry after a usage/rate limit, restart or continue Ralph after an iteration limit, or notify Codex when the executor is stopped and Codex can still govern the next step. It must not proactively review or correct code, choose the next implementation slice merely because a commit landed, accept `RALPH_DONE`, close corrections without evidence, or silently replace Codex/governor steering and final gates. + +## What the bridge alerts on + +The bridge only forwards events that usually need TUI-level unblocking: + +- Claude/Ralph `Stop` without `RALPH_DONE`, especially max-iteration or early-stop cases that need a continue/restart handoff. +- Claude Code `Notification`, `PermissionRequest`, `PostToolUseFailure`, or `SubagentStop` when the TUI is waiting for user input. +- Codex `agent-turn-complete` or stop-like events that contain blocked/stuck/waiting language. +- Usage, rate, quota, or retry-limit messages that require waiting and trying again later. +- Messages containing decision markers such as `blocked`, `waiting for`, `need input`, `needs governor`, `accept or reject`, or `COMANDO_RALPH`. + +Normal progress, routine tool use, newly landed commits, closed corrections, and a proper `RALPH_DONE` stop are silent. + +## Files + +```text +.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py +.codex/skills/ralph-loop-governor/assets/hermes-bridge-claude-settings.fragment.json +.codex/skills/ralph-loop-governor/assets/hermes-bridge-codex-notify.fragment.toml +``` + +## Configure Hermes + +Create a Hermes webhook subscription for the run. Pick a route name that is unique enough for the repository or feature. + +```bash +hermes webhook subscribe ralph-loop- +``` + +Then put the resulting webhook URL in one of these locations: + +```bash +export RALPH_HERMES_WEBHOOK_URL="https:///webhooks/ralph-loop-" +# or +mkdir -p .ralph-loop +printf '%s\n' "https:///webhooks/ralph-loop-" > .ralph-loop/hermes-webhook-url +``` + +If no webhook URL is configured, the bridge exits 0. If a `hermes` binary exists but no webhook URL exists, the bridge records local JSONL events in `.ralph-loop/hermes-bridge/events.jsonl` for later inspection. + +## Configure Claude Code hooks + +Merge the JSON fragment from `assets/hermes-bridge-claude-settings.fragment.json` into the target repository or user Claude Code settings. Replace ``, ``, and roadmap paths. + +The command intentionally uses only local files and environment variables. If `RALPH_HERMES_WEBHOOK_URL` is unset and `.ralph-loop/hermes-webhook-url` is absent, Claude/Ralph continues exactly as before. + +## Configure Codex notify + +Merge the TOML fragment from `assets/hermes-bridge-codex-notify.fragment.toml` into the target Codex config for the run. This is useful for the governor side: if Codex stops with a blocked decision or emits a `COMANDO_RALPH:` handoff, Hermes can turn that into a bounded executor instruction. + +## Payload contract + +The bridge posts JSON shaped like this: + +```json +{ + "schema_version": "ralph-loop-hermes-bridge/v1", + "role": "negotiator", + "source": "claude-code", + "event": "Stop", + "project": "example-repo", + "session": "tmux:ralph-example", + "workdir": "/repo/path", + "status_file": "docs/roadmap//status.md", + "correction_queue": "docs/roadmap//correction-queue.md", + "gates_file": "docs/roadmap//gates.md", + "summary": "Ralph stopped before RALPH_DONE and is waiting for a decision.", + "attention_required": true, + "reasons": ["stop_without_completion", "blocked_language"], + "idempotency_key": "sha256...", + "created_at": "2026-01-01T00:00:00Z" +} +``` + +Hermes should use the payload as a bounded TUI-unblock prompt: + +1. Inspect only enough status, correction queue, gates, and recent transcript context to understand why the TUI flow stopped. +2. Decide whether a user-like unblock action is required now, or whether Codex/Ralph can continue without Hermes. +3. Produce one of: + - an answer to the blocking TUI prompt, choosing the most complete safe delivery option when the prompt asks for scope; + - a retry/continue action after a usage/rate/quota limit and appropriate wait; + - a restart/continue handoff for Ralph after max iterations or early stop; + - a concise notification to Codex when Codex can govern the next executor step; + - a concise user-facing multiple-choice question only when the decision cannot be made safely; + - a no-op if the event is stale, already resolved, or merely normal progress. +4. Avoid recursion. Do not trigger another hook storm from the negotiation output. +5. Do not use the bridge to perform code review, open new corrections, select the next implementation slice, or otherwise replace Codex governance. + +## Safety guardrails + +- Duplicate idempotency keys are suppressed for 10 minutes by default. +- Webhook delivery failures are recorded locally and do not fail the executor hook. +- Missing Hermes configuration is a successful no-op. +- The bridge is passive TUI-unblock infrastructure. It does not replace correction evidence, gates, final stabilization, active Codex governance, or final governor acceptance. diff --git a/docs/ralph-loop-governor.md b/docs/ralph-loop-governor.md index 221cba8..8101be8 100644 --- a/docs/ralph-loop-governor.md +++ b/docs/ralph-loop-governor.md @@ -38,6 +38,26 @@ Correction restart: Keep natural-language prompts quoted so shell punctuation inside the prompt cannot split the command or drop `--max-iterations` / `--completion-promise`. +## Optional Hermes Negotiator + +Hermes can sit near the Claude/Ralph executor and the Codex governor as an optional passive TUI unblocker. It is useful when the normal flow would otherwise stop on user-like TUI input: a Claude/Codex prompt waiting for an answer, a usage/rate limit that needs a delayed retry, or a Ralph max-iteration/early-stop event that needs a restart or Codex handoff. + +Use the bridge only as optional assistance: + +- If Hermes and a webhook URL are configured, hooks can POST stall, permission, failure, rate-limit, max-iteration, or early-stop events to Hermes. +- If Hermes is absent or no webhook URL is configured, the bridge exits 0 and the normal manual workflow remains unchanged. +- Hermes may perform the minimum safe unblock action needed to keep the executor and governor able to run. +- When a prompt asks about scope, Hermes should choose the most complete safe delivery option, not defer or partial work. +- Hermes must not proactively review or correct code, choose the next implementation slice merely because a commit landed, close corrections without evidence, or accept `RALPH_DONE` on behalf of Codex. + +Configure it with `docs/hermes-negotiator-bridge.md` and these assets: + +```text +.codex/skills/ralph-loop-governor/scripts/ralph-loop-hermes-bridge.py +.codex/skills/ralph-loop-governor/assets/hermes-bridge-claude-settings.fragment.json +.codex/skills/ralph-loop-governor/assets/hermes-bridge-codex-notify.fragment.toml +``` + ## Codex Monitor Loop Codex should write a monitor file outside the repo unless the user asks otherwise: diff --git a/tests/test_hermes_bridge.py b/tests/test_hermes_bridge.py new file mode 100644 index 0000000..f050b6a --- /dev/null +++ b/tests/test_hermes_bridge.py @@ -0,0 +1,94 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import importlib.util + +SCRIPT = Path(__file__).resolve().parents[1] / ".codex" / "skills" / "ralph-loop-governor" / "scripts" / "ralph-loop-hermes-bridge.py" +spec = importlib.util.spec_from_file_location("ralph_loop_hermes_bridge", SCRIPT) +assert spec is not None +assert spec.loader is not None +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) + + +class HermesBridgeTests(unittest.TestCase): + def test_stop_without_ralph_done_requires_attention(self): + payload = bridge.build_payload( + source="claude-code", + event="Stop", + summary="Ralph stopped after asking whether to accept a re-scope.", + stdin_event={}, + project="demo", + session="tmux:demo", + workdir=Path.cwd(), + status_file="docs/roadmap/demo/status.md", + correction_queue="docs/roadmap/demo/correction-queue.md", + gates_file="docs/roadmap/demo/gates.md", + ) + + self.assertTrue(payload["attention_required"]) + self.assertIn("stop_without_completion", payload["reasons"]) + self.assertEqual(payload["role"], "negotiator") + + def test_ralph_done_stop_is_not_an_attention_alert(self): + payload = bridge.build_payload( + source="claude-code", + event="Stop", + summary="RALPH_DONE", + stdin_event={}, + project="demo", + session="tmux:demo", + workdir=Path.cwd(), + status_file=None, + correction_queue=None, + gates_file=None, + ) + + self.assertFalse(payload["attention_required"]) + self.assertEqual(payload["reasons"], []) + + def test_blocked_language_requires_attention_for_turn_complete(self): + payload = bridge.build_payload( + source="codex", + event="agent-turn-complete", + summary="I am blocked waiting for the governor to choose option A or B.", + stdin_event={}, + project="demo", + session="codex:demo", + workdir=Path.cwd(), + status_file=None, + correction_queue=None, + gates_file=None, + ) + + self.assertTrue(payload["attention_required"]) + self.assertIn("blocked_language", payload["reasons"]) + + def test_duplicate_key_is_suppressed_until_ttl_expires(self): + with tempfile.TemporaryDirectory() as tmp: + state_path = Path(tmp) / "state.json" + payload = {"idempotency_key": "same", "created_at": "2026-01-01T00:00:00Z"} + + self.assertFalse(bridge.is_duplicate(state_path, payload, now=1_000, ttl_seconds=60)) + bridge.remember_event(state_path, payload, now=1_000) + self.assertTrue(bridge.is_duplicate(state_path, payload, now=1_030, ttl_seconds=60)) + self.assertFalse(bridge.is_duplicate(state_path, payload, now=1_061, ttl_seconds=60)) + + def test_missing_webhook_is_successful_noop(self): + with tempfile.TemporaryDirectory() as tmp: + args = bridge.parse_args([ + "--source", "claude-code", + "--event", "Stop", + "--summary", "Stopped early", + "--state-dir", tmp, + "--dry-run", + ]) + code = bridge.run(args, stdin_text="{}") + + self.assertEqual(code, 0) + + +if __name__ == "__main__": + unittest.main()