diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0fae09d..c9bb844 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -25,6 +25,12 @@ "source": "./plugins/pr-flow", "description": "PR review feedback loop for Claude Code. Create PRs with readiness checks, commit + push + trigger @claude review, inspect status, and work through review issues interactively.", "version": "1.2.2" + }, + { + "name": "swarm", + "source": "./plugins/swarm", + "description": "Local mixture-of-agents code review for Claude Code. Will fan out to Claude subagents plus codex and grok CLIs, merge and verify findings into one ranked report. Phase 1 ships the backend adapter and /swarm:agents status.", + "version": "0.1.0" } ] } diff --git a/.claude/knowledge/_index.md b/.claude/knowledge/_index.md index 2384125..76056e6 100644 --- a/.claude/knowledge/_index.md +++ b/.claude/knowledge/_index.md @@ -13,6 +13,7 @@ - `features/herdr-kickoff-automation.md` — herdr `herdr-launch.sh`: `launch` (`/kickoff`) + `resume` (`/continue ` reopens an `/exit`-closed tab) - `features/herdr-close-automation.md` — `/close` in herdr: cwd-tab teardown, plugin SessionEnd hook, the one TUI-exit primitive, detached self-exit onto idle - `features/task-archiving-on-close.md` — `/close` archives (not deletes) the task file; adaptive commit + ff-push to main +- `features/swarm-backend-adapter.md` — Verified codex/grok CLI facts (schema-enforced JSON, effort mapping, stdin hang) behind `swarm`'s adapter script ## Deployment - `deployment/ci-structure-checks.md` — `check-structure.py` as the single automated guard for a build-less repo diff --git a/.claude/knowledge/features/swarm-backend-adapter.md b/.claude/knowledge/features/swarm-backend-adapter.md new file mode 100644 index 0000000..3fad619 --- /dev/null +++ b/.claude/knowledge/features/swarm-backend-adapter.md @@ -0,0 +1,63 @@ +--- +title: "Swarm Backend Adapter Layer" +createdAt: 2026-07-02 +updatedAt: 2026-07-02 +createdFrom: "branch: task/add-swarm-plugin" +updatedFrom: "branch: task/add-swarm-plugin" +pluginVersion: 1.8.2 +prime: false +--- + +# Swarm Backend Adapter Layer + +The `swarm` plugin reviews locally with a mixture-of-agents ensemble: Claude +subagents plus the external `codex` and `grok` CLIs. All deterministic backend +logic lives in one script — `plugins/swarm/scripts/agents.sh` (verbs: `list`, +`available`, `ready`, `run`) — so skills never call an external CLI directly. +The script header documents the per-backend mechanics; this entry captures the +*verified* CLI behavior the adapter is built on and the gotchas that cost a +debugging round. + +## Verified CLI facts (codex 0.128 / grok 0.2.77, 2026-07) + +- **Uniform findings JSON** is achievable from both CLIs: `codex exec + --output-schema ` and `grok --json-schema ''` both enforce a + JSON Schema on the final answer. One bundled schema + (`scripts/schema/finding.schema.json`) feeds both — the ensemble merge never + parses free-form review prose. In strict structured-output modes all + properties must be `required`, so the schema requires every field and uses + honest defaults (`line: 0`, self-reported `confidence`) instead of optionals. +- **Where the JSON lands differs per CLI**: codex writes the pure JSON via + `--output-last-message ` (stdout carries the agent transcript, + stderr the progress log); grok prints a response **envelope** on stdout — + the validated object is its `.structuredOutput` field. +- **grok's default model rejects `--effort`** (`grok-composer-2.5-fast` errors + with "does not support parameter reasoningEffort"). The adapter must pin + `-m grok-build`. grok's effort ladder (low…max) matches code-review's; + codex has no `max` tier → map `max`→`xhigh` (`-c model_reasoning_effort=…`). +- **`grok-composer-2.5-fast` also does not enforce `--json-schema`**: it + returns plain text with `structuredOutput: null` + + `structuredOutputError: "model output was not valid JSON"`. So it cannot + serve as a second grok ensemble voice (besides being same-family-correlated, + which would dilute the ≥2-backend consensus signal). `grok-build` is the + only schema-capable grok model; for "more grok" at high effort, prefer its + native `--best-of-n N` over a second model voice. +- **Headless tool execution**: both CLIs run read-only commands (e.g. + `git diff`) without extra approval flags — codex inside `-s read-only` + sandbox, grok headless `-p` auto-approves read-only tools. So lens prompts + may either inline the diff or instruct the agent to read it itself. + +## Gotchas (found in E2E testing, fixed in the adapter) + +- **codex hangs on inherited stdin.** With an open non-TTY stdin, `codex exec` + waits for "additional input from stdin" *in addition to* the positional + prompt — in a background shell this hangs forever. Always call it with + ` # installed? prints version +agents.sh ready # authenticated? hint on stderr if not +agents.sh run [--prompt-file f] [--effort E] [--model M] [--schema f] + # lens prompt in → findings JSON out +``` + +Backends: + +| Backend | Role | Mechanics | +|---------|------|-----------| +| `claude` | probe-only | reviews run in-session via the Agent tool | +| `codex` | external reviewer | `codex exec --output-schema` in a read-only sandbox; auth via `codex login status` | +| `grok` | external reviewer | headless `-p` with inline `--json-schema`; findings extracted from the response envelope | + +Unavailable backends drop silently from the ensemble — `claude` alone still +works. + +### Shared findings schema (`scripts/schema/finding.schema.json`) + +Both external CLIs enforce the same JSON schema on their output, so the +ensemble merge receives uniform findings: + +```json +{ + "findings": [ + { + "file": "scripts/foo.sh", + "line": 42, + "severity": "warning", + "summary": "One-sentence statement of the defect", + "failure_scenario": "Concrete, falsifiable inputs → wrong behavior", + "confidence": "high", + "recommendation": "Suggested fix" + } + ] +} +``` +Severity is one of `critical | warning | minor`; confidence one of +`high | medium | low`. + +`failure_scenario` is required and must be falsifiable — it is what the +verifier tests in the confidence phase. + +## Requirements + +- `python3` on PATH (JSON handling in the adapter). +- `codex` and/or `grok` CLIs are optional — install and authenticate them to + widen the ensemble. diff --git a/plugins/swarm/docs/pipeline-blueprint.md b/plugins/swarm/docs/pipeline-blueprint.md new file mode 100644 index 0000000..8be760a --- /dev/null +++ b/plugins/swarm/docs/pipeline-blueprint.md @@ -0,0 +1,257 @@ +# `/swarm:review` Pipeline Blueprint (P2–P5) + +> Working proof-of-concept for the review pipeline, validated by two live +> ensemble dry-runs on this very branch (2026-07-02/03, runs `wf_b692c02d-990` +> and `wf_354898e8-770`). This is the **starting point for P2**, not shipped +> code — P2 turns it into a registered workflow script under `plugins/swarm/`. +> Design rationale lives in the working task file (kept locally, not committed +> to the repo); this file is the concrete shape. + +## Pipeline shape (4 phases) + +``` +Scope+gate → Fan-out (Claude lenses ∥ codex ∥ grok) → Merge (file,mechanism) → Verify solos → Synthesis +``` + +1. **Scope + lens gating** — one cheap agent reads the diff, classifies the + change kind, and decides which lenses are worth running (a lens that can't + pay off is skipped → whole finder agents saved). Gated-out lenses are + reported, never silently dropped. +2. **Fan-out** — in parallel: one Claude finder per gated lens (reads the file + itself → real line numbers) **plus** codex and grok as full multi-lens + reviews through the adapter (thin wrapper agents; workflow scripts have no + Bash). All emit the shared `finding.schema.json`. +3. **Merge** — an LLM merge step clusters the pooled findings by + `(file, mechanism)` — **not** `(file, line)`: external CLIs number against + the inlined diff, so line equality never matches (dry-run learning L1). + Consensus = ≥2 distinct backends in one cluster ⇒ CONFIRMED. +4. **Verify** — every solo cluster (one backend only) goes through an + adversarial 3-state verifier (CONFIRMED/PLAUSIBLE/REFUTED; PLAUSIBLE is the + default, only REFUTED is dropped). Consensus clusters get a *lighter* verify + rather than a blind skip — agreement across backends that share the same + inlined diff + schema + near-identical prompts can be correlated bias, not + independent proof (see § Security). Prefer cross-family agreement. +5. **Synthesis** — rank (severity, then consensus>solo) and emit the balance + data (severity split, consensus/solo, per-lens raw vs. surviving). + +## Reference script (erprobt — `wf_354898e8-770`) + +Path constants are the only thing P2 must rewrite: `ADAPTER` → +`${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh`; the diff/prompt files come from the +scope step (write to a temp path, or have finders read the diff directly). + +```js +export const meta = { + name: 'swarm-review', + description: 'Local mixture-of-agents review: scope+gate → fan-out → (file,mechanism) merge → verify solos → ranked synthesis.', + phases: [ + { title: 'Scope', detail: 'classify diff + gate lenses' }, + { title: 'Fan-out', detail: 'Claude lenses + codex + grok in parallel' }, + { title: 'Merge', detail: 'cluster by (file, mechanism)' }, + { title: 'Verify', detail: '3-state verify of solos' }, + ], +} + +const ADAPTER = '${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh' // P2: resolve plugin root +const DIFF_FILE = '' // diff text for finders to read +const EXTERNAL_PROMPT = '' // full-review prompt for codex/grok (diff inlined) + +const CANDIDATE_LENSES = ['correctness', 'security', 'style', 'adversarial', 'conventions'] +const LENS_BRIEF = { + correctness: 'shell quoting/word-splitting, exit codes, set -euo pipefail, JSON handling, argv/ARG_MAX, edge cases', + security: 'command/argument injection via prompt or filename, unsafe temp files, data leakage', + style: 'duplication, dead code, unclear constructs, inconsistent idioms', + adversarial: 'challenge the design/assumptions: what did the author assume that the diff does not guarantee?', + conventions: 'repo conventions: naming, doc/README sync, version-sync, sibling-script idioms', +} +const FINDINGS_SCHEMA = { /* shared shape — mirrors scripts/schema/finding.schema.json */ + type: 'object', additionalProperties: false, required: ['findings'], + properties: { findings: { type: 'array', items: { + type: 'object', additionalProperties: false, + required: ['file', 'line', 'severity', 'summary', 'failure_scenario', 'confidence', 'recommendation'], + properties: { + file: { type: 'string' }, line: { type: 'integer' }, + severity: { enum: ['critical', 'warning', 'minor'] }, + summary: { type: 'string' }, failure_scenario: { type: 'string' }, + confidence: { enum: ['high', 'medium', 'low'] }, recommendation: { type: 'string' }, + }, + } } }, +} + +// Phase 1: Scope + lens gating +phase('Scope') +const GATE_SCHEMA = { + type: 'object', additionalProperties: false, required: ['change_kind', 'run', 'skip'], + properties: { + change_kind: { type: 'string' }, + run: { type: 'array', items: { type: 'string' } }, + skip: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['lens', 'why'], properties: { lens: { type: 'string' }, why: { type: 'string' } } } }, + }, +} +const gate = await agent( + `You are the scope/lens-gating step of a code review. Read the unified diff at ${DIFF_FILE}.\n` + + `Candidate lenses: ${CANDIDATE_LENSES.join(', ')}.\n` + + `Decide which lenses are worth running and which to skip because they cannot pay off. Be decisive but do NOT skip security when any code/argument/filename flows to an external process.\n` + + `Return change_kind, run (lens names), skip (lens + one-clause why).`, + { label: 'scope+gate', phase: 'Scope', schema: GATE_SCHEMA, model: 'haiku', effort: 'low' } +) +const runLenses = (gate?.run || CANDIDATE_LENSES).filter((l) => CANDIDATE_LENSES.includes(l)) + +// Phase 2: Ensemble fan-out +phase('Fan-out') +const claudeThunks = runLenses.map((lens) => () => + agent( + `You are the "${lens}" lens finder. Read the diff at ${DIFF_FILE} and review ONLY through the ${lens} lens: ${LENS_BRIEF[lens]}.\n` + + `One finding per defect, concrete falsifiable failure_scenario. Prefix each summary with "[${lens}] ". Empty is valid. Cite real file lines.`, + { label: `claude:${lens}`, phase: 'Fan-out', schema: FINDINGS_SCHEMA, effort: 'medium' } + ).then((r) => ({ backend: 'claude', lens, findings: r?.findings || [] })).catch(() => ({ backend: 'claude', lens, findings: [] })) +) +const externalThunks = ['codex', 'grok'].map((b) => () => + agent( + `You are a thin transport wrapper — do NOT review yourself. Run this with the Bash tool (timeout 600000), wait for it:\n\n` + + `bash "${ADAPTER}" run ${b} --effort high --prompt-file "${EXTERNAL_PROMPT}"\n\n` + + `It prints one JSON object {"findings":[...]} on stdout. Return it VERBATIM. On non-zero exit / no JSON, return {"findings":[]}.`, + { label: `${b}:full`, phase: 'Fan-out', schema: FINDINGS_SCHEMA, agentType: 'general-purpose', model: 'haiku', effort: 'low' } + ).then((r) => ({ backend: b, lens: null, findings: (r && Array.isArray(r.findings)) ? r.findings : [] })).catch(() => ({ backend: b, lens: null, findings: [] })) +) +const voices = await parallel([...claudeThunks, ...externalThunks]) + +const pool = [] +for (const v of voices.filter(Boolean)) for (const f of v.findings) { + let lens = v.lens + if (!lens) { const m = /^\s*\[(\w+)\]/.exec(f.summary || ''); lens = m ? m[1].toLowerCase() : 'unspecified' } + pool.push({ ...f, backend: v.backend, lens }) +} + +// Phase 3: Merge / cluster by (file, mechanism) — LLM step, NOT (file,line) JS +phase('Merge') +let clusters = [] +if (pool.length > 0) { + const CLUSTER_SCHEMA = { + type: 'object', additionalProperties: false, required: ['clusters'], + properties: { clusters: { type: 'array', items: { + type: 'object', additionalProperties: false, + required: ['file', 'line', 'mechanism', 'severity', 'summary', 'failure_scenario', 'recommendation', 'lens', 'member_indices'], + properties: { + file: { type: 'string' }, line: { type: 'integer' }, mechanism: { type: 'string' }, + severity: { enum: ['critical', 'warning', 'minor'] }, + summary: { type: 'string' }, failure_scenario: { type: 'string' }, recommendation: { type: 'string' }, + lens: { type: 'string' }, member_indices: { type: 'array', items: { type: 'integer' } }, + }, + } } }, + } + const numbered = pool.map((f, i) => `#${i} [${f.backend}/${f.lens}] ${f.file}:${f.line} — ${f.summary} :: ${f.failure_scenario}`).join('\n') + const res = await agent( + `Merge/dedup step. ${pool.length} raw findings from claude/codex/grok below. Cluster by UNDERLYING DEFECT — same file + same mechanism = one cluster — EVEN IF line numbers differ (external tools number against the diff; match on meaning, not line). ` + + `Return per cluster: file, representative line, short mechanism key, severity (max of members), summary, strongest failure_scenario, recommendation, dominant lens, member_indices. Every index in exactly one cluster.\n\n` + numbered, + { label: 'merge:cluster', phase: 'Merge', schema: CLUSTER_SCHEMA, effort: 'medium' } + ) + clusters = (res?.clusters || []).map((c) => { + const members = (c.member_indices || []).filter((i) => i >= 0 && i < pool.length) + const backends = Array.from(new Set(members.map((i) => pool[i].backend))).sort() + return { ...c, backends, consensus: backends.length >= 2 ? 'CONFIRMED' : 'solo' } + }) +} +const consensusClusters = clusters.filter((c) => c.consensus === 'CONFIRMED') +const soloClusters = clusters.filter((c) => c.consensus === 'solo') + +// Phase 4: 3-state verify of solos +phase('Verify') +const VERDICT_SCHEMA = { + type: 'object', additionalProperties: false, required: ['verdict', 'evidence'], + properties: { verdict: { enum: ['CONFIRMED', 'PLAUSIBLE', 'REFUTED'] }, evidence: { type: 'string' } }, +} +const verifiedSolos = await parallel(soloClusters.map((c) => () => + agent( + `Adversarial verifier for one solo finding — try to REFUTE it against the real repo.\n` + + `File: ${c.file} (line ${c.line})\nMechanism: ${c.mechanism}\nClaim: ${c.summary}\nFailure: ${c.failure_scenario}\n\n` + + `Read the file / run read-only checks. Verdict: CONFIRMED / REFUTED / PLAUSIBLE (default when unsure) + one-sentence evidence.`, + { label: `verify:${c.file.split('/').pop()}`, phase: 'Verify', schema: VERDICT_SCHEMA, effort: 'medium' } + ).then((v) => ({ ...c, verifier: v?.verdict || 'PLAUSIBLE', evidence: v?.evidence || '' })).catch(() => ({ ...c, verifier: 'PLAUSIBLE', evidence: 'verifier error → PLAUSIBLE' })) +)) + +// Synthesis + balance data +// P2 (see § Security): consensus is a strong prior, not proof — run a light verify +// here too (or require cross-family agreement) instead of stamping CONFIRMED blind. +const finalConsensus = consensusClusters.map((c) => ({ ...c, verifier: 'CONFIRMED', evidence: `agreed by ${c.backends.join('+')}` })) +const finalSolos = verifiedSolos.filter(Boolean).filter((c) => c.verifier !== 'REFUTED') +const refuted = verifiedSolos.filter(Boolean).filter((c) => c.verifier === 'REFUTED') +const sevRank = { critical: 0, warning: 1, minor: 2 } +const all = [...finalConsensus, ...finalSolos].sort((a, b) => (sevRank[a.severity] - sevRank[b.severity]) || (a.consensus === 'CONFIRMED' ? -1 : 1)) +const perLens = {}, survivingPerLens = {} +for (const f of pool) perLens[f.lens] = (perLens[f.lens] || 0) + 1 +for (const c of all) survivingPerLens[c.lens] = (survivingPerLens[c.lens] || 0) + 1 +return { gate, voices, findings: all, refuted, balance: { total: all.length, consensus: finalConsensus.length, solo: finalSolos.length, refuted: refuted.length, rawPerLens: perLens, survivingPerLens } } +``` + +## What the dry-runs proved (and the P2 calibration points) + +- **Structure holds:** 12 agents, 4 phases, 0 errors, ~6 min. The adapter + carries the external voices cleanly. +- **Lens gating works but was too aggressive** — it skipped `security` on the + adapter diff. P2 gate prompt already hardened here: *never skip security + when code/arguments/filenames flow to an external process.* +- **Merge must be the LLM cluster step, not `(file,line)` JS** (L1). With the + cluster step, consensus=0 was *honest* (the backends genuinely found + different defects) — solo+verify is the normal path, consensus the exception. +- **3-state verify earns its place:** it REFUTED a wrong codex claim + (`--skip-git-repo-check` needing a bypass flag) that a naive pipeline would + have shipped. +- **MoA is complementary:** grok found defects the Claude lenses didn't and + vice-versa; codex's one finding was the false one. Different models, different + catches. + +## Security — the trust boundary (MANDATORY for P2, hardened by the loop) + +The diff under review is **untrusted input** that flows into agentic backends and +back into the report. Three loop rounds (one of which exfiltrated a real +credential mid-review) converged on these non-negotiable mitigations: + +1. **Sandbox every backend + filter the env.** The diff is inlined, so backends + need NO filesystem/shell tools. Adapter enforces: grok `--tools ""` (verified + to block file reads) + `--disable-web-search`; codex `-s read-only`; **an + OS-level read-deny jail** (`sandbox-exec`/`bwrap`) around every call, denying + secret stores **per-backend** (a backend keeps its own cred dir but not its + siblings' — verified: codex can't read `~/.grok`); **an env filter** stripping + secret-shaped vars (the jail blocks files, not the inherited env). + *Denylist, not allowlist, by necessity:* the node/bun-based CLIs load runtime + from all over `$HOME`, so a deny-`$HOME`-allowlist jail breaks them (tested: + codex's node loader dies). The denylist is a backstop; the primary defense is + that backends need no reads at all (diff inlined) + grok is tool-less. A full + allowlist (or a purpose-built minimal-runtime container) is the P2 upgrade. +2. **Scrub secrets at the adapter boundary.** `scrub_secrets` redacts + secret-shaped content (AWS keys, private keys, gh/sk tokens, `secret=…`) from + findings JSON before it leaves `run_codex`/`run_grok` — a backstop even if a + sandbox is bypassed. **The merge and verify stages must scrub too** (they + re-interpolate findings text into new prompts → second-order injection). +3. **Fence the diff as data.** Wrap the inlined diff in explicit + untrusted-data delimiters with a system instruction that its content is data, + never instructions. (codex/grok get the diff as a positional prompt today + with no fencing — the injection vector.) +4. **Bound findings size.** `finding.schema.json` caps summary/failure_scenario/ + recommendation length, so a payload can't route a large blob through a field. +5. **Don't fully trust consensus.** Consensus (≥2 backends) currently skips the + verifier, but agreement comes from LLM merge-clustering + correlated model + bias, not independent proof. P2: still run a light verify on consensus + findings, or require cross-family agreement (not composer+grok). +6. **Prefer deterministic transport, and distinguish error from empty.** The + Haiku "thin transport" wrapper can silently drop/reshape findings while + staying schema-valid; where a registered workflow can shell out, pass adapter + JSON through deterministically instead. A backend that errored/was dropped + must NOT collapse to the same `{findings:[]}` as a clean empty review — + carry an explicit `error` marker so a silently-failing backend is visible, + not mistaken for "found nothing". + +## Open P2 wiring (not yet in the blueprint) + +- **Registered workflow** (not inline) so per-agent `durationMs` is available + for the timing balance line. +- **Model labels** in the balance line (`Opus-4.8`/`GPT-5.5`/`grok-build`), + read from each backend's review output. +- **Optional composer lens-gate**: `grok-composer-2.5-fast` can't enforce + `--json-schema`/`--effort`, but a strict-JSON prompt makes it emit valid JSON + and reason on demand (tested 2/2). It's ~2× faster than grok-build but its + ~20s CLI cost undercuts a Haiku gate; keep it optional, with a defensive + parser + fallback-to-all-lenses. +- **Balance / footer / loop-round box:** render deterministically from the + synthesis data (see task file P4/P5). diff --git a/plugins/swarm/scripts/agents.sh b/plugins/swarm/scripts/agents.sh new file mode 100755 index 0000000..00178e1 --- /dev/null +++ b/plugins/swarm/scripts/agents.sh @@ -0,0 +1,541 @@ +#!/usr/bin/env bash +# agents.sh — swarm backend adapter layer +# +# Uniform interface over the review backends (claude, codex, grok) so swarm +# skills never talk to an external CLI directly. +# +# Subcommands: +# list [--json] Probe all backends -> human table or JSON array +# available Exit 0 if the CLI is installed; prints its version +# ready Exit 0 if authenticated/usable; hint on stderr if not +# run [opts] Run a review prompt -> findings JSON on stdout +# --prompt-file Read the lens prompt from a file (default: stdin) +# --effort low|medium|high|xhigh|max (default: xhigh) +# --model Backend model override +# --schema JSON schema to enforce (default: bundled finding.schema.json) +# +# Backend notes (probed against codex 0.128 / grok 0.2.77, 2026-07): +# claude — probe-only: reviews run in-session via the Agent tool, so +# `run claude` is a usage error. available/ready/list include it. +# codex — `codex exec --output-schema` in a read-only sandbox; the pure +# schema JSON arrives via --output-last-message (stdout carries the +# agent transcript, which we discard). Auth: `codex login status`. +# Reasoning effort has no "max" tier -> max maps to xhigh. +# grok — headless `-p` with inline --json-schema; the validated object is +# the `.structuredOutput` field of a response envelope. Needs an +# explicit model (-m): the default model (grok-composer-2.5-fast) +# rejects --effort AND ignores --json-schema (structuredOutput +# stays null) — grok-build is the only schema-capable choice. +# Auth heuristic: non-empty ~/.grok/auth.json (no status command). +# +# Exit codes: 0 ok · 1 unavailable / not ready / run failed · 2 usage error + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_SCHEMA="$SCRIPT_DIR/schema/finding.schema.json" +GROK_DEFAULT_MODEL="grok-build" +# Default HOME so `$HOME` expansions below (auth file, sandbox deny paths) don't +# abort the whole script under `set -u` when HOME is unset. +HOME="${HOME:-$(cd ~ 2>/dev/null && pwd || echo /nonexistent)}" +GROK_AUTH_FILE="${GROK_AUTH_FILE:-$HOME/.grok/auth.json}" + +# Temp file for codex's --output-last-message; must be a global (not a +# function-local) so the EXIT trap still sees it under `set -u`. +TMP_OUT="" +cleanup() { if [[ -n "${TMP_OUT:-}" ]]; then rm -f "$TMP_OUT"; fi; } +trap cleanup EXIT + +print_usage() { + # Usage block = header comment up to (not including) "# Backend notes"; + # bounded by pattern, not line numbers, so header edits can't truncate it. + awk 'NR < 2 {next} /^# Backend notes/ {exit} {sub(/^# ?/, ""); print}' "$0" +} + +usage() { + print_usage >&2 + exit 2 +} + +require_python3() { + command -v python3 >/dev/null \ + || { echo "python3 not found on PATH — required by the swarm adapter" >&2; exit 1; } +} + +column_or_cat() { + # Align TSV into columns when util-linux `column` is present; otherwise pass + # the raw TSV through so `list` degrades instead of dying (exit 127) under + # set -euo pipefail on a minimal host. + if command -v column >/dev/null; then + column -t -s $'\t' + else + cat + fi +} + +# Wall-clock cap for external CLI calls so a hung backend fails fast instead of +# blocking a fan-out forever. Uses coreutils timeout/gtimeout when available; +# passes through unchanged if neither exists (best-effort, never a hard dep). +# Override seconds via SWARM_TIMEOUT; 0 disables. +ADAPTER_TIMEOUT="${SWARM_TIMEOUT:-600}" +_timeout_warned="" +with_timeout() { + if [[ "$ADAPTER_TIMEOUT" == "0" ]]; then "$@"; return; fi + if command -v timeout >/dev/null; then timeout "$ADAPTER_TIMEOUT" "$@" + elif command -v gtimeout >/dev/null; then gtimeout "$ADAPTER_TIMEOUT" "$@" + else + # No coreutils timeout: run bare, but say so once — otherwise the documented + # cap silently never applies (e.g. stock macOS) and a hung backend blocks. + if [[ -z "$_timeout_warned" ]]; then + echo "warning: no timeout/gtimeout on PATH — external calls run WITHOUT the ${ADAPTER_TIMEOUT}s cap (install coreutils, or set SWARM_TIMEOUT=0 to silence)" >&2 + _timeout_warned=1 + fi + "$@" + fi +} + +require_valid_timeout() { + # A malformed SWARM_TIMEOUT would reach `timeout` and exit 125 — which the + # rc==124 checks don't recognize, so every external run would misreport as a + # backend failure. Reject up front. Only the literal integer disables (0). + [[ "$ADAPTER_TIMEOUT" =~ ^[0-9]+$ ]] \ + || { echo "Invalid SWARM_TIMEOUT='$ADAPTER_TIMEOUT' — must be a non-negative integer (seconds; 0 disables)" >&2; exit 2; } +} + +# OS-level read-deny jail for external CLI calls (the root-cause fix for +# "-s read-only still permits file reads"). The diff is untrusted, so an +# injected payload could steer a backend to read local secrets. This denies +# reads of common secret stores while leaving the CLI's own config + the repo +# readable (verified: ~/.aws blocked, ~/.codex readable). macOS: sandbox-exec; +# Linux: bwrap; else passthrough (scrub_secrets + backend flags remain). +# Extra deny paths via SWARM_DENY_PATHS (colon-separated). +_sandbox_deny_paths() { + # $1 = the calling backend (its OWN credential dir stays readable — it needs + # it to authenticate; the OTHER backends' cred dirs are denied so an injected + # read can't steal a sibling's token). A denylist is a backstop, not the + # primary defense: grok runs tool-less, the diff is inlined so backends need + # no file reads at all, and scrub_secrets + env filtering back it up. A full + # allowlist jail is impractical here — the node/bun-based CLIs load runtime + # from all over $HOME, so deny-$HOME breaks them (documented in the blueprint). + local own="${1:-}" + printf '%s\n' \ + "$HOME/.aws" "$HOME/.ssh" "$HOME/.gnupg" "$HOME/.netrc" \ + "$HOME/.config/gcloud" "$HOME/.kube" "$HOME/.docker" \ + "$HOME/.git-credentials" "$HOME/.npmrc" "$HOME/.pypirc" \ + "$HOME/.config/gh" "$HOME/.cargo/credentials" "/etc/master.passwd" \ + "$HOME/.config/anthropic" "$HOME/.config/openai" "$HOME/.claude.json" + if [[ "$own" != "codex" ]]; then printf '%s\n' "$HOME/.codex"; fi + if [[ "$own" != "grok" ]]; then printf '%s\n' "$HOME/.grok"; fi + local extra="${SWARM_DENY_PATHS:-}" + # if-form, not `[[ … ]] && …`: the latter returns 1 when extra is empty, and + # under set -e that aborts the `profile="$(…)"` assignment that calls this. + if [[ -n "$extra" ]]; then printf '%s\n' "${extra//:/$'\n'}"; fi + return 0 +} + +SANDBOX_CMD=() +_sandbox_warned="" +_sandbox_ready="" +_init_sandbox() { + # Lazy, per-backend (needs python3 for realpath). One backend per process, so + # building once is fine. + [[ -n "$_sandbox_ready" ]] && return + _sandbox_ready=1 + local backend="${1:-}" + if command -v sandbox-exec >/dev/null; then + # Build the deny profile via python: realpath each path (defeats symlinks + # like /tmp→/private/tmp, /etc→/private/etc — sandbox-exec matches the + # resolved path) and deny it as BOTH a subpath (dirs + contents) and a + # literal (single files like ~/.netrc). + local profile + profile="$(_sandbox_deny_paths "$backend" | python3 -c ' +import os, sys +rules = [] +for line in sys.stdin: + p = line.strip() + if not p: + continue + rp = os.path.realpath(p) + esc = rp.replace("\\", "\\\\").replace("\"", "\\\"") + rules.append("(subpath \"%s\")" % esc) + rules.append("(literal \"%s\")" % esc) +sys.stdout.write("(version 1)(allow default)(deny file-read* %s)" % " ".join(rules)) +')" + SANDBOX_CMD=(sandbox-exec -p "$profile") + elif command -v bwrap >/dev/null; then + # --tmpfs masks a directory; a regular file (e.g. ~/.netrc) needs a bind of + # an empty source instead — --tmpfs over a file dies with ENOTDIR. + local args=(--dev-bind / /) p + while IFS= read -r p; do + if [[ -d "$p" ]]; then args+=(--tmpfs "$p") + elif [[ -f "$p" ]]; then args+=(--ro-bind /dev/null "$p") + fi + done < <(_sandbox_deny_paths "$backend") + SANDBOX_CMD=(bwrap "${args[@]}") + fi +} + +_env_filter_args() { + # Emit `-u NAME` pairs for secret-shaped env vars: the jail blocks file reads + # but backends inherit the environment, so a secret in AWS_SECRET_ACCESS_KEY / + # *_TOKEN / *_API_KEY would otherwise pass straight through. Backend auth comes + # from its config dir (not env), so stripping these is safe. + local name + while IFS='=' read -r name _; do + case "$name" in + AWS_*|*_TOKEN|*_SECRET|*_PASSWORD|*PASSWD*|*_API_KEY|*APIKEY*|*_CREDENTIALS|GH_TOKEN|GITHUB_TOKEN|NPM_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|XAI_API_KEY|GROK_API_KEY) + printf '%s\n' "-u" "$name" ;; + esac + done < <(env) +} + +sandboxed() { + # OS jail + env filter around an external call. $1 = backend (its own cred dir + # stays readable; siblings' are denied). Warn once if no jail is available. + local backend="$1"; shift + _init_sandbox "$backend" + if ((${#SANDBOX_CMD[@]} == 0)) && [[ -z "$_sandbox_warned" ]]; then + echo "warning: no sandbox-exec/bwrap — external calls run without an OS read-deny jail (secret scrub + env filter still apply)" >&2 + _sandbox_warned=1 + fi + local env_args=() _e + while IFS= read -r _e; do env_args+=("$_e"); done < <(_env_filter_args) + # order: timeout → env (strip secrets) → sandbox-exec (jail) → backend + with_timeout env ${env_args[@]+"${env_args[@]}"} ${SANDBOX_CMD[@]+"${SANDBOX_CMD[@]}"} "$@" +} + +scrub_secrets() { + # Last-line-of-defense secret filter on the findings JSON before it leaves the + # adapter. The diff under review is untrusted and a prompt-injected backend + # could try to route a credential into a findings string field; redact + # secret-shaped content here so it can never reach the merged report, even if + # a backend sandbox is bypassed. Redacts (not blocks) so real findings survive. + python3 -c ' +import re, sys +PATTERNS = [ + (re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED-AWS-KEY]"), + (re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"), "[REDACTED-PRIVATE-KEY]"), + (re.compile(r"(?i)aws_secret_access_key\s*[=:]\s*[A-Za-z0-9/+]{20,}"), "aws_secret_access_key=[REDACTED]"), + (re.compile(r"(?i)\b(secret|token|password|passwd|api[_-]?key)\b\s*[=:]\s*[A-Za-z0-9/+._-]{16,}"), r"\1=[REDACTED]"), + (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), "[REDACTED-GH-TOKEN]"), + (re.compile(r"\bsk-[A-Za-z0-9]{20,}"), "[REDACTED-API-KEY]"), +] +data = sys.stdin.read() +hit = False +for pat, repl in PATTERNS: + data, n = pat.subn(repl, data) + if n: hit = True +if hit: + sys.stderr.write("swarm: redacted secret-shaped content from findings before output\n") +sys.stdout.write(data) +' +} + +validate_backend() { + case "$1" in + claude|codex|grok) ;; + *) echo "Unknown backend: $1 (expected claude|codex|grok)" >&2; exit 2 ;; + esac +} + +# ---------- probes ---------- + +available_version() { + # Prints the backend's version line; exit 1 if not installed. + local backend="$1" + if [[ "$backend" == "claude" ]]; then + # claude reviews run in-session via the Agent tool, so inside a Claude + # Code session the backend exists by definition — the PATH lookup only + # provides a nicer version string, never gates availability. + # Capture separately (not `… || echo in-session`): a SIGPIPE from head() + # under pipefail would otherwise run BOTH the real version and the + # fallback, printing two lines. + local cver + cver="$(claude --version 2>/dev/null | head -1 || true)" + echo "${cver:-in-session}" + return 0 + fi + command -v "$backend" >/dev/null || return 1 + # Best-effort version string. `|| true` + explicit `return 0`: once + # `command -v` confirmed the CLI, a non-zero `--version` exit or a SIGPIPE + # from head() (under pipefail) must NOT flip an installed backend to + # "unavailable". + "$backend" --version 2>/dev/null | head -1 || true + return 0 +} + +ready_check() { + local backend="$1" + case "$backend" in + claude) return 0 ;; # in-session, no separate auth + codex) codex login status >/dev/null 2>&1 ;; + grok) [[ -s "$GROK_AUTH_FILE" ]] ;; + esac +} + +ready_hint() { + # claude needs no hint: it is always available + ready in-session. + case "$1" in + codex) echo "run: codex login" ;; + grok) echo "run: grok login" ;; + esac +} + +# ---------- subcommands ---------- + +subcmd_available() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + validate_backend "$backend" + available_version "$backend" +} + +require_usable() { + # Shared installed+ready gate for `ready` and `run`. + local backend="$1" + if ! available_version "$backend" >/dev/null; then + echo "$backend: not installed" >&2 + exit 1 + fi + if ! ready_check "$backend"; then + echo "$backend: not ready — $(ready_hint "$backend")" >&2 + exit 1 + fi +} + +subcmd_ready() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + validate_backend "$backend" + require_usable "$backend" + echo "ready" +} + +print_rows() { + # One TSV row per backend: backend, available, version, ready, hint. + # $1 fills empty fields — the human table needs a placeholder because BSD + # column collapses adjacent tabs, shifting later columns left. + local placeholder="${1:-}" + local b ver avail rdy hint + for b in claude codex grok; do + ver="" avail=no rdy=no hint="" + if ver="$(available_version "$b")"; then + avail=yes + if ready_check "$b"; then rdy=yes; else hint="$(ready_hint "$b")"; fi + else + hint="not installed" + fi + ver="${ver//$'\t'/ }" # a tab inside a version string would shift the TSV columns + printf '%s\t%s\t%s\t%s\t%s\n' "$b" "$avail" "${ver:-$placeholder}" "$rdy" "${hint:-$placeholder}" + done +} + +subcmd_list() { + case "${1:-}" in + --json) + require_python3 + print_rows | python3 -c ' +import json, sys +rows = [] +for line in sys.stdin: + b, avail, ver, rdy, hint = (line.rstrip("\n").split("\t") + [""] * 5)[:5] + rows.append({"backend": b, "available": avail == "yes", "version": ver, + "ready": rdy == "yes", "hint": hint}) +json.dump(rows, sys.stdout, indent=2) +print() +' + ;; + "") + { printf 'BACKEND\tAVAILABLE\tVERSION\tREADY\tHINT\n'; print_rows "-"; } \ + | column_or_cat + ;; + *) + echo "Unknown flag: $1" >&2 + exit 2 + ;; + esac +} + +subcmd_run() { + local backend="${1:-}" + [[ -z "$backend" ]] && usage + shift + validate_backend "$backend" + if [[ "$backend" == "claude" ]]; then + echo "claude reviews run in-session via the Agent tool, not through this adapter" >&2 + exit 2 + fi + + local prompt_file="" effort="xhigh" model="" schema="$DEFAULT_SCHEMA" + while [[ $# -gt 0 ]]; do + [[ $# -ge 2 ]] || { echo "Missing value for $1" >&2; exit 2; } + case "$1" in + --prompt-file) prompt_file="$2"; shift 2 ;; + --effort) effort="$2"; shift 2 ;; + --model) model="$2"; shift 2 ;; + --schema) schema="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac + done + case "$effort" in + low|medium|high|xhigh|max) ;; + *) echo "Invalid effort: $effort (low|medium|high|xhigh|max)" >&2; exit 2 ;; + esac + [[ -f "$schema" ]] || { echo "Schema not found: $schema" >&2; exit 2; } + + # The prompt travels as ONE argv word, so the binding limit is the per-argument + # cap, not total ARG_MAX: Linux MAX_ARG_STRLEN is 128 KiB (macOS has no + # per-arg cap but a ~1 MiB total). Cap at 120 KiB to stay under the Linux + # per-arg limit with headroom for the schema arg + environment. Measure BYTES + # (a multibyte prompt would slip a `${#prompt}` char-count yet overflow exec), + # and for a file check its size BEFORE reading it (a 500 MiB file would + # otherwise be slurped into a shell variable first). + local max_bytes=122880 nbytes + local prompt + if [[ -n "$prompt_file" ]]; then + [[ -f "$prompt_file" ]] || { echo "Prompt file not found: $prompt_file" >&2; exit 2; } + nbytes=$(wc -c < "$prompt_file") + (( nbytes > max_bytes )) && { echo "Prompt file too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } + prompt="$(cat "$prompt_file")" + else + # Guard against blocking forever on an interactive/absent stdin: with no + # --prompt-file and a TTY on fd 0, `cat` would hang waiting for input. + [[ -t 0 ]] && { echo "No prompt: pass --prompt-file or pipe the prompt on stdin" >&2; exit 2; } + prompt="$(cat)" + nbytes=$(printf '%s' "$prompt" | wc -c) + (( nbytes > max_bytes )) && { echo "Prompt too large ($(( nbytes / 1024 )) KiB > 120 KiB) — inline less of the diff, or have the agent read it itself" >&2; exit 2; } + fi + [[ -z "$prompt" ]] && { echo "Empty prompt (use --prompt-file or stdin)" >&2; exit 2; } + + require_usable "$backend" + require_python3 + require_valid_timeout + + case "$backend" in + codex) run_codex "$prompt" "$effort" "$model" "$schema" ;; + grok) run_grok "$prompt" "$effort" "$model" "$schema" ;; + esac +} + +run_codex() { + local prompt="$1" effort="$2" model="$3" schema="$4" + [[ "$effort" == "max" ]] && effort="xhigh" + + TMP_OUT="$(mktemp)" + + # Array (not unquoted ${model:+…}) so a model name with whitespace is one + # argv word, matching the effort_args idiom in run_grok. + local model_args=() + [[ -n "$model" ]] && model_args=(-m "$model") + + # The schema-validated JSON lands in $TMP_OUT; codex's stdout copy of the + # final message is discarded (its transcript goes to stderr = debug info). + # stdin must be closed: with an inherited open non-TTY stdin, codex waits + # for "additional input from stdin" and hangs. + # `--` ends flag parsing: a prompt starting with "-" (e.g. a markdown + # bullet) would otherwise be rejected as an unknown flag. + # 2>/dev/null discards codex's reasoning transcript (goes to stderr): under + # injection it could echo a secret it read, and it never passes scrub_secrets. + # The exit code (incl. 124 timeout) still drives error handling. + local rc=0 + sandboxed codex codex exec -s read-only --skip-git-repo-check \ + -c model_reasoning_effort="$effort" \ + ${model_args[@]+"${model_args[@]}"} \ + --output-schema "$schema" \ + --output-last-message "$TMP_OUT" \ + -- "$prompt" /dev/null 2>/dev/null || rc=$? + if (( rc != 0 )); then + (( rc == 124 )) && echo "codex exec timed out after ${ADAPTER_TIMEOUT}s" >&2 || echo "codex exec failed" >&2 + exit 1 + fi + [[ -s "$TMP_OUT" ]] || { echo "codex produced no output" >&2; exit 1; } + # Validate SHAPE, not just JSON syntax: a valid-but-wrong object (no findings + # array) would otherwise pass through and crash the merge step downstream. + python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + sys.stderr.write("codex returned invalid JSON\n"); sys.exit(1) +if not (isinstance(d, dict) and isinstance(d.get("findings"), list)): + sys.stderr.write("codex output is not a {findings:[...]} object\n"); sys.exit(1) +' <"$TMP_OUT" || exit 1 + scrub_secrets <"$TMP_OUT" + echo +} + +run_grok() { + local prompt="$1" effort="$2" model="$3" schema="$4" + local grok_model="${model:-$GROK_DEFAULT_MODEL}" + + # Preflight-reject non-default models: only grok-build enforces --json-schema + # (and accepts --effort). Any other model would silently return + # structuredOutput:null and the run would fail late with no schema output — + # so reject up front with a usage error rather than burn a review on it. + # (Schema-less models like grok-composer-2.5-fast belong on the caller's own + # defensive-parse path, not this schema-enforcing adapter.) + if [[ "$grok_model" != "$GROK_DEFAULT_MODEL" ]]; then + echo "grok model '$grok_model' does not enforce --json-schema — the adapter requires schema output; use $GROK_DEFAULT_MODEL (default)" >&2 + exit 2 + fi + + # --single= (not "-p "): as a separate argv word a prompt + # starting with "-" would be parsed as a flag. + # Sandbox: the diff is untrusted and could try to steer grok into reading + # local secrets or fetching a URL to exfiltrate. grok reviews the diff INLINE + # in the prompt, so it needs NO tools — `--tools ""` (empty allowlist) + # removes file/shell access (verified: grok then can't read files), and + # `--disable-web-search` closes the network channel. scrub_secrets on the + # output is the belt-and-braces backstop. + local raw rc=0 + raw="$(sandboxed grok grok -m "$grok_model" --effort "$effort" \ + --tools "" --disable-web-search \ + --json-schema "$(cat "$schema")" \ + --single="$prompt" /dev/null)" || rc=$? + if (( rc != 0 )); then + (( rc == 124 )) && echo "grok timed out after ${ADAPTER_TIMEOUT}s" >&2 || echo "grok failed" >&2 + exit 1 + fi + printf '%s' "$raw" | python3 -c ' +import json, sys +data = sys.stdin.read() +try: + d = json.loads(data) +except Exception: + # Do NOT echo the raw bytes: on the error path they never pass scrub_secrets + # and could carry injected/secret content. Report size only. + sys.stderr.write("grok returned invalid JSON (%d bytes; content withheld)\n" % len(data)) + sys.exit(1) +if not isinstance(d, dict): + sys.stderr.write("grok returned non-object JSON (%s)\n" % type(d).__name__) + sys.exit(1) +if d.get("type") == "error": + sys.stderr.write("grok error: %s\n" % d.get("message", "unknown")) + sys.exit(1) +out = d.get("structuredOutput") +if out is None: + sys.stderr.write("grok returned no structuredOutput\n") + sys.exit(1) +if not (isinstance(out, dict) and isinstance(out.get("findings"), list)): + sys.stderr.write("grok structuredOutput is not a {findings:[...]} object\n") + sys.exit(1) +json.dump(out, sys.stdout) +print() +' | scrub_secrets +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + list) subcmd_list "$@" ;; + available) subcmd_available "$@" ;; + ready) subcmd_ready "$@" ;; + run) subcmd_run "$@" ;; + -h|--help) print_usage; exit 0 ;; + "") usage ;; + *) echo "Unknown subcommand: $cmd" >&2; usage ;; + esac +} + +main "$@" diff --git a/plugins/swarm/scripts/schema/finding.schema.json b/plugins/swarm/scripts/schema/finding.schema.json new file mode 100644 index 0000000..e4c1471 --- /dev/null +++ b/plugins/swarm/scripts/schema/finding.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Swarm review findings", + "description": "Shared findings shape every swarm backend normalizes into. Enforced on codex via `codex exec --output-schema` and on grok via `--json-schema`, so the ensemble merge receives uniform JSON. All item fields are required (strict structured-output modes reject optional properties); use line=0 and confidence as honest defaults rather than omitting.", + "type": "object", + "additionalProperties": false, + "required": ["findings"], + "properties": { + "findings": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "file", + "line", + "severity", + "summary", + "failure_scenario", + "confidence", + "recommendation" + ], + "properties": { + "file": { + "type": "string", + "maxLength": 500, + "description": "Repo-relative path of the file the finding is in" + }, + "line": { + "type": "integer", + "minimum": 0, + "description": "1-indexed line the finding anchors to; 0 for file-level findings" + }, + "severity": { + "enum": ["critical", "warning", "minor"], + "description": "critical = correctness/security defect; warning = likely problem or risky pattern; minor = cleanup/style" + }, + "summary": { + "type": "string", + "maxLength": 400, + "description": "One-sentence statement of the defect" + }, + "failure_scenario": { + "type": "string", + "maxLength": 1200, + "description": "Concrete inputs/state leading to wrong output or crash. Must be falsifiable — this is what the verifier tests. For style findings: the concrete maintenance cost." + }, + "confidence": { + "enum": ["high", "medium", "low"], + "description": "Reviewer's own confidence that the finding is real" + }, + "recommendation": { + "type": "string", + "maxLength": 800, + "description": "Suggested fix in one or two sentences. maxLength caps here (and on summary/failure_scenario) also bound how much data an injected payload could route through a finding field." + } + } + } + } + } +} diff --git a/plugins/swarm/skills/agents/SKILL.md b/plugins/swarm/skills/agents/SKILL.md new file mode 100644 index 0000000..37a0bd3 --- /dev/null +++ b/plugins/swarm/skills/agents/SKILL.md @@ -0,0 +1,41 @@ +--- +name: agents +description: | + Shows swarm backend status: which review agents (claude, codex, grok) are + installed and authenticated. + Trigger: "swarm agents", "which review backends are live", "agent status". +user_invocable: true +--- + +# Swarm Agent Status + +> Probe all review backends and report which are live. + +## Instructions + +1. Run: `bash "${CLAUDE_PLUGIN_ROOT}/scripts/agents.sh" list --json` +2. Render the JSON array as a table: + + | Backend | Installed | Version | Ready | Notes | + |---------|-----------|---------|-------|-------| + + - `available: false` → Installed ❌, Notes = "not installed" + - `available: true, ready: false` → Ready ❌, Notes = the `hint` field (e.g. "run: codex login") + - both true → ✅ ✅, Notes empty +3. Close with one line stating which backends are live (all with + `available && ready`), e.g.: + `Live backends: claude + codex + grok — full ensemble.` + If only claude is live, note that installing/authenticating the external + CLIs (`codex`, `grok`) would widen the ensemble. Do not reference other + swarm commands until they ship. + +## Notes + +- Read-only, no side effects — safe to run anytime. +- `claude` is always ready when Claude Code runs (reviews happen in-session + via the Agent tool; the external CLIs are called through the adapter). +- **`grok` Ready is a heuristic** — it means a non-empty `~/.grok/auth.json` + exists, NOT that the token is valid/unexpired (codex, by contrast, runs a + real `codex login status`). So grok can show Ready yet fail at review time on + a stale token; treat it as "credentials present" and let the run surface a + real auth error.