diff --git a/.agents/skills/ringer/SKILL.md b/.agents/skills/ringer/SKILL.md new file mode 100644 index 0000000..c5a59da --- /dev/null +++ b/.agents/skills/ringer/SKILL.md @@ -0,0 +1,108 @@ +--- +name: ringer +description: >- + Orchestrate implementation and evaluation work through Ringer verified + worker swarms. Use before drafting or running a Ringer manifest, delegating + repeatable edits or reviews, choosing Claude/Codex/OpenCode/Grok workers, + or diagnosing a failed Ringer run. Skip for read-only exploration, pure + conversation, and a single one-shot edit that does not start an edit/test loop. +--- + +# Ringer orchestrator playbook for Codex + +You are the decision-maker. Ringer workers are the implementation and review +lanes. Keep planning, task boundaries, check design, result review, and final +synthesis in the parent Codex thread; give mechanical or parallelizable work to +Ringer. + +When the user requests Sol Advanced, use GPT-5.6 Sol as the parent orchestrator. +In the Codex app, choose Sol and an appropriate reasoning level under Advanced. +For CLI sessions, the equivalent quality-first starting point is: + +```bash +codex -m gpt-5.6-sol -c model_reasoning_effort=high +``` + +`Advanced` is a product selector, not a value for `model_reasoning_effort`. +Raise or lower the effort only when task difficulty warrants it. + +## Operating contract + +1. Read `templates/README.md` before inventing a swarm shape. +2. Keep each worker task bounded, stateless, and independently verifiable. +3. Put instructions in `spec`; do not make the worker chase an instruction file. +4. Give concurrent editing workers disjoint file ownership, normally through + worktrees. +5. Write a check that executes the artifact and prints a useful reason on + failure. `expect_files` is only a fast existence preflight. +6. Run `./ringer.py lint MANIFEST` before spending worker tokens. +7. Use `./ringer.py run MANIFEST --baseline` when a check can be proved against + the untouched tree. +8. Run the real swarm with an explicit identity, for example: + + ```bash + ./ringer.py run MANIFEST --identity sol-advanced + ``` + +9. Review the run state, raw logs for failures or retries, and at least one + passing artifact. A worker's summary is never proof. +10. Integrate worker patches serially and run the repository's full validation + after integration. + +Keep Ringside visible for interactive work. Do not use `--no-dashboard` unless +the user asks for headless execution or the run is an automated test. + +## Worker routing + +The manifest selects a worker with `engine` and, where supported, `model`. +Ringer applies the same closed-stdin, timeout, retry, raw-log, and executed-check +rules to every engine. + +- `claude`: Claude Code's non-interactive worker lane. The built-in engine pins + `claude-sonnet-5`, enables Claude's native task-directory sandbox, refuses to + fall back to unsandboxed Bash, and streams raw JSON events. Use `engine_args` + such as `["--effort", "low"]` or `["--effort", "high"]` per task. +- `codex`: the default Codex CLI worker lane. Do not confuse this with the parent + Sol orchestrator; it is just another worker when selected in a manifest. +- `opencode`: the OpenRouter harness for third-party models. Put the model slug + in the manifest `model` field. +- `grok`: the Grok Build CLI lane when configured. + +Prefer Claude or another cheaper/proven lane for tightly specified typing, +tests, mechanical changes, and independent reviews. Keep architecture choices, +cross-task dependencies, conflict resolution, and acceptance judgment with Sol. +Use `./ringer.py models --task-type TYPE` and local evidence when choosing among +available workers; do not assume one model is best for every task shape. + +Example task: + +```json +{ + "key": "claude-tests", + "engine": "claude", + "model": "claude-sonnet-5", + "engine_args": ["--effort", "medium"], + "task_type": "test-hardening", + "spec": "You own only tests/test_widget.py. Add the named regression tests, do not edit production code, run python -m unittest tests.test_widget, and leave changes uncommitted.", + "check": "python -m unittest tests.test_widget || { echo 'FAIL: widget regression tests did not pass'; exit 1; }", + "verified": "The widget regression suite executes and passes" +} +``` + +## Safety boundaries + +- The built-in Claude sandbox is fail-closed on macOS, Linux, and WSL2. If the + platform cannot start it, the worker must fail setup rather than run loose. +- `full_access: true` is exceptional and still requires `allow_full_access = + true` in Ringer config. Use it only when the task genuinely needs to spawn + its own processes outside the task boundary. +- Worktree PASS cleanup removes the worktree. Export patches and any ignored + deliverables outside the worktree in the check before it exits zero. +- Do not let review workers fix what they discovered. Sol confirms findings, + then creates a separate fix swarm. + +## Completion report + +Return the plan chosen, worker/model routing, checks executed, attempt/retry +results, artifacts or patches produced, integration validation, and any +remaining uncertainty. Separate verified facts from worker claims. diff --git a/.claude/skills/ringer/SKILL.md b/.claude/skills/ringer/SKILL.md index 9e8770e..e65fcc7 100644 --- a/.claude/skills/ringer/SKILL.md +++ b/.claude/skills/ringer/SKILL.md @@ -48,7 +48,7 @@ description: >- `--no-dashboard` except in automated tests or when the user explicitly asks. -Ringer runs manifest tasks in parallel across cheap CLI workers (Codex, +Ringer runs manifest tasks in parallel across CLI workers (Claude Code, Codex, OpenCode/GLM, others via config) and verifies every task by **executing a check command** — exit 0 is the only PASS. Failed tasks are retried once with the check's actual failure output injected into the retry prompt. You — @@ -229,6 +229,9 @@ per task via the manifest `engine` field. Defaults are deliberate: - **codex** (default): strongest general worker. Use per-task `engine_args` to set reasoning effort — spend it on hard tasks, not boilerplate. +- **claude**: built-in Claude Code worker lane, pinned to `claude-sonnet-5` + with a fail-closed native task-directory sandbox. Use per-task + `engine_args` such as `["--effort", "low|medium|high"]`. - **opencode**: the universal lane — any OpenRouter model via the `model` field (engine `model_default` is GLM-5.2, the cheap-intelligence pick). Validate a model new to you with a trivial one-task manifest before diff --git a/README.md b/README.md index 529e36c..8cfc1c0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Frontier models are finally good enough to trust with real implementation — but their tokens are priced like senior-engineer hours, and most of a build is not senior-engineer work. It's scaffolding, migrations, test suites, batch transforms. Mechanical labor. -So split the roles. Your best model writes the specs and reviews the results. A swarm of cheap workers — Codex, Grok, anything with a CLI — does the implementation in parallel. Your premium budget stops scaling with lines of code written and starts scaling with decisions made. +So split the roles. Your best model writes the specs and reviews the results. A swarm of cheaper workers — Claude, Codex, Grok, anything with a CLI — does the implementation in parallel. Your premium budget stops scaling with lines of code written and starts scaling with decisions made. One problem: parallel agents lie. "Done" doesn't mean working. Ringer doesn't take the worker's word for anything — it **executes your check command** against the artifact. Pass or fail is decided by running the code, not by reading the agent's summary. Failures retry once with the failure context injected, and every attempt is logged so your setup gets measurably better over time. @@ -17,7 +17,7 @@ And because a swarm you can't see is a swarm you don't trust: **Ringside**, a lo ## How it works ``` -manifest.json ──▶ ringer.py ──▶ N parallel workers (codex exec, each in its own dir) +manifest.json ──▶ ringer.py ──▶ N parallel workers (Claude/Codex/etc., one dir each) │ │ │ ▼ │ executed checks ── fail ──▶ retry once w/ failure context @@ -47,10 +47,10 @@ git clone https://github.com/NateBJones-Projects/ringer && cd ringer mkdir -p ~/.config/ringer && cp config.sample.toml ~/.config/ringer/config.toml # optional — sane defaults without it ``` -3. Teach your agent to route work through Ringer: +3. Teach Codex and Claude Code to route work through Ringer: ```bash -# optional but recommended: teach your agent to route work through ringer +# optional but recommended: installs both host skills; Claude also gets nudge hooks ./ringer.py install-agent ``` @@ -62,6 +62,21 @@ mkdir -p ~/.config/ringer && cp config.sample.toml ~/.config/ringer/config.toml The demo spawns three Codex workers in parallel, verifies each artifact by executing it, and prints a verdict table — and Ringside, the live dashboard, opens in your browser on its own. If all three say PASS, that's the whole setup. +### Sol Advanced orchestrator + Claude workers + +Ringer now ships the inverse of its original Claude-orchestrator/Codex-worker setup as a first-class path: GPT-5.6 Sol can own planning and acceptance in Codex while Claude Code and any other configured engines do bounded worker tasks. + +```bash +npm install -g @anthropic-ai/claude-code +claude auth login +./ringer.py install-agent --target codex +codex -m gpt-5.6-sol -c model_reasoning_effort=high +``` + +In the Codex app, choose GPT-5.6 Sol and High reasoning under Advanced, then invoke `$ringer`. The built-in `claude` engine is immediately selectable with `"engine": "claude"`; its default lane pins `claude-sonnet-5`, uses Claude's native sandbox in fail-closed mode, and accepts per-task effort through `"engine_args": ["--effort", "medium"]`. + +The architecture, setup, mixed-worker manifest, security boundaries, and acceptance rollout are in [Sol Advanced orchestrator, heterogeneous workers](docs/SOL-ORCHESTRATOR.md). + Run your own batch: ```bash @@ -144,7 +159,7 @@ Run one command: ./ringer.py install-agent ``` -It installs the ringer skill — the orchestrator playbook — user-level for Claude Code, and registers two gentle hooks: a Bash hook that notices model-calling or harness commands running outside a live Ringer run, and an edit-loop hook that notices batch editing without a run. Each hook nudges ONCE per session, pointing the agent at the skill. +It installs the Ringer orchestrator playbook user-level for both Codex (`~/.agents/skills/ringer`) and Claude Code (`~/.claude/skills/ringer`). Claude also receives two gentle hooks: a Bash hook that notices model-calling or harness commands running outside a live Ringer run, and an edit-loop hook that notices batch editing without a run. Each hook nudges ONCE per session, pointing the agent at the skill. Use `--target codex` or `--target claude` when you want only one host, and `--project` for repo-local installation. The hooks never block anything. A user who says "just do it inline" is obeyed; uninstall with `./ringer.py uninstall-agent`. @@ -154,7 +169,7 @@ For CI and evals, `config.sample.toml` includes `[engines.mock]` so the enforcem ![Identical workers, each under its own light](docs/engines.png) -Ringer ships with three worker lanes: **Codex CLI** is the built-in default, and `config.sample.toml` carries verified engine blocks for **Grok Build CLI** (works as-is once you `grok login`) and **OpenCode + OpenRouter** (one edit: point `bin` at the sandbox wrapper in your clone). Anything else with a headless CLI is a config block away: +Ringer ships with four worker lanes: **Codex CLI** is the built-in default, **Claude Code** is also built in, and `config.sample.toml` carries verified engine blocks for **Grok Build CLI** (works as-is once you `grok login`) and **OpenCode + OpenRouter** (one edit: point `bin` at the sandbox wrapper in your clone). Anything else with a headless CLI is a config block away: ```toml [engines.mymodel] @@ -164,6 +179,19 @@ args_template = ["run", "{spec}", "--dir", "{taskdir}"] Per-task `"engine": "mymodel"` routes work to it — the invariants (stdin closed, process-group kill, executed verification, raw logs) apply to every engine identically. +### Claude Code worker lane + +Install and authenticate Claude Code once: + +```bash +npm install -g @anthropic-ai/claude-code +claude auth login +``` + +Then route any task with `"engine": "claude"`. The built-in argv puts the spec immediately after `-p`, selects `claude-sonnet-5` explicitly, streams verbose JSON into the raw worker log, and disables session persistence. Sandboxed tasks enable Claude's native sandbox with automatic approval only inside that boundary, remove its unsandboxed-command escape hatch, and fail if the platform sandbox is unavailable. On Linux/WSL2, install `bubblewrap` and `socat`; Claude's sandbox is not available on WSL1. + +Use per-task `engine_args` for worker effort, for example `["--effort", "low"]` on mechanical work or `["--effort", "high"]` on a demanding bounded task. `full_access: true` switches Claude to bypass mode only when Ringer's separate `allow_full_access` gate is enabled. + ### The universal harness: OpenCode + OpenRouter Unless a model ships its own first-class harness (Codex does), OpenCode is the harness that runs it — one engine block covers every OpenRouter-served model. `config.sample.toml` includes a ready-to-uncomment engine whose `{model}` placeholder is filled per task from the manifest's `"model"` field, with `model_default` as the fallback. The shipped default is OpenRouter's `z-ai/glm-5.2` — roughly $0.74/M input and $2.33/M output (2026-07), about 20-30x cheaper output than frontier coding models; a complete write-code-and-pass-the-check task lands around a penny. diff --git a/config.sample.toml b/config.sample.toml index 1527dea..5b29b7d 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -91,6 +91,44 @@ token_regex = "tokens\\s+used\\s*:?\\s*([0-9][0-9,]*)" # default reads the `model:` header; override it here only if that format changes. model_report_regex = "(?m)^model:[ \\t]*([^ \\t\\r\\n]+)[ \\t]*\\r?$" +# Claude Code is a built-in worker lane, so no config block is required. This +# explicit block documents and lets you override the shipped defaults. Install +# with `npm install -g @anthropic-ai/claude-code`, then `claude auth login`. +# Ringer runs Claude in non-interactive stream-JSON mode, pins the current +# Sonnet model instead of inheriting local settings drift, and passes per-task +# `engine_args` through (for example ["--effort", "low|medium|high"]). +[engines.claude] +bin = "claude" +model_default = "claude-sonnet-5" +args_template = [ + "-p", + "{spec}", + "--output-format", + "stream-json", + "--verbose", + "--no-session-persistence", + "--model", + "{model}", + "{access_args}", + "{engine_args}", +] + +# Claude's native sandbox writes only inside the worker cwd. failIfUnavailable +# prevents a missing Seatbelt/bubblewrap setup from silently running loose, and +# allowUnsandboxedCommands=false removes Claude's sandbox escape hatch. +sandbox_args = [ + "--permission-mode", + "acceptEdits", + "--settings", + '{"sandbox":{"enabled":true,"failIfUnavailable":true,"autoAllowBashIfSandboxed":true,"allowUnsandboxedCommands":false}}', +] +full_access_args = [ + "--dangerously-skip-permissions", + "--settings", + '{"sandbox":{"enabled":false}}', +] +model_report_regex = '"model"\s*:\s*"([^"]+)"' + # Grok Build CLI (xAI). Install: curl -fsSL https://x.ai/cli/install.sh | bash # (or: npm install -g @xai-official/grok), then `grok login` — OAuth on a # SuperGrok or X Premium Plus plan. Headless mode (-p) runs a full agentic diff --git a/docs/SOL-ORCHESTRATOR.md b/docs/SOL-ORCHESTRATOR.md new file mode 100644 index 0000000..6500bcb --- /dev/null +++ b/docs/SOL-ORCHESTRATOR.md @@ -0,0 +1,229 @@ +# Sol Advanced orchestrator, heterogeneous workers + +This is the supported operating model for using GPT-5.6 Sol as Ringer's +decision-maker while Claude Code, Codex CLI, Grok Build, OpenCode/OpenRouter, +and future CLI agents do bounded worker tasks. + +## Architecture + +```text +human brief + | + v +Codex app or CLI: GPT-5.6 Sol + High reasoning + | plan, partition, choose workers, write checks, review evidence + v +manifest.json + | + v +ringer.py + | isolate, launch, time out, capture raw output, verify, retry once + +-------------------+-------------------+-------------------+ + | | | | +Claude Code Codex CLI OpenCode/Grok future CLI +task directory task directory task directory same contract + | | | | + +-------------------+-------------------+-------------------+ + | + v + executed checks + run state + Ringside + | + v + Sol reviews and integrates +``` + +Sol remains outside `ringer.py`. That is intentional. Ringer's runtime is the +deterministic control plane for process isolation and evidence; the premium +model is the adaptive control plane for judgment. Putting Sol into the worker +loop would blur the boundary between the agent that produced a result and the +agent that accepts it, and it would make Ringer responsible for one vendor's +conversation/session protocol. + +The Codex-facing integration is a repo/user skill. Codex discovers repo skills +under `.agents/skills`, while `AGENTS.md` and project config remain available +for narrower repository conventions. See OpenAI's [Codex skills +documentation](https://developers.openai.com/codex/skills) and [configuration +reference](https://developers.openai.com/codex/config-reference). + +## What is implemented + +- `.agents/skills/ringer/SKILL.md` gives Codex/Sol the Ringer orchestration + workflow, worker-routing rules, safety boundaries, and completion contract. +- `install-agent` installs Codex and Claude host support by default. Use + `--target codex` or `--target claude` for only one host. +- `claude` is a built-in worker engine. A manifest can select it without adding + a custom engine block. +- The Claude lane pins `claude-sonnet-5`, accepts per-task `--effort`, uses + non-interactive streaming output, and disables session persistence. +- Sandboxed Claude workers enable Claude Code's native filesystem/network + sandbox, auto-approve commands only inside it, disable the unsandboxed escape + hatch, and fail setup if the sandbox is unavailable. +- `full_access: true` switches Claude to bypass mode only behind Ringer's + existing `allow_full_access` configuration gate. +- Model identity records Claude separately from its harness so Ringside and the + local scoreboard do not label the harness as the model. + +Claude's non-interactive flags and model selection are documented in the +[Claude Code CLI reference](https://code.claude.com/docs/en/cli-usage). Its +native sandbox behavior and prerequisites are documented in [Claude Code +sandboxing](https://code.claude.com/docs/en/sandboxing). + +## One-time setup + +Ringer itself runs on macOS/Linux, or Windows through WSL. In the Ringer clone: + +```bash +# Orchestrator +npm install -g @openai/codex +codex login + +# Primary worker lane +npm install -g @anthropic-ai/claude-code +claude auth login + +# Install both Ringer host skills; Claude also receives the existing nudge hooks +./ringer.py install-agent + +# Or install only the Codex/Sol orchestration skill +./ringer.py install-agent --target codex +``` + +On WSL2, install Claude Code's sandbox prerequisites before using the Claude +lane: + +```bash +sudo apt-get install bubblewrap socat +``` + +The built-in worker deliberately refuses to run sandboxed tasks if those +prerequisites are missing. WSL1 does not provide the required isolation; use +WSL2 or choose another sandboxed engine. + +To start the orchestrator in the Codex CLI: + +```bash +codex -m gpt-5.6-sol -c model_reasoning_effort=high +``` + +In the Codex app, open the Advanced model controls and select GPT-5.6 Sol with +High reasoning. `Advanced` is the selector name, not a reasoning-effort value. +Start with High for architecture and acceptance work; lower it when the task is +already tightly specified. + +Then invoke the skill explicitly or describe a task that matches it: + +```text +$ringer Use Sol as orchestrator. Plan this feature, assign bounded implementation +and test tasks to Claude workers, use another configured engine for one +independent review, execute every check, then review and integrate the evidence. +``` + +## Manifest contract + +The orchestrator chooses a worker per task. This example assigns implementation +to Claude, a separate review to another configured model, and keeps acceptance +with Sol: + +```json +{ + "run_name": "widget-cache", + "workdir": "/tmp/widget-cache", + "repo": "/absolute/path/to/widget-repo", + "worktrees": true, + "max_parallel": 2, + "tasks": [ + { + "key": "claude-cache-fix", + "engine": "claude", + "model": "claude-sonnet-5", + "engine_args": ["--effort", "medium"], + "task_type": "code-fix", + "spec": "You own only src/cache.py and tests/test_cache.py in this dedicated worktree. Implement the approved cache invalidation rule. Do not commit or edit any other file. Run python -m unittest tests.test_cache. Leave changes uncommitted so the check can export the patch.", + "check": "python -m unittest tests.test_cache || { echo 'FAIL: focused cache tests failed'; exit 1; }; git add src/cache.py tests/test_cache.py || { echo 'FAIL: could not stage owned files'; exit 1; }; git diff --cached --quiet && { echo 'FAIL: worker produced no patch'; exit 1; }; git diff --cached > /tmp/widget-cache/claude-cache-fix.patch; test -s /tmp/widget-cache/claude-cache-fix.patch || { echo 'FAIL: patch export is empty'; exit 1; }", + "expect_files": [], + "verified": "The focused cache tests pass and a non-empty scoped patch is exported" + }, + { + "key": "independent-review", + "engine": "opencode", + "model": "openrouter/z-ai/glm-5.2", + "task_type": "code-review", + "spec": "Read the supplied design and repository sources without editing them. Write only report.md in the current task directory. Report concrete cache correctness risks with file-and-line evidence, or write NO FINDINGS and list what you inspected.", + "check": "python /absolute/path/to/checks/review-swarm.py report.md || { echo 'FAIL: review report did not satisfy the evidence contract'; exit 1; }; cp report.md /tmp/widget-cache/independent-review-report.md || { echo 'FAIL: could not export review before worktree cleanup'; exit 1; }; test -s /tmp/widget-cache/independent-review-report.md || { echo 'FAIL: exported review is empty'; exit 1; }", + "expect_files": ["report.md"], + "verified": "The review report satisfies the structured evidence contract and is exported before worktree cleanup" + } + ] +} +``` + +The example is a skeleton: replace the paths and review validator with real +ones, then run `lint` and `--baseline`. The orchestrator must inspect exported +patches before applying them. Do not let the same worker both discover a defect +and approve its own fix. + +## Routing policy + +Use role separation, not a permanent model hierarchy: + +| Work | Owner | Reason | +|---|---|---| +| Ambiguous decomposition, dependencies, acceptance criteria | Sol | Requires global context and judgment | +| Check design and baseline interpretation | Sol | Defines what Ringer is allowed to believe | +| Tightly bounded implementation and tests | Claude or another proven worker | Mechanical work is isolated and verified | +| Independent review | A different worker/model | Reduces correlated blind spots | +| Conflict resolution and patch integration | Sol | Requires cross-worker synthesis | +| Final full-suite validation | Ringer command run by Sol | Acceptance rests on executed evidence | + +Before the first run of a task shape, inspect the local evidence: + +```bash +./ringer.py models --task-type code-fix +./ringer.py models --explore --task-type code-fix +``` + +The model that is cheaper in general is not automatically cheaper per passing +task. Prefer first-try pass rate and the actual check contract over vendor or +model reputation. + +## Rollout plan and acceptance gates + +1. **Host integration**: install only the Codex skill in a temporary home and + verify no Claude settings are written. Install `all` and verify both skills + plus Claude's two existing nudge hooks. Reinstall to prove idempotence. +2. **Worker composition**: dry-run a one-task Claude manifest and inspect the + argv. It must contain print mode, stream JSON, the pinned model, the explicit + sandbox settings, and closed stdin through Ringer. +3. **Sandbox smoke**: run a Claude task that writes inside its task directory + and attempts a harmless write outside it. The inside write must succeed; the + outside write must fail without an approval prompt or unsandboxed retry. +4. **Verification smoke**: make attempt one fail with an explanatory check, + confirm the check output enters attempt two, and confirm only an executed + exit-zero check yields PASS. +5. **Mixed swarm**: run at least one Claude implementation task and one + different-engine review task. Confirm Ringside identifies harness, model, + attempt, and evidence separately. +6. **Integration gate**: have Sol inspect and apply one exported patch at a time, + run the full repository suite after each application, and reject any worker + claim that the checks do not support. + +The codebase's unit tests cover gates 1 and 2 without spending model tokens. +Gates 3 through 6 are live acceptance tests because they depend on local CLI +authentication, platform sandbox support, and paid model execution. + +## Failure policy + +- Missing Claude binary: fail before spawning and print the install/login hint. +- Missing sandbox dependency: Claude exits setup; Ringer records a failed + attempt instead of silently running unsandboxed. +- Worker exits zero but artifact/check fails: retry once with raw failure + context. +- Worker times out: terminate the process group, log the timeout, then use the + normal retry policy. +- Two workers touch the same path: the manifest is wrong. Stop and repartition; + do not resolve the collision by letting them race. +- Review disagrees with implementation: Sol confirms the evidence against the + repository before opening a fix task. + +This keeps the trust boundary simple: workers may propose or type; only checks +prove; Sol decides what the proof means. diff --git a/docs/TAXONOMY.md b/docs/TAXONOMY.md index 75e244a..564b576 100644 --- a/docs/TAXONOMY.md +++ b/docs/TAXONOMY.md @@ -16,7 +16,7 @@ Registered models use the lab recorded in `registry/model-identity.toml`. An unr ## Harness -The harness is the agent shell that invokes the model: Codex CLI, Grok Build CLI, or OpenCode. It runs a model but does not become the model or its lab. +The harness is the agent shell that invokes the model: Codex CLI, Claude Code, Grok Build CLI, or OpenCode. It runs a model but does not become the model or its lab. Worked example, because this exact conflation has happened: **"Grok Build" is a harness, never a model and never a lab.** The Grok Build CLI serves exactly two models — Grok 4.5 (lab: xAI) and Composer 2.5 (lab: Cursor/Anysphere). There is no model called "Grok Build"; a scoreboard row named after a harness is a taxonomy bug. diff --git a/registry/model-identity.toml b/registry/model-identity.toml index 2807de9..18a6766 100644 --- a/registry/model-identity.toml +++ b/registry/model-identity.toml @@ -40,6 +40,18 @@ confidence = "verified" source = "https://developers.openai.com/codex/models" last_verified = 2026-07-10 +[engines.claude] +harness = "Claude Code" +access = "OAuth plan or Anthropic API" +default_model_key = "claude-sonnet-5" + +[engines.claude.models."claude-sonnet-5"] +display = "Claude Sonnet 5" +lab = "Anthropic" +confidence = "verified" +source = "https://code.claude.com/docs/en/cli-usage" +last_verified = 2026-07-18 + [engines.grok] harness = "Grok Build CLI" access = "OAuth plan" diff --git a/ringer.py b/ringer.py index 0dca41b..faa72cb 100755 --- a/ringer.py +++ b/ringer.py @@ -50,6 +50,7 @@ CONFIG_DIR_NAME = TOOL_NAME CONFIG_FILE_NAME = "config.toml" DEFAULT_ENGINE_NAME = "codex" +CLAUDE_ENGINE_NAME = "claude" DEFAULT_TIMEOUT_S = 900 CHECK_TIMEOUT_S = 60 DEFAULT_DASHBOARD_PORT_BASE = 8787 @@ -891,6 +892,56 @@ def built_in_codex_engine() -> EngineConfig: ) +def built_in_claude_engine() -> EngineConfig: + """Claude Code worker with a fail-closed, task-directory sandbox.""" + resolved = shutil.which(CLAUDE_ENGINE_NAME) or CLAUDE_ENGINE_NAME + sandbox_settings = json.dumps( + { + "sandbox": { + "enabled": True, + "failIfUnavailable": True, + "autoAllowBashIfSandboxed": True, + "allowUnsandboxedCommands": False, + } + }, + separators=(",", ":"), + ) + full_access_settings = json.dumps( + {"sandbox": {"enabled": False}}, + separators=(",", ":"), + ) + return EngineConfig( + name=CLAUDE_ENGINE_NAME, + bin=resolved, + args_template=( + "-p", + "{spec}", + "--output-format", + "stream-json", + "--verbose", + "--no-session-persistence", + "--model", + "{model}", + "{access_args}", + "{engine_args}", + ), + full_access_args=( + "--dangerously-skip-permissions", + "--settings", + full_access_settings, + ), + sandbox_args=( + "--permission-mode", + "acceptEdits", + "--settings", + sandbox_settings, + ), + token_regex=None, + model_report_regex=r'"model"\s*:\s*"([^"]+)"', + model_default="claude-sonnet-5", + ) + + def load_eval_config(raw: Any, state_dir: Path) -> EvalConfig: if raw is None: raw = {} @@ -927,7 +978,10 @@ def load_hud_port(raw: Any) -> int: def load_engines(raw: Any) -> dict[str, EngineConfig]: - engines: dict[str, EngineConfig] = {DEFAULT_ENGINE_NAME: built_in_codex_engine()} + engines: dict[str, EngineConfig] = { + DEFAULT_ENGINE_NAME: built_in_codex_engine(), + CLAUDE_ENGINE_NAME: built_in_claude_engine(), + } if raw is None: return engines if not isinstance(raw, dict): @@ -8804,7 +8858,15 @@ def effective_model_from_command(command: list[str]) -> str: def effective_reasoning_effort_from_command(command: list[str]) -> str | None: """Return an explicitly configured model reasoning effort from worker argv.""" - for item in command: + for index, item in enumerate(command): + if item == "--effort": + if index + 1 < len(command): + effort = command[index + 1].strip() + return effort or None + return None + if item.startswith("--effort="): + effort = item.removeprefix("--effort=").strip() + return effort or None match = re.search( r"(?:^|[=,\s])model_reasoning_effort\s*=\s*[\"']?([^\"',\s]+)", item, @@ -8912,6 +8974,7 @@ def print_steering_notes(manifest: Manifest, config: AppConfig) -> None: ENGINE_INSTALL_HINTS = { "codex": "install it with `npm install -g @openai/codex` (or `brew install --cask codex`), then run `codex login`", + "claude": "install it with `npm install -g @anthropic-ai/claude-code`, then run `claude auth login`", "opencode": "install it with `curl -fsSL https://opencode.ai/install | bash`, then run `opencode auth login`", } @@ -9341,7 +9404,7 @@ def dry_run( dashboard_enabled: bool, force_browser: bool, ) -> None: - print("DRY RUN: no codex workers will be spawned.") + print("DRY RUN: no workers will be spawned.") print(f"Run: {manifest.run_name}") print(f"Identity: {identity}") print(f"Config: {config.path if config.path else '(safe defaults)'}") @@ -9473,10 +9536,26 @@ def claude_root(project: bool) -> Path: return (Path.cwd() if project else Path.home()) / ".claude" +def codex_skills_root(project: bool) -> Path: + return (Path.cwd() if project else Path.home()) / ".agents" / "skills" + + def ringer_skill_source() -> Path: return repo_root() / ".claude" / "skills" / "ringer" / "SKILL.md" +def codex_ringer_skill_source() -> Path: + return repo_root() / ".agents" / "skills" / "ringer" / "SKILL.md" + + +def copy_skill_file(source: Path, target: Path) -> None: + if not source.exists(): + raise ValueError(f"ringer skill source not found: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + if source.resolve() != target.resolve(): + shutil.copy2(source, target) + + def ringer_hook_command(action: str) -> str: hook_path = repo_root() / "hooks" / "ringer_nudge.py" return f"python3 {shlex.quote(str(hook_path))} {action}" @@ -9587,14 +9666,11 @@ def remove_ringer_hooks(settings: dict[str, Any]) -> int: return removed -def install_agent(project: bool = False) -> int: +def install_claude_agent(project: bool = False) -> None: root = claude_root(project) skill_source = ringer_skill_source() skill_target = root / "skills" / "ringer" / "SKILL.md" - if not skill_source.exists(): - raise ValueError(f"ringer skill source not found: {skill_source}") - skill_target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(skill_source, skill_target) + copy_skill_file(skill_source, skill_target) settings_path = root / "settings.json" settings = load_settings(settings_path) @@ -9614,17 +9690,35 @@ def install_agent(project: bool = False) -> int: if changed or not settings_path.exists(): write_settings(settings_path, settings) - scope = "project" if project else "user" - print(f"Installed ringer agent for {scope} scope.") - print(f"Skill: {skill_target}") + print(f"Claude skill: {skill_target}") if changed: - print(f"Hooks: added PreToolUse Bash and PostToolUse Edit|Write in {settings_path}") + print(f"Claude hooks: added PreToolUse Bash and PostToolUse Edit|Write in {settings_path}") else: - print(f"Hooks: already present in {settings_path}") + print(f"Claude hooks: already present in {settings_path}") + + +def install_codex_agent(project: bool = False) -> None: + skill_source = codex_ringer_skill_source() + skill_target = codex_skills_root(project) / "ringer" / "SKILL.md" + copy_skill_file(skill_source, skill_target) + print(f"Codex skill: {skill_target}") + print( + "Sol Advanced: select GPT-5.6 Sol with High reasoning in the app, or run " + "`codex -m gpt-5.6-sol -c model_reasoning_effort=high`." + ) + + +def install_agent(project: bool = False, target: str = "all") -> int: + scope = "project" if project else "user" + print(f"Installing ringer agent support for {target} ({scope} scope).") + if target in {"all", "claude"}: + install_claude_agent(project) + if target in {"all", "codex"}: + install_codex_agent(project) return 0 -def uninstall_agent(project: bool = False) -> int: +def uninstall_claude_agent(project: bool = False) -> tuple[int, bool]: root = claude_root(project) settings_path = root / "settings.json" removed_hooks = 0 @@ -9636,14 +9730,39 @@ def uninstall_agent(project: bool = False) -> int: skill_dir = root / "skills" / "ringer" removed_skill = False - if skill_dir.exists(): + source_dir = ringer_skill_source().parent + if skill_dir.exists() and skill_dir.resolve() != source_dir.resolve(): shutil.rmtree(skill_dir) removed_skill = True + return removed_hooks, removed_skill + + +def uninstall_codex_agent(project: bool = False) -> bool: + skill_dir = codex_skills_root(project) / "ringer" + source_dir = codex_ringer_skill_source().parent + if not skill_dir.exists() or skill_dir.resolve() == source_dir.resolve(): + return False + shutil.rmtree(skill_dir) + return True + + +def uninstall_agent(project: bool = False, target: str = "all") -> int: + removed_hooks = 0 + removed_claude_skill = False + removed_codex_skill = False + if target in {"all", "claude"}: + removed_hooks, removed_claude_skill = uninstall_claude_agent(project) + if target in {"all", "codex"}: + removed_codex_skill = uninstall_codex_agent(project) + scope = "project" if project else "user" - print(f"Uninstalled ringer agent for {scope} scope.") - print(f"Hooks removed: {removed_hooks}") - print(f"Skill removed: {'yes' if removed_skill else 'no'}") + print(f"Uninstalled ringer agent support for {target} ({scope} scope).") + if target in {"all", "claude"}: + print(f"Claude hooks removed: {removed_hooks}") + print(f"Claude skill removed: {'yes' if removed_claude_skill else 'no'}") + if target in {"all", "codex"}: + print(f"Codex skill removed: {'yes' if removed_codex_skill else 'no'}") return 0 @@ -9921,7 +10040,7 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="disable zero-LLM HTML status/report artifacts (see [artifact] in config.toml)", ) - run_parser.add_argument("--dry-run", action="store_true", help="print the plan without spawning codex") + run_parser.add_argument("--dry-run", action="store_true", help="print the plan without spawning workers") run_parser.add_argument( "--baseline", action="store_true", @@ -9995,13 +10114,39 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="disable zero-LLM HTML status/report artifacts (see [artifact] in config.toml)", ) - demo_parser.add_argument("--dry-run", action="store_true", help="print the demo plan without spawning codex") + demo_parser.add_argument("--dry-run", action="store_true", help="print the demo plan without spawning workers") - install_parser = subparsers.add_parser("install-agent", help="install the ringer Claude Code skill and hooks") - install_parser.add_argument("--project", action="store_true", help="install into ./.claude instead of ~/.claude") + install_parser = subparsers.add_parser( + "install-agent", + help="install Ringer orchestration support for Codex, Claude Code, or both", + ) + install_parser.add_argument( + "--target", + choices=("all", "codex", "claude"), + default="all", + help="agent host to configure (default: all)", + ) + install_parser.add_argument( + "--project", + action="store_true", + help="install repo-local skills/settings instead of user-level files", + ) - uninstall_parser = subparsers.add_parser("uninstall-agent", help="remove the ringer Claude Code skill and hooks") - uninstall_parser.add_argument("--project", action="store_true", help="remove from ./.claude instead of ~/.claude") + uninstall_parser = subparsers.add_parser( + "uninstall-agent", + help="remove Ringer orchestration support for Codex, Claude Code, or both", + ) + uninstall_parser.add_argument( + "--target", + choices=("all", "codex", "claude"), + default="all", + help="agent host to remove (default: all)", + ) + uninstall_parser.add_argument( + "--project", + action="store_true", + help="remove repo-local skills/settings instead of user-level files", + ) return parser @@ -10047,9 +10192,9 @@ def main(argv: list[str] | None = None) -> int: print(f"Self-update skipped: {result.reason or 'not available'}.") return 0 if args.command == "install-agent": - return install_agent(project=args.project) + return install_agent(project=args.project, target=args.target) if args.command == "uninstall-agent": - return uninstall_agent(project=args.project) + return uninstall_agent(project=args.project, target=args.target) if args.command == "lint": manifest = Manifest.from_path(args.manifest) diff --git a/templates/probe/claude-worker-smoke.json b/templates/probe/claude-worker-smoke.json new file mode 100644 index 0000000..220f799 --- /dev/null +++ b/templates/probe/claude-worker-smoke.json @@ -0,0 +1,19 @@ +{ + "run_name": "claude-worker-smoke", + "workdir": "/tmp/ringer-claude-worker-smoke", + "max_parallel": 1, + "tasks": [ + { + "key": "claude-ready", + "engine": "claude", + "model": "claude-sonnet-5", + "engine_args": ["--effort", "low"], + "task_type": "probe", + "spec": "You are a one-task Ringer probe worker. Boundary: this is a smoke test, not a general code-editing task. You own only hello.txt in the current task directory. Create hello.txt containing exactly one line: claude worker ready. Do not modify any other file, do not commit, and do not claim success until you have read the file back.", + "check": "test -f hello.txt || { echo 'FAIL: hello.txt was not created'; exit 1; }; test \"$(wc -l < hello.txt)\" -eq 1 || { echo 'FAIL: hello.txt must contain exactly one line'; exit 1; }; grep -Fxq 'claude worker ready' hello.txt || { echo 'FAIL: hello.txt does not contain the exact expected text'; exit 1; }", + "expect_files": ["hello.txt"], + "timeout_s": 300, + "verified": "hello.txt exists and contains exactly the expected single line" + } + ] +} diff --git a/tests/test_agent_install.py b/tests/test_agent_install.py index 8f339f5..cc31163 100644 --- a/tests/test_agent_install.py +++ b/tests/test_agent_install.py @@ -3,6 +3,7 @@ import json import os +import shutil import subprocess import sys import tempfile @@ -23,6 +24,7 @@ def setUp(self) -> None: def run_cli(self, *args: str, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]: env = os.environ.copy() env["HOME"] = str(self.home) + env["USERPROFILE"] = str(self.home) env["RINGER_HOME"] = str(self.ringer_home) return subprocess.run( [sys.executable, "ringer.py", *args], @@ -54,13 +56,20 @@ def ringer_handlers(self, settings: dict[str, object]) -> list[dict[str, object] handlers.append(handler) return handlers - def test_fresh_install_creates_skill_copy_and_hook_entries(self) -> None: + def test_fresh_install_creates_codex_and_claude_skills_plus_claude_hooks(self) -> None: result = self.run_cli("install-agent") self.assertEqual(0, result.returncode, result.stderr) skill = self.home / ".claude" / "skills" / "ringer" / "SKILL.md" self.assertTrue(skill.exists()) self.assertEqual((ROOT / ".claude" / "skills" / "ringer" / "SKILL.md").read_text(), skill.read_text()) + codex_skill = self.home / ".agents" / "skills" / "ringer" / "SKILL.md" + self.assertTrue(codex_skill.exists()) + self.assertEqual( + (ROOT / ".agents" / "skills" / "ringer" / "SKILL.md").read_text(), + codex_skill.read_text(), + ) + self.assertIn("gpt-5.6-sol", result.stdout) settings = self.read_settings() hooks = settings["hooks"] @@ -153,21 +162,41 @@ def test_uninstall_removes_only_ringer_entries_and_skill_dir(self) -> None: ] self.assertEqual(["echo keep-me"], kept) self.assertFalse((self.home / ".claude" / "skills" / "ringer").exists()) + self.assertFalse((self.home / ".agents" / "skills" / "ringer").exists()) + + def test_codex_target_does_not_write_claude_settings(self) -> None: + install = self.run_cli("install-agent", "--target", "codex") + self.assertEqual(0, install.returncode, install.stderr) + self.assertTrue((self.home / ".agents" / "skills" / "ringer" / "SKILL.md").exists()) + self.assertFalse((self.home / ".claude").exists()) + + uninstall = self.run_cli("uninstall-agent", "--target", "codex") + self.assertEqual(0, uninstall.returncode, uninstall.stderr) + self.assertFalse((self.home / ".agents" / "skills" / "ringer").exists()) + self.assertFalse((self.home / ".claude").exists()) def test_project_variant_writes_under_temp_cwd(self) -> None: project = Path(self.tmp.name) / "project" project.mkdir() - os.symlink(ROOT / "ringer.py", project / "ringer.py") + shutil.copy2(ROOT / "ringer.py", project / "ringer.py") + shutil.copytree(ROOT / ".claude" / "skills", project / ".claude" / "skills") + shutil.copytree(ROOT / ".agents" / "skills", project / ".agents" / "skills") + shutil.copytree(ROOT / "hooks", project / "hooks") install = self.run_cli("install-agent", "--project", cwd=project) self.assertEqual(0, install.returncode, install.stderr) self.assertTrue((project / ".claude" / "skills" / "ringer" / "SKILL.md").exists()) + self.assertTrue((project / ".agents" / "skills" / "ringer" / "SKILL.md").exists()) self.assertTrue((project / ".claude" / "settings.json").exists()) self.assertFalse((self.home / ".claude").exists()) + self.assertFalse((self.home / ".agents").exists()) uninstall = self.run_cli("uninstall-agent", "--project", cwd=project) self.assertEqual(0, uninstall.returncode, uninstall.stderr) - self.assertFalse((project / ".claude" / "skills" / "ringer").exists()) + # The checked-in source skills are already the project targets. Uninstall + # removes generated hooks but must not delete repository source files. + self.assertTrue((project / ".claude" / "skills" / "ringer").exists()) + self.assertTrue((project / ".agents" / "skills" / "ringer").exists()) settings = self.read_settings(project) self.assertEqual([], self.ringer_handlers(settings)) diff --git a/tests/test_model_field.py b/tests/test_model_field.py index 0c380bc..5efaa09 100644 --- a/tests/test_model_field.py +++ b/tests/test_model_field.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import shutil import sys import tempfile import unittest @@ -23,7 +24,10 @@ WorkerResult, build_worker_command, effective_model_from_command, + effective_reasoning_effort_from_command, load_engines, + load_model_identity_registry, + parse_reported_model, preflight_engine_bins, validate_manifest_engines, ) @@ -85,8 +89,69 @@ def test_returns_empty_when_absent_or_missing_value(self) -> None: self.assertEqual("", effective_model_from_command(["codex", "--model"])) self.assertEqual("", effective_model_from_command(["codex", "--model="])) + def test_reads_codex_and_claude_reasoning_flag_forms(self) -> None: + self.assertEqual( + "high", + effective_reasoning_effort_from_command( + ["codex", "-c", "model_reasoning_effort=high"] + ), + ) + self.assertEqual( + "medium", + effective_reasoning_effort_from_command(["claude", "--effort", "medium"]), + ) + self.assertEqual( + "low", + effective_reasoning_effort_from_command(["claude", "--effort=low"]), + ) + class ModelPlaceholderTests(unittest.TestCase): + def test_built_in_claude_engine_is_pinned_and_fail_closed(self) -> None: + engine = load_engines(None)["claude"] + self.assertEqual("claude-sonnet-5", engine.model_default) + + command = build_worker_command( + engine, + taskdir=Path("/tmp/claude-task"), + spec="write the file", + full_access=False, + engine_args=("--effort", "medium"), + ) + self.assertTrue(Path(command[0]).name.startswith("claude")) + self.assertEqual("write the file", command[command.index("-p") + 1]) + self.assertEqual("claude-sonnet-5", command[command.index("--model") + 1]) + self.assertEqual("acceptEdits", command[command.index("--permission-mode") + 1]) + self.assertIn("--no-session-persistence", command) + self.assertEqual("medium", effective_reasoning_effort_from_command(command)) + settings = json.loads(command[command.index("--settings") + 1]) + self.assertTrue(settings["sandbox"]["enabled"]) + self.assertTrue(settings["sandbox"]["failIfUnavailable"]) + self.assertFalse(settings["sandbox"]["allowUnsandboxedCommands"]) + + full_access_command = build_worker_command( + engine, + taskdir=Path("/tmp/claude-task"), + spec="write the file", + full_access=True, + ) + self.assertIn("--dangerously-skip-permissions", full_access_command) + full_settings = json.loads( + full_access_command[full_access_command.index("--settings") + 1] + ) + self.assertFalse(full_settings["sandbox"]["enabled"]) + + reported = parse_reported_model( + '{"type":"system","model":"claude-sonnet-5"}', + engine.model_report_regex, + ) + self.assertEqual("claude-sonnet-5", reported) + identity = load_model_identity_registry().resolve("claude", reported) + self.assertEqual( + ("Claude Sonnet 5", "Anthropic", "Claude Code"), + (identity.model_display, identity.lab, identity.harness), + ) + def test_model_default_fills_placeholder(self) -> None: cmd = build_worker_command( harness_engine(), taskdir=Path("/tmp/t"), spec="do it", full_access=False @@ -283,7 +348,26 @@ def test_preflight_catches_missing_engine_binary(self) -> None: with self.assertRaisesRegex(ValueError, "binary not found.*npm install -g @openai/codex"): preflight_engine_bins(manifest, config) + def test_preflight_names_claude_install_and_login(self) -> None: + broken = EngineConfig( + name="claude", + bin="/nonexistent/path/to/claude", + args_template=("-p", "{spec}"), + full_access_args=(), + sandbox_args=(), + token_regex=None, + ) + config = self.config({"claude": broken}) + manifest = self.manifest(self.base_task(engine="claude")) + with self.assertRaisesRegex( + ValueError, + "binary not found.*@anthropic-ai/claude-code.*claude auth login", + ): + preflight_engine_bins(manifest, config) + def test_preflight_accepts_absolute_and_path_resolved_binaries(self) -> None: + path_resolved = shutil.which("sh") or shutil.which("git") + self.assertIsNotNone(path_resolved) absolute = EngineConfig( name="worker", bin=sys.executable, @@ -294,7 +378,7 @@ def test_preflight_accepts_absolute_and_path_resolved_binaries(self) -> None: ) bare = EngineConfig( name="shellworker", - bin="sh", + bin=Path(path_resolved).name, args_template=("{spec}",), full_access_args=(), sandbox_args=(),