documentation

revmux

A supervised multi-agent review, normally launched by your coding agent rather than typed by you. revmux spawns claude --print and codex exec subprocesses, watches them, and returns findings on stdout as JSON or markdown.

What revmux is #

revmux runs a review and returns findings, and does nothing else. It performs no scope detection, no git operations, no PR fetching and no source modification. It has zero VCS dependency: no git library, no git subprocess, no repository walking. All review context is written to disk by the caller and passed in as a task round.

That caller is normally a model. You ask your coding agent for a review, and the shipped skill does the rest: it works out what is under review, gathers the context, writes the round, launches revmux, reads the report back and acts on it. To that agent revmux is a black box with a stable contract, context in and a verified report out, which is why the report is JSON by default and why every subcommand answers in JSON too. Running it by hand works exactly the same way and is how the pages here describe it, but it is not the usual path.

The subject does not have to be code. A round is a scope description plus whatever context you put beside it, so a branch, a pull request, a design document, an implementation plan, a proposal or a filed issue all go in the same way. The eight code lenses read a change; the expert profile rates what goes wrong if the thing is built and run as written, so it reads a plan as readily as a diff; and triage runs a four-way panel over a filed item, where the severities rate how much a point bears on the decision rather than what breaks at runtime.

The split is deliberate. The agents themselves run diff commands inside the working directory you point them at, and revmux only substitutes a path. Anything that would make revmux read a repository belongs in the caller.

What you get over an ad hoc fan-out. A watchdog that notices a stall, a kill and retry you own, a live view of every agent with per-agent token counts, and a run archive that survives the session. The subprocess does not make the model faster; it makes the run recoverable and auditable.

Install #

Homebrew, on macOS:

brew install umputun/apps/revmux

It is a cask, so brew install --cask umputun/apps/revmux is the explicit spelling of the same thing, and brew upgrade picks up later releases.

Binaries and packages

Every release carries prebuilt binaries for macOS and Linux on amd64 and arm64, as .tar.gz archives plus .deb and .rpm packages. This is the Linux path, since Homebrew casks are macOS only.

dpkg -i revmux_<version>_linux_amd64.deb
rpm -i revmux_<version>_linux_amd64.rpm

From source

With a Go toolchain:

go install github.com/umputun/revmux/app@latest

The binary is installed as app, so rename it to revmux, or build from a clone instead:

git clone https://github.com/umputun/revmux.git && cd revmux
make build        # produces .bin/revmux
make install      # and symlinks it to /usr/local/bin/revmux

make install links rather than copies, so a later make build is picked up without reinstalling. Override the location with BINDIR when /usr/local/bin is not writable, as in make install BINDIR=~/bin. make uninstall removes the link.

The agent skill

The binary runs a review; the skill is what asks for one. Install it in Claude Code with two slash commands, and the repository becomes a plugin marketplace of one:

/plugin marketplace add umputun/revmux
/plugin install revmux@revmux

For Codex CLI, copy the tree instead: cp -r plugins/codex/skills/revmux ~/.codex/skills/revmux. After either, ask for a review in words and the skill does the rest. What it does is further down.

Model CLIs

revmux drives the model CLIs as subprocesses, so whichever ones your profile names must already be installed and authenticated. Which those are is a property of the profile, not a fixed pair:

preflight.sh in the shipped skill answers it for any profile and any invocation, --lenses included.

ANTHROPIC_API_KEY is stripped from the child environment by default so claude uses interactive subscription auth. Pass --preserve-anthropic-api-key if you authenticate by key. CLAUDECODE is always stripped, since a claude child refuses to start when it thinks it is a nested session.

Quick start #

revmux new creates the round and prints every path you write to, so nothing constructs a path by hand:

$ revmux new --task pr-123 --run 01-initial
{
  "task_dir":  "/abs/.revmux/tasks/pr-123",
  "task_file": "/abs/.revmux/tasks/pr-123/task.md",
  "round_dir": "/abs/.revmux/tasks/pr-123/01-initial",
  "input_dir": "/abs/.revmux/tasks/pr-123/01-initial/input",
  "scope":     "/abs/.revmux/tasks/pr-123/01-initial/input/scope.md",
  "goal":      "/abs/.revmux/tasks/pr-123/01-initial/input/goal.md",
  "profile":   "/abs/.revmux/tasks/pr-123/01-initial/input/profile.md",
  "context":   "/abs/.revmux/tasks/pr-123/01-initial/input/context",
  "created":   ["task_dir", "task_file", "round_dir", "input_dir"]
}

Take the scope path out of that payload rather than joining it yourself, write the scope into it, and run the review. The same call again is safe: a round already scaffolded is reported rather than recreated.

scope=$(revmux new --task pr-123 --run 01-initial | jq -r .scope)
cat > "$scope" <<'EOF'
Review the changes on this branch against master.
Diff command: git diff master...HEAD
EOF

revmux --task pr-123 --run 01-initial

That runs the comprehensive profile, shows a live TUI, and writes the report to stdout as JSON. After fixing something, open a new round on the same task and revmux carries the earlier rounds into every prompt:

revmux new --task pr-123 --run 02-after-fix     # then write its own input/scope.md
revmux --task pr-123 --run 02-after-fix > findings.json

Three stages #

Only the roster and the severity bar vary between review shapes, so everything else is configuration.

  1. find. The profile's roster runs in parallel: several claude agents, each composing one or more lenses, plus a codex peer. Launch is staggered, agent one first and the rest released once it produces its first output. Each agent returns structured findings.
  2. synthesize. One model call. It merges every source's findings, dedupes on (file, line ±2), boosts confidence where distinct sources corroborate, splits out open questions and pre-existing issues, and drops weak singletons. It is told the true source roster as data, including which agents degraded.
  3. verify. Parallel agents grouped by directory, thin directories merged and the group count capped. Each verifier sees only its own group, so it cannot anchor on a neighbouring finding. Every finding comes back with a verdict: confirmed, refined, rejected, immaterial or pre-existing.

--verify-group-by source keys the groups by the agent that raised the finding and skips the thin merge instead, so a panel of one-argument agents does not collapse into a single verifier. --no-synthesis passes findings through with their attribution intact, and --no-verify marks every finding unverified rather than silently claiming it was checked.

Codex is a peer source, not a second pass. It runs alongside the lens agents and its findings go through the same synthesis and verification. Ordering the two would mean the second reviewer sees the first's findings and anchors on them, which is exactly what the cross-source confidence boost assumes did not happen.

Supervision and degrade #

A finder that produces no output for --idle-timeout (2m by default) is killed and retried once. So is one that exceeds --hard-timeout (20m per attempt). On a second failure the agent is marked degraded and the run continues: the report banner names the missing agent, and synthesis is told the real source count rather than being left to assume a full roster. A run where every source degraded is a tool error, not a clean empty report.

Verification is single-attempt. A verifier that fails, times out, or answers with something carrying no verdicts leaves its own group unverified rather than being relaunched, and a verdict the model omits or spells wrongly leaves that one finding unverified. The findings themselves are never dropped by such a failure: silence from a verifier is not a rejection.

Agent processes are started in their own session, so the terminal never signals them directly. A delivered SIGINT or SIGTERM cancels the run and revmux tears each process group down itself, rather than leaving the model CLIs and everything they spawned running unsupervised.

What counts as a source #

A source is a process. The cross-source confidence boost counts distinct processes, never tags and never lenses. An agent carrying two lenses that flags the same issue under both is still one source, because it cannot corroborate itself.

The wire format keeps the distinction in two fields that are never interchangeable. sources holds agent names such as ["bugs+impl", "codex"] and is the only input to the boost. lenses holds the lens names that raised the finding and is informational. revmux stamps sources itself once the model's output is parsed, and no schema exposes the field, so an agent cannot name itself twice.

Task directory #

Review context reaches revmux only as a task round the caller has filled. --task names a task under --tasks-dir (default ./.revmux/tasks), and --run names one round inside it. Both names are caller-chosen and semantic; revmux allocates neither.

<tasks-dir>/pr-123/               a task: one subject, reviewed over as many rounds as it takes
├── task.md                       optional; front matter identifying the task
├── 01-initial/                   a round
│   ├── input/                    caller-written; the only channel review context travels through
│   │   ├── scope.md              {{SCOPE}}    required; missing or empty is a load-time error
│   │   ├── goal.md               {{GOAL}}     optional
│   │   ├── profile.md            {{PROFILE}}  optional, the project's own conventions
│   │   └── context/              {{CONTEXT}}  optional: ticket text, design notes, spec excerpts
│   └── ...                       revmux-written artifacts, see the run archive
└── 02-after-fix/                 the next round, with its own input/

Context belongs to the round, not to the task. Round two reviews the fixes for what round one found: a different scope, usually a different goal. Kept at task level they would be overwritten by whoever composes the next round, taking the record of what the previous round reviewed with them.

Variables expand to the paths of these files, never to their contents, and the agent reads them itself. Prompt composition stats them and never opens one, so no prompt can be bloated by a large scope. An absent optional file expands to none provided, which is not an error: the run proceeds with generic severity calibration.

There are no --goal, --goal-file, --profile-file or --context-file flags. One mechanism, no precedence rules, and nothing for revmux to author.

--run has no default: the round holds your own context, so revmux cannot name one you have not filled. A round that has already run is an error rather than an overwrite, because a round that went badly is exactly the one worth keeping.

Neither name may contain a path separator or .., be absolute, or begin with a dot. A round additionally may not be called task.md: that is the one entry the task directory keeps beside its rounds, and a round named after it would be read as the task's own metadata.

revmux new

revmux new --task <id> --run <name> creates the task directory, a commented-out task.md, the round and its input/, then prints every path you write to as JSON along with a created list naming which of them this call made. It creates the tasks root itself too, so a first run on a clean checkout materializes ./.revmux/tasks/ as well. Everything else in revmux opens and never creates, so a typo'd --task on a review is an error rather than an empty task nobody filled.

It never overwrites. An existing task.md is left alone, and a round that has already run is refused. A round whose review was interrupted before it finished is not one that has run: it is scaffolded and reviewed again under the same name, with the input/ you wrote still in it, provided that review had not already written artifacts into the round. If it had, new refuses the name and says what is in there, so it never hands back a round the review itself would reject.

task.md

Optional, at task level, and about the task rather than about any one round:

---
description: OAuth token exchange rework
url: https://github.com/umputun/revmux/pull/123
branch: feature/oauth
base: 4ed3259
---

Reviewing the token exchange path after the provider swap.

Every key is optional, as is the body and the file itself. revmux config reports the front matter under paths.tasks, which is how a caller matches an existing task instead of guessing at an id. Opening pr123 beside an existing pr-123 silently forks the history into two.

revmux stores and reports these; it never resolves one. No git command runs against branch or base, and nothing is fetched from url. They are strings you wrote and strings you read back.

Prior rounds

Prior rounds are injected into every composed prompt. revmux wrote them, so it hands them over rather than making the caller copy them forward. The injected block is the task directory path plus a generated one-line inventory per round: name, when it ran, finding counts by severity, and which sources degraded. An agent can judge relevance without opening anything, and read a round in full when that matters.

The block carries its own re-evaluate-independently instruction, and on a first round it is omitted entirely. It is not a {{VAR}}, because a variable would be opt-in per file and any lens or profile omitting it would silently lose the history.

Two precedence chains #

Runtime knobs resolve from the command line, then ./.revmux/config, then ~/.config/revmux/config, then the built-in default. Layers merge per key, so a project config setting one knob leaves the rest alone. The project layer is auto-detected: no flag selects it, and its absence simply drops it.

Prompt and lens files resolve from ./.revmux/, then ~/.config/revmux/, then the defaults built into the binary, per file. Overriding one lens leaves every other lens where it was, and deleting an override falls back to the embedded copy rather than disabling the lens. To actually drop a lens, remove it from the profile roster.

The project layer is a review standard you can commit

What a project actually cares about, its conventions, what counts as major, the mistakes it keeps repeating, usually lives in a maintainer's head and reaches contributors one review comment at a time. revmux init writes it to disk instead: the config template plus every prompt file as it currently resolved, ready to edit.

Checked into .revmux/, that tree is the project's review, versioned and diffable like the rest of the code. Everyone who clones the repository runs it, so a contributor gets the review a maintainer would have run before the maintainer opens the pull request. A finding traces back to the lens text that raised it rather than to a prompt nobody kept. A review that missed something is fixed by editing a file, once, and every later round has the fix. What belongs there is anything a reviewer would otherwise have to be told: a lens for a subsystem with its own failure modes, a severity bar that says what major means here, a profile shaped like the review this project actually runs.

.revmux/ is code. The project layer supplies prompt text as well as knobs, and that text becomes the instructions a headless agent with a shell executes. Running revmux inside a repository trusts it the same way .claude/ or a Makefile there does. Review it before reviewing a branch you did not write, or run revmux from outside the tree: the project layer is read from the process working directory, never from --workdir.

~/.config/revmux/
├── config                    INI, runtime knobs only
├── prompts/
│   ├── profiles/
│   │   ├── comprehensive.md  roster front matter + shared preamble + severity bar
│   │   ├── focused.md  final.md  claude-only.md  codex-only.md
│   │   └── grill-me.md  expert.md  triage.md
│   ├── synthesis.md
│   └── verify.md
└── lenses/
    ├── bugs.md  impl.md  architecture.md
    ├── quality.md  docs.md  tests.md  comments.md  adversarial.md
    └── grounding.md  precedent.md  thesis.md  antithesis.md  cost.md

--config-dir relocates the user layer. revmux init materializes ./.revmux/ from whatever resolved, and --dump-defaults <dir> extracts the embedded prompt tree instead, which is how a customized file is diffed against the shipped one. Neither overwrites a file you have customized, and a normal run writes no config at all.

Paths resolve against the process working directory: the project config layer, and --tasks-dir's ./.revmux/tasks default. --workdir is separate, setting where the subprocesses run and what {{WORKDIR}} expands to. Reviewing a repository from outside it means passing --config-dir and --tasks-dir as well.

Profiles #

A profile is roster front matter plus a body that is the shared preamble and severity bar. The top-level model is the review's runner; a roster entry or a stage naming its own overrides it.

---
description: all eight lenses across three claude agents plus an adversarial codex peer
model: claude/opus:high
agents:
  - {name: bugs+impl,    lenses: [bugs, impl],            color: cyan}
  - {name: arch+quality, lenses: [architecture, quality], color: magenta}
  - {name: docs+tests,   lenses: [docs, tests, comments], color: green}
  - {name: codex, lenses: [adversarial], model: codex/gpt-5.6-sol:high, color: yellow}
---

The top-level runner is inheritance, not a fixed review topology. This supports single-vendor profiles when only one CLI is available, mixed peers for independent perspectives, a wide lower-effort finder roster followed by a stronger synthesis model, or a high-effort verifier where false positives are expensive. Each roster entry remains a distinct source regardless of which binary runs it, while its lenses define the job independently of the runner.

The model string

One model string selects the binary, the model and the effort together. The binary leads and is mandatory, either claude or codex, so a value validates itself and revmux never has to guess which CLI runs gpt-5.6-sol from a catalog of model names that would go stale.

claude                   claude, its own default model and effort
claude/opus:high         fully specified
codex/gpt-5.6-sol        effort falls back to the profile's, then the binary's
codex:high               codex's default model at high effort

The three travel together because they are not independent: opus means nothing to codex. A file cannot state a pairing that will not run, and an entry naming a different binary than the profile brings its own model rather than inheriting one belonging to the other. A trailing slash is refused, since claude/ is a second spelling of claude. It parses on the first / so a model whose own name has one survives, and on the last :, whose suffix must be a real effort: :hgih is a load error rather than a typo nobody sees.

A stage resolves through three layers in turn: its stages: override, the stage file's own model:, then the profile's. The shipped synthesis.md and verify.md name no runner of their own, so codex-only is one line and no more. The optional stages block is for a deliberately mixed run, such as codex finders and a claude synthesis:

stages:
  synthesis: claude/opus:high

Everything is validated at load. An unknown binary, effort, lens or color is a startup error, never a silent default: a typo'd model quietly changing which model reviews your code is worse than a failed launch.

Shipped profiles

profile roster
comprehensive bugs+impl, arch+quality, docs+tests on claude, the last carrying comments too, plus an adversarial codex peer
focused one bugs agent plus the codex peer, for a small or time-boxed change
final bugs+impl plus the codex peer, nothing below major reported
claude-only the same four lens splits on claude, no codex peer, for a machine with no codex
codex-only the same four lens splits on codex, and synthesis and verify with them, no claude anywhere
grill-me bugs+impl and architecture+quality, each run once on claude and once on codex, every agent reading against the change
expert two agents at the highest effort, codex gpt-5.6-sol:xhigh and claude fable:xhigh, each carrying all eight lenses, both stages on fable
triage facts, thesis, antithesis on claude plus cost on codex: a panel over a filed item rather than a diff, and it wants --no-synthesis

expert is for when the answer matters more than the wall clock, and it is expensive enough to be worth asking for deliberately. Both agents read everything rather than splitting the lenses, so where the two models independently agree the cross-source boost measures genuine corroboration rather than two halves of one review. Its severity bar rates what goes wrong if the thing is built and run as written, so a plan reads as naturally as a diff.

Writing your own

The eight are starting points, not the menu. A profile is a file under prompts/profiles/ in any of the three layers, so dropping .revmux/prompts/profiles/release.md into a project makes --profile release work, with no registration step anywhere. The same is true of lenses: a roster naming payments resolves lenses/payments.md through the same chain, and a file only your project has resolves in your project.

---
description: pre-release pass over the payment path
model: claude/opus:high
agents:
  - {name: money,     lenses: [bugs, impl, payments],   color: red}
  - {name: contracts, lenses: [architecture, docs],     color: cyan}
  - {name: peer,      lenses: [adversarial], model: codex/gpt-5.6-sol:xhigh}
  - {name: second,    lenses: [bugs],        model: codex/gpt-5.6-sol:high}
stages:
  synthesis: claude/opus:high
  verify:    claude/sonnet:low
---

Everything below the front matter is the preamble every agent in this roster
receives, including what counts as critical, major and minor here.

Four sources, two vendors, four different jobs, and a synthesis model stronger than the verifier after it. There is no fixed shape to a review: the roster is as wide as you are willing to pay for, each entry carries as many lenses as the job needs, and any entry can leave the profile's model for its own. Copy a shipped profile and edit it rather than starting from an empty file, since the body carries the severity bar the findings are calibrated against.

triage reviews an issue, a proposal or a discussion instead of a change. Its severities rate how much a point bears on the decision rather than what goes wrong at runtime, and it returns arguments for a maintainer to weigh. revmux decides nothing. Run it with --no-synthesis: every argument on a four-way panel is single-source by construction, so the drop rule eats the minor ones and the confidence boost fires on agreement between agents told to disagree. --verify-group-by source keeps each panelist's case in front of its own verifier.

Lenses #

Executor and lens are orthogonal. Every roster entry composes lenses, and its model: only selects which binary runs it. There is no codex-specific prompt file: codex is an entry whose model: names it, composing lenses/adversarial.md, so the adversarial lens runs on claude by changing one word and bugs runs on codex the same way. Lens text stays executor-agnostic, and the output-contract difference (claude has --json-schema, codex does not) is injected by the executor.

lens covers
bugscorrectness defects: logic and boundaries, nil and bounds, concurrency, resource lifetime, error handling
implgoal fit: whether the change does what it set out to do, is wired up, and is proportionate
architectureconventions and organization: the project's own rules, established patterns, dependency and interface shape
qualitystyle, over-engineering, error handling and accidental duplication in code that already works
docsdocumentation accuracy: doc comments against the code, and the project docs the change leaves stale
testswhether tests exist where a defect can hide, actually exercise the code, and survive concurrency
commentsthe code's own stated rules: doc comments and inline notes the change was supposed to obey
adversarialattacks the change looking for what a sympathetic reader would accept
groundingwhether what a filed item claims is true of the code as it stands today
precedenthow comparable asks were decided here before, and whether that bears on this one
thesisthe strongest honest case that a filed item should be done or that its report is real
antithesisthe strongest case against, and whether something simpler reaches the same goal
costwhat implementing a filed item reaches into, and whether the work is proportionate

The last five read a filed item rather than a diff and are what the triage profile composes; the eight above them review a change.

--lenses bugs,impl replaces a profile's roster while keeping its body. It produces one agent carrying every named lens, not one agent per lens: a caller asking for two lenses is asking for a viewpoint, not for two corroborating votes. The synthesized entry inherits the profile's top-level model whole, binary included, so --profile codex-only --lenses bugs runs on codex.

Prompt composition #

One agent's prompt is the profile body plus each of its lens files, concatenated, with {{VAR}} substituted and the prior-rounds block appended. The variable vocabulary is closed: {{SCOPE}}, {{GOAL}}, {{PROFILE}}, {{CONTEXT}}, {{WORKDIR}}, plus {{FINDINGS}} for both model stages and {{SOURCES}} for synthesis only. Verify sees one group at a time and is never given the roster. A prompt file naming anything else fails at load, which is what makes a typo loud instead of silent.

Runtime knobs #

These read from the config file under the same name as the flag. The reference carries the full flag list.

flagdefaultmeaning
--idle-timeout2mkill and retry an agent after this long with no output
--hard-timeout20mkill an agent after this long, per attempt
--stagger-delay30show long to wait for the first agent before releasing the rest
--max-parallel4how many agents run at once
--verify-groups6cap on the number of verifier groups
--verify-group-bydirkey verifier groups by directory or by the agent that raised the finding
--tasks-dir./.revmux/tasksroot directory holding task directories
--auto-exit0sclose the terminal UI this long after the report arrives; 0 never closes it
--profilecomprehensiveprofile naming the roster to run

--task and --run are both required for a review, and neither is a config key: a config file naming the round to write would make the same command review different context in different directories.

The report #

The report goes to stdout as JSON, or as markdown with --markdown. The TUI renders to the tty and progress lines go to stderr, so revmux --task pr-123 --run 02-after-fix > findings.json works with the display running. The TUI is gated on the tty being openable, never on stdout being a terminal, which is false in exactly that invocation.

{
  "scope": {"task": "pr-123", "run": "02-after-fix",
            "scope_path": "/abs/.revmux/tasks/pr-123/02-after-fix/input/scope.md"},
  "sources": {
    "expected": 4, "reported": 3, "degraded": ["docs+tests"],
    "agents": [
      {"name": "bugs+impl", "lenses": ["bugs", "impl"], "executor": "claude",
       "requested_model": "opus", "actual_model": "claude-opus-5",
       "effort": "high", "tokens": 48210, "raised": 6, "degraded": false}
    ]
  },
  "findings": [
    {"id": "f1", "file": "app/pipeline/find.go", "line": 88, "end_line": 0,
     "severity": "major", "confidence": 90,
     "title": "...", "body": "...", "fix": "...",
     "sources": ["bugs+impl", "codex"], "lenses": ["bugs", "adversarial"],
     "verdict": "confirmed"}
  ],
  "open_questions": [], "pre_existing": [], "immaterial": [],
  "stats": {
    "started_at": "2026-07-26T16:02:11Z", "finished_at": "2026-07-26T16:07:44Z",
    "duration_ms": 333000, "tokens": 184920,
    "stages": [{"name": "find", "duration_ms": 201000},
               {"name": "synthesis", "duration_ms": 62000,
                "executor": "claude", "model": "opus", "effort": "high"}]
  }
}

line is the anchor and end_line is optional: zero means a single line, and a zero line means a file-level finding that renders as the bare path. verdict is one of confirmed, refined, rejected, immaterial, pre_existing, or unverified when nobody checked it, whether because the stage was skipped or because that group's verifier failed. Empty lists are emitted as arrays rather than null.

--min-confidence filters once, before anything renders, and the printed report, the findings browser and the exit code are all computed from the filtered set. A finding the exit code says is absent is never listed in the TUI. Open questions, pre-existing and immaterial findings pass through untouched.

Exit codes #

codemeaning
0no findings above --min-confidence
1findings above --min-confidence. A normal outcome, not a failure
2 tool error: bad config, unreadable prompt tree, an omitted --run, a round with no input/ or an empty scope.md, a round that has already run or is being written by another run, an unwritable run artifact, or every source degraded

The subcommands use the same 2 for their own tool errors and never exit 1: there is no report and so no threshold to be above. A run that exits 2 usually leaves no report, the exception being a failure writing the report to stdout, which happens after the round is archived.

A configuration error is caught before the round is claimed at all, so it leaves no manifest.json and the name is free. Anything that fails once the pipeline has started leaves an empty marker beside what it had written, and both revmux new and the run itself refuse the name and say what the round holds. Either way the input/ you wrote is untouched.

Run archive #

Every run writes its artifacts into its own round directory, beside the input/ it was pointed at. They exist so a review can be audited without re-running it, which the final report alone cannot support, and because the round holds its own context, one round read in isolation shows both what was reviewed and what came back.

<tasks-dir>/pr-123/02-after-fix/
├── input/                    the scope, goal, profile and context this round was reviewed against
├── manifest.json             roster, prompt provenance and hashes, requested vs actual model, timings
├── prompts/
│   ├── agents/               composed prompt per agent, post-substitution: the bytes the model saw
│   │   ├── bugs+impl.md
│   │   └── codex.md
│   └── stages/               separate from agents/ so an agent named `verify` cannot collide
│       ├── synthesis.md
│       ├── verify-app-executor.md      one per group, directories by default
│       └── verify-app-pipeline.md
├── stages/                   a skipped stage writes no snapshot
│   ├── 1-found.json          findings as the find stage left them
│   ├── 2-synthesized.json
│   └── 3-verified.json
├── events.jsonl              revmux's own decisions: stalls, retries, degrades, stage transitions
├── agents/                   verbatim tees; own subdir so an agent named `events` cannot collide
│   ├── bugs+impl.jsonl       claude stream-json
│   ├── bugs+impl.retry.jsonl a retried agent keeps both attempts
│   └── codex.log             codex prose
├── report.md                 the filtered report rendered as markdown, what --markdown writes
└── findings.json

manifest.json records which of the three precedence layers supplied each prompt file and its content hash, because two rounds of one task can use different lens text. It also records requested-vs-actual model per agent: claude --model can be silently ignored, so a roster's model pin is a claim until it is read back.

It doubles as the marker claiming the round. It is created exclusively as the run starts, which is both how a round that has already run is detected and how a real round is told from a directory left under the task. It is created empty and filled in when the run finishes, so a marker still empty means the run never came back, and such a round is not counted as a prior round in the meantime.

A round like that is re-runnable under the same name only while nothing else was written into it, which is narrower than it sounds: the pipeline opens events.jsonl before it launches an agent, so a review interrupted at any point after it started has written something. What is re-runnable is a round claimed by a run that died before the pipeline began. The error names what it found, nothing is deleted to make the round usable, and the fix is to open the next round and copy the input/ across.

A round already being written by another revmux is refused too. An empty marker is what an interrupted run leaves and what a run starting right now leaves, so size alone cannot tell them apart. revmux holds an exclusive OS-level lock on the marker for the run's lifetime, and the lock is gone the moment the holding process is, so a round nobody is writing is still re-runnable with nothing to clean up.

A failed archive write fails the run. A report emitted next to a half-written archive reads as complete, and the gap only surfaces later when someone tries to audit it. The one exception is a per-agent tee under agents/, which degrades that one source instead: it belongs to that one agent and is the only artifact whose failure is attributable to a single source.

Rounds accumulate and are never pruned. revmux stats reads them back as numbers, and revmux cleanup is the one command that removes anything.

Terminal UI #

A status table on top, one row per supervised process with its name, state, elapsed time and last activity, and one detail pane below it. The roster fills it first, and the synthesis and verify processes take rows of their own as they start, so the table shows what is running rather than only what the profile named.

The revmux terminal UI with the codex agent tab focused, showing that agent's own scrollback under the status table
The status table stays visible whichever tab is focused, so a stalled agent is noticed while reading another one's output.

The findings count in the header follows the same logic: the finders add to it, a later stage's merged count replaces it, and it is rebuilt from the finished report at the end, since verify rejects findings and --min-confidence filters without either emitting an event. It is shown broken down by severity when the width allows, and colored by the worst severity in it. Red on any critical, yellow on any major, green only when nothing above minor was found.

Tab 1 all is the combined chronological view and is focused by default; the tabs after it are per-agent full-detail scrollback. On completion the model switches to the findings browser, and the agent tabs stay reachable so a reader can check why a finding was raised. Each finding's body and fix render as markdown documents.

Press i to replace those panes with the inputs captured when the TUI started. The status table remains visible, and the input tabs show scope, goal, profile, then each file under context/. Markdown files render as documents up to 64 KiB per file; a larger one falls back to the line-at-a-time rendering the log panes use. The snapshot is read after the tty opens and before any review process starts, and it does not refresh during the run. Headless runs read no snapshot at all.

keysaction
tab shift+tab h lswitch pane
1-9, then a letterfocus that pane directly; the token is shown on the tab
fjump to the findings browser
ishow the startup input snapshot, or return to the review panes
k jscroll
pgup pgdn ctrl+b ctrl+fpage
home end g Gtop, bottom
/filter findings; enter accepts, esc clears
escreturn from the input viewer, or abandon a filter; never quits
qquit, once the report is in
ctrl+cquit, at any point

Only ctrl+c ends a review that is still running. q waits for the report, so a reader who reaches for it as a pager key does not lose the view of a live run. Quitting stops watching the run, it does not stop the run: the report is still written to stdout when the pipeline finishes.

With --no-tui, or when the tty cannot be opened, the same events render as timestamped lines on stderr, each agent in its own color:

16:02:11 bugs+impl     started [bugs, impl]
16:02:19 arch+quality  reading the roster resolution path
16:04:02 docs+tests    retrying: agent docs+tests stalled
16:05:12 bugs+impl     done, 6 findings
16:05:40               ── synthesis ──
16:09:03               ── complete ──
16:09:03               6m52s, sources 4/4, degraded none
16:09:03               6 findings: 1 major, 5 minor

The closing three lines are written after the last event, to say what the run came to. They carry counts only: the findings themselves go to stdout, and a degraded run names its missing sources here rather than leaving the log looking like a complete one.

Ctrl-C delivers a signal under --no-tui. While the TUI is running it does not: the terminal is in raw mode, so the keystroke reaches revmux as a key rather than a signal. A second Ctrl-C, once the TUI has restored the terminal, cancels.

revmux config #

revmux is normally driven by a caller model, so the resolved configuration is machine-readable rather than something to reconstruct from --help and a directory listing. revmux config prints it as JSON on stdout and exits 0. It runs no pipeline and creates nothing; the only thing it touches is a read of the tasks root.

It reports what resolved, never what is embedded: a user who overrode one lens and added another sees his own tree. Each runtime knob carries the precedence layer that supplied it, so a caller can tell a deliberate choice from a default. Flags may precede the subcommand, which is how a caller asks what a given invocation would resolve to:

$ revmux --stagger-delay=45s config
{
  "knobs": [
    {"name": "stagger-delay", "value": "45s", "source": "flag"},
    {"name": "max-parallel",  "value": 2,     "source": "project"},
    {"name": "profile", "value": "comprehensive", "source": "default"}
  ],
  "profiles": [{"name": "comprehensive", "description": "...",
                "runner": {"executor": "claude", "model": "opus", "effort": "high"},
                "roster": [...], "stages": [...]}],
  "lenses": [{"name": "adversarial", "description": "..."}],
  "stages": [{"name": "synthesis", "description": "..."}],
  "vocabulary": {"executors": ["claude", "codex"],
                 "efforts": ["low", "medium", "high", "xhigh", "max"]},
  "paths": {
    "tasks_dir": "/abs/project/.revmux/tasks",
    "config_dir": "/home/user/.config/revmux",
    "project_dir": "/abs/project/.revmux",
    "workdir": "/abs/project",
    "tasks": [{"id": "pr-123", "description": "OAuth token exchange rework",
               "url": "...", "branch": "feature/oauth", "base": "4ed3259",
               "rounds": ["01-initial", "02-after-fix"]}]
  }
}

The top-level stages array is the stage prompt itself, with a runner only if that file authored one, which the shipped pair do not. What actually runs is profiles[].stages, and each profile also reports its own base runner as profiles[].runner: the one the roster falls back to and the one the single agent --lenses synthesizes runs on. That is why a preflight check can tell which binaries an invocation needs.

paths.tasks is the task store: every task that already exists, whatever its task.md says about it, and the rounds recorded under it. Rounds are those that ran to completion, so neither a directory prepared but not yet reviewed nor a round whose review was interrupted is one. An empty list always means empty: a tasks root that could not be read is reported as paths.tasks_error, and nothing that failed is reported as nothing being there.

revmux init #

revmux init materializes ./.revmux/ so there is something local to edit: the commented-out config template, plus every prompt file as it currently resolved. --init is the same implementation behind a flag, for a caller that already builds an argument list.

What it writes is the winning layer's own bytes, front matter included. A user with ~/.config/revmux/ overrides gets those copied down rather than the shipped text, so editing the result changes the review that already runs instead of reverting it to the default one. --dump-defaults <dir> is the other direction, and the only way to reach the embedded copy for a diff.

{
  "dir": "/abs/project/.revmux",
  "config": "/abs/project/.revmux/config",
  "files": [
    {"path": "/abs/project/.revmux/lenses/bugs.md", "layer": "user", "created": true},
    {"path": "/abs/project/.revmux/prompts/synthesis.md", "layer": "embedded", "created": true}
  ]
}

layer is where the content came from: project, user or embedded. created is false for a file already there, which is reported and left byte-identical, so a second run changes nothing and no prompt file you customized is ever overwritten.

The config is reported as a path alone because it is not materialized the same way. It ships commented out, and one holding no uncommented key is rewritten with the current template, which is what lets an upgrade move a default you never set. A config carrying an actual setting is left exactly as it is.

revmux stats #

revmux stats reads what past rounds produced and prints it as JSON on stdout. It runs no pipeline, spawns no agent and writes nothing: it is arithmetic over the archive, so it is always safe to call.

$ revmux stats                    # every task under the tasks root
$ revmux stats --task pr-123      # one task

{
  "tasks": [
    {"id": "pr-123", "description": "the auth refactor", "rounds": 5,
     "size_mb": 6.6, "last_run": "2026-07-27", "skipped": [],
     "agents": [{"name": "bugs+impl", "raised": 8, "survived": 8, "corroborated": 5,
                 "degraded_rounds": 0, "retries": 0, "tokens": 10441185}],
     "lenses": [{"name": "bugs", "raised": 14, "ambiguous": 3,
                 "verdicts": {"confirmed": 4, "refined": 6, "unverified": 4}}],
     "stages": [{"name": "synthesis", "in": 62, "out": 46},
                {"name": "verify", "in": 46, "out": 46},
                {"name": "report", "in": 46, "out": 46}]}
  ],
  "totals": {"rounds": 5, "size_mb": 6.6, "last_run": "2026-07-27", ...}
}

Per agent. raised is what it put on the table before synthesis merged anything; survived is what was still there in the round's last stage snapshot, counted across all four of that report's arrays; and corroborated is the subset of those another agent independently reached. The attribution is exact rather than model-supplied.

Per lens. raised counts the find stage only, since after synthesis a finding's lenses is a union across merged findings from different agents. ambiguous is the part of it attributable only by the raising agent's whole lens set, which is what the find stage falls back to when the model named no valid lens. A per-lens number is only as good as its ambiguous share, so the two belong together wherever either is quoted.

A lens whose raised sits well above its verdict total lost findings somewhere between the two, but not necessarily to the verifier: synthesis merging two findings that carry the same lens produces the same gap. Read it as attrition to look into rather than as rejections counted.

Per stage. in and out for synthesis, verify and report, each the union of that report's four finding arrays. report carries the --min-confidence attrition, and there is no find entry since nothing goes into it. reclassified and refined are there because in and out understate verification badly: a finding moved into immaterial or pre_existing leaves the total unchanged. Over one corpus verify dropped 2 findings of the 150 that reached it while lowering the severity of 28.

Per task. size_mb is what the task occupies, summed from file sizes rather than disk blocks. last_run is the finished_at of the newest round's manifest.json, so it says when the task was last reviewed rather than when anything last touched the directory. A round skipped because its artifacts would not decode is named in skipped, with the artifact and the reason, so a corpus that shrank does not read as a corpus that is simply smaller.

Every survivor and every per-lens number comes from the per-stage snapshots under stages/, never from the round's findings.json, which is the filtered report. Two numbers come from elsewhere and say so: the report stage entry reads findings.json precisely to measure what the filter removed, and retries comes from events.jsonl.

revmux cleanup #

revmux cleanup --task <id> removes one task and everything under it, and prints what went as JSON. It is the only thing in revmux that deletes anything: a review, new, init, config and stats remove nothing, so nothing is ever removed as a side effect of doing something else.

$ revmux cleanup --task since-1f21e93
{
  "tasks_dir": "/repo/.revmux/tasks",
  "removed": [{"id": "since-1f21e93", "rounds": 5, "size_mb": 6.6}],
  "total_mb_after": 6.4
}

The archive grows by roughly half a megabyte per round and revmux never prunes on its own, so reclaiming is a decision rather than a policy. There is no age threshold, no size cap and no all-tasks form. What to remove is read off revmux stats, which reports every task's size, round count, description and date.

It removes a whole task, never a round inside one. A task's rounds are one review's history and are read together; a task that quietly lost its early rounds would keep being reported by revmux stats as the whole record.

It refuses more than it removes. A name that is not one task directly under the tasks root is an error and nothing is removed. An absent --task names the flag rather than meaning every task. A task a running review holds is refused, though that is a check taken as it goes rather than a lock held across the removal, so do not run it against a task under review.

Agent skills #

revmux is built to be driven by a caller model, and the repository ships that caller as a skill for two harnesses. Ask for a review in words and the skill does the rest: it resolves what is being reviewed, runs the git commands, writes the round's input/, launches revmux, reads the JSON back, and opens a new round on the same task after fixes.

harnesslocationinstall
Claude Code .claude-plugin/skills/revmux/ /plugin marketplace add umputun/revmux then /plugin install revmux@revmux
Codex CLI plugins/codex/skills/revmux/ cp -r plugins/codex/skills/revmux ~/.codex/skills/revmux

Asked for a pull request, the skill fetches the head into a throwaway worktree, points --workdir at it while running from the main checkout, so the archive outlives the checkout and the branch's own .revmux/ never loads, and removes both the worktree and the temp branch afterwards. Asked to triage a filed item instead of a change, it gathers the issue or discussion, its thread and the author's history into context/, runs the triage panel over them and puts the maintainer's six answers to him with the arguments behind each.

Both trees carry the same reference material and the same scripts:

preflight.sh
check revmux plus the binaries a given profile and invocation need, --lenses included
task-state.sh
resolve the tasks root from revmux config and report what a task holds: its task.md anchors, its rounds, and each round's input/ state
launch-revmux.sh
run revmux with its TUI in a terminal overlay (agterm, tmux, Zellij, herdr, kitty, wezterm, cmux, ghostty, iTerm2, Emacs vterm), returning the report on stdout and revmux's own exit code
analyze-corpus.py
read the run archive and report what it says about the review itself: which stage is filtering, which lens rates hardest, whether the gating count converges

The launcher exists because an agent's shell has no tty, so the TUI never appears there. The overlay is how a user watches a review happen; everything else about the run is identical. It forwards PATH into the overlay deliberately, since revmux spawns claude and codex itself and an overlay shell inherits an environment that predates the user's shell rc files. ANTHROPIC_API_KEY is not forwarded, since an env KEY=VAL prefix would put it in the process argv.

Development #

make build    # build .bin/revmux
make install  # symlink .bin/revmux into $BINDIR (default /usr/local/bin)
make test     # race detector plus coverage, mocks excluded
make lint     # golangci-lint plus shellcheck over the shipped scripts
make fmt      # gofmt and goimports

No test spawns a real model. The executors are driven through a mocked CommandRunner against recorded CLI fixtures, the pipeline through mocked runners, and the TUI through synthetic bubbletea messages. revmux is MIT licensed; the source is on GitHub.