MMR Reference

What MMR is

Multi-Model Review runs your changes past several independent AI code reviewers ("channels"), then reconciles their findings into a single de-duplicated list and a verdict that gates the work. No channel ever sees another channel's output — agreement between them is what raises confidence, and disagreement is what surfaces ambiguity.

The core idea in five moves

  1. Resolve a diff — from a PR, staged changes, a branch range, or a piped diff.
  2. Dispatch channels — each channel is a separate subprocess given the same prompt, run in parallel and isolated packages/mmr/src/commands/review.ts:636.
  3. Parse — each channel's raw output is parsed into a common Finding shape.
  4. Reconcile — findings are grouped by a stable key, de-duplicated, and scored for agreement and confidence packages/mmr/src/core/reconciler.ts:43.
  5. Verdict — a severity gate yields pass, degraded-pass, blocked, or needs-user-decision packages/mmr/src/types.ts:25.

Two layers, one mental model. The mmr CLI is the engine that dispatches the built-in channels and computes the verdict. The scaffold run review-pr / review-code wrappers sit on top: they add a Superpowers code-reviewer agent channel via mmr reconcile, handle auth recovery, and drive the fix loop.

End-to-end flow

A single mmr review … --sync run walks the whole pipeline. Channels fan out in parallel; everything converges at reconciliation.

#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#000000;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#666;stroke:#666;}#my-svg .marker.cross{stroke:#666;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#000000;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#000000;color:#000000;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#eee;stroke:#999;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#666!important;stroke-width:0;stroke:#666;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#666;stroke-width:1px;}#my-svg .flowchart-link{stroke:#666;fill:none;}#my-svg .edgeLabel{background-color:white;text-align:center;}#my-svg .edgeLabel p{background-color:white;}#my-svg .edgeLabel rect{opacity:0.5;background-color:white;fill:white;}#my-svg .labelBkg{background-color:rgba(255, 255, 255, 0.5);}#my-svg .cluster rect{fill:hsl(0, 0%, 98.9215686275%);stroke:#707070;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(-160, 0%, 93.3333333333%);border:1px solid #707070;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#000000;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:white;text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:white;padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:white;fill:white;}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#999;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#999;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}Resolve diff(--pr / --staged--diff / --base)Build prompt(+ focus, criteria)codexantigravityclaudegrokopencode(opt-in)doc-conformance(opt-in)Parse→ FindingReconcile(dedupe + score)Verdict(gate + exit code)

Compensating passes (see Degraded mode below) are injected after the first dispatch round for any channel that was unavailable, then folded back into the same reconcile step.

The mmr review command

One command, several input modes. Pick the flag that matches your target; everything else is control and output options. Type in the box to filter the table.

FlagGroupDescription
--diff <path|->inputRead a unified diff from a file, or - for stdin. Highest-priority input mode.
--pr <n>inputFetch the PR diff via gh pr diff.
--stagedinputReview staged changes (git diff --cached).
--base <ref> [--head <ref>]inputReview a branch range (git diff base...head, head defaults to HEAD).
(no input flag)inputFalls back to unstaged working-tree changes (git diff).
--focus <text>controlFree-text focus areas appended to every channel prompt.
--fix-threshold <P0|P1|P2|P3>controlSeverity gate. Findings at or above this block. Default P2 (from .mmr.yaml).
--channels <names…>controlRun only these channels, overriding config defaults. Abstract channels are filtered out.
--timeout <seconds>controlPer-channel timeout override.
--template <name>controlUse a named review-criteria template from config.
--format <json|text|markdown>outputOutput format. Default json.
--syncmodeRun the full pipeline (dispatch → parse → reconcile → verdict) and return results. Without it, dispatch is fire-and-forget.
--dry-runmodeResolve the diff and assemble the prompt without dispatching any channel.
--compensate-missingmodeAlso run a compensating pass for channels whose CLI isn't installed. Off by default — see Degraded mode.
--session <id>roundsLink this run into a multi-round session; the id must match ^[A-Za-z0-9_-]+$ and not be a reserved name packages/mmr/src/commands/sessions.ts:15.
--round <n>rounds1-based round counter within a session.
--max-rounds <n>roundsHard cap on rounds; exceeding it exits 3 before dispatch. Without --session, it defaults to defaults.loop_control.max_rounds_default (5). With --session and no --max-rounds, middleware hardcodes 5 and your configured max_rounds_default is ignored packages/mmr/src/commands/review.ts:387 — pass --max-rounds explicitly if you configured a different cap.
--accept-new-ackstrustTrust acknowledgment files newly introduced by the diff.
--trust-project-ackstrustTrust working-tree project acks in non-Git / untrusted modes.
--trust-project-configtrustTrust working-tree .mmr.yaml in untrusted modes.
--config-base-ref <ref>trustLoad .mmr.yaml and acks from a trusted Git ref instead of HEAD.

Copy-paste commands by target

# PR review (full pipeline, JSON out)
mmr review --pr 123 --sync --format json

# Staged changes before commit
mmr review --staged --sync --format json

# All tracked uncommitted changes (no untracked)
git diff HEAD | mmr review --diff - --sync --format json

# Branch range
mmr review --base main --head "$BRANCH" --sync --format json

# A single file's current contents, as an "all-added" diff
(diff -u /dev/null path/to/file.ts || true) | mmr review --diff - --sync --format json

# Only specific channels (e.g. just grok + claude)
mmr review --pr 123 --channels grok claude --sync --format json

Other subcommands

CommandPurpose
mmr reconcile <job-id> --channel <name> --input <data>Inject an external channel's findings (e.g. the Superpowers agent) into an existing job and re-run the results pipeline. Input is a file, - for stdin, or inline JSON. packages/mmr/src/commands/reconcile.ts:17
mmr status <job-id>Per-channel status and elapsed time. Exit 0 = all complete, 1 = running, 2 = a channel failed, 5 = not found.
mmr results <job-id> [--raw]Re-run parse → reconcile → format on a completed job. Exit code reflects the verdict.
mmr jobs <list|prune>List jobs, or prune old ones per job_retention_days.
mmr sessions <start|list|show|end> <id>Manage multi-round review sessions (stored under ~/.mmr/sessions/).
mmr config <init|test|channels|path|show|enable|disable|set|unset>Scaffold, inspect, and mutate .mmr.yaml. init scaffolds; test pre-flights install + auth; channels lists (add --format text for a table with a provenance SOURCE column); show <name> inspects one channel with provenance; path discloses the read/write search order; enable/disable <channel> toggle a channel; set <dotted.path> <value> / unset <dotted.path> edit any value (validated before write). All mutators are scope-aware (--global/--project) and never leave an invalid config on disk.
mmr doctor [--fix] [--format json]Diagnose every channel's health (install + auth) with per-channel remediation. --fix disables channels whose CLI is not installed (records to ~/.mmr/config.yaml).
mmr critique [input]Multi-model design critique of an artifact — advisory, never gates. A peer to review, not a code review. See below.
mmr commands [--format json]Machine-readable capability manifest — every command with a runnable example and a writes flag. Agents load this once instead of probing --help.
mmr explain [<topic>]Inline just-in-time docs for a concept (channels, config, scopes, compensation, redaction, provenance). No arg lists the topics.
mmr ack <add|list|rm|prune>Sticky acknowledgments — silence a finding by its stable key so it stops blocking across rounds. --scope project (default, ./.mmr/acks) or user (~/.mmr/acks); project acks shadow user acks. A re-worded finding still matches via the same shingle threshold. prune is a no-op stub today packages/mmr/src/commands/ack.ts:95.
mmr skill install --platform <name> | --allInstall a "use MMR for code review" skill into a project per agent CLI: Cursor (.cursor/rules/mmr-review.mdc), Codex + Antigravity (shared AGENTS.md managed block), OpenCode (.opencode/skills/mmr/SKILL.md, a full auto-discovered Agent Skill). Supports --dry-run, --force, and --dir. packages/mmr/src/commands/skill.ts:85
# Capture a job_id from a review, then fold in an agent channel:
mmr reconcile "$JOB_ID" --channel superpowers --input findings.json

# Install the MMR review skill into the current project for one or all agent CLIs:
mmr skill install --platform cursor
mmr skill install --all --dry-run

# Turn a channel off / on without hand-editing YAML (writes channels.<name>.enabled):
mmr config disable grok      # not-installed channels record to ~/.mmr/config.yaml; --project to scope to repo
mmr config enable grok       # also clears any legacy channels_disabled entry
mmr config path              # show where config is read from and written to

Each agent CLI reads its own instruction file, so mmr skill install writes the skill in the matching convention: a dedicated .cursor/rules/mmr-review.mdc for Cursor, and an idempotent managed block (delimited by <!-- BEGIN mmr-skill --> and <!-- END mmr-skill -->) in AGENTS.md (Codex and Antigravity share the AGENTS.md standard, so both resolve to the same block). For the block-mode files, re-running rewrites only the managed block and leaves the rest of the file intact; the dedicated Cursor file is created fresh and needs --force to overwrite. The skill bodies are bundled with the package under packages/mmr/templates/skills/ packages/mmr/templates/skills/agents/mmr-review.md:1.

mmr critique — the design peer

review judges a diff. critique judges a design: a design doc, a plan, or a pasted "problem + proposed solution". Use it before you build, when you want independent models to weigh an approach.

It is advisory and never gates. Once the input resolves it exits 0, whatever the critique says; only a usage error (missing or unreadable input) exits 1. There is no severity, no fix_threshold, and no verdict.

The report has three parts:

FlagEffect
--context repoGround the critique in the codebase so models judge fit, not just the idea. Default none.
--context-paths a.ts,b.tsGround against specific files (implies --context repo).
--session <id>Iterate: each round sees the prior round and your revisions.
--lenses <a,b,…>Give each channel a persona, cycled one per channel. Built-ins: skeptic, simplifier, user-advocate, pragmatist, security, scale. Any other name gets a generic preamble. Passing any lens relabels the output to "perspectives".
--no-synthesisSkip the synthesis pass (deterministic output only).
--format text|jsonDefault text. Note: no markdown, unlike review.

The synthesis pass is conditional, not guaranteed — it runs only when at least two items and two channels came back and the claude channel is installed and authenticated. Otherwise you get the deterministic report alone.

mmr critique docs/design.md --context repo
mmr critique - --focus "scaling" --lenses skeptic,pragmatist

Channel architecture

A channel is pure config data — there is no per-channel code. The dispatcher runs whatever command the channel defines, hands it the prompt, and parses its output with the configured parser. Adding a channel is normally a .mmr.yaml edit, not a code change.

The channel config shape

channels:
  <name>:
    kind: subprocess              # subprocess (default) | http
    enabled: true                 # run by default?
    command: "codex exec"         # whitespace-split, spawned WITHOUT a shell
    flags: ["--ephemeral"]        # appended after the command tokens
    env: { KEY: value }           # extra environment
    cwd: "{{neutral_cwd}}"        # run from a neutral dir (strips project config)
    prompt_delivery: stdin        # stdin (default) | prompt-file
    prompt_wrapper: "{{prompt}}"  # template wrapped around the prompt
    output_parser: default        # default | default-last | gemini | doc-conformance | {kind:…}
                                  # (`gemini` the PARSER is still registered and
                                  #  usable by custom channels — only the gemini
                                  #  CHANNEL is retired)
    stderr: capture               # capture | suppress | passthrough
    timeout: 300                  # seconds (falls back to defaults.timeout)
    auth: { check, timeout, failure_exit_codes, recovery }
    extends: base-channel         # inherit from another channel (≤4 levels)
    abstract: false               # template-only; never dispatched directly
    required: false               # true ⇒ compensate even when not installed
    retired: false                # tombstone; forced off, never dispatched
    headers: { … }                # http channels only (warns on inline secrets)

An http channel swaps command/flags for endpoint, model, endpoint_convention: openai-chat, and an optional api_key_env (sent as Authorization: Bearer … unless api_key_header / api_key_prefix say otherwise) packages/mmr/src/config/schema.ts:178.

Built-in channels

Why grok is different. codex/claude/antigravity all read the prompt from stdin. Grok's CLI requires the prompt as an argument and ignores stdin, so its channel uses prompt_delivery: prompt-file — the dispatcher writes the prompt to a temp file and passes its path via the {{prompt_file}} placeholder. Grok wraps its reply in a JSON .text field, which the parser unwraps before extracting findings.

The defaults, commands, and parsers below are the built-in presets packages/mmr/src/config/defaults.ts:32.

ChannelDefaultStrengthPrompt deliveryParser
codexenabledCorrectness, security, API contractsstdindefault
claudeenabledPlan alignment, code quality, testingstdindefault
grokenabledIndependent second opinion (xAI; proprietary)prompt-fileunwrap $.text → default-last
antigravity (agy)enabledGoogle's CLI reviewer (replaces the retired Gemini)stdindefault
opencode (opc)opt-inOpen-source CLI; independent correctness / code-quality passstdindefault
doc-conformanceopt-inPRD/stories/standards conformance (LLM-graded)stdindoc-conformance
geminiretiredTombstone only — never dispatched——
command: codex exec
flags: [--skip-git-repo-check, -s, read-only, --ephemeral]
auth.check: codex login status        # local file check (fast, 5s)
recovery: codex login
output_parser: default
stderr: suppress
command: agy                          # Google's CLI reviewer (alias of antigravity)
# runs hardened: neutral cwd, --sandbox, auto-approve, real HOME
auth.check: agy -p "respond with ok"  # LLM round-trip, 20s
recovery: agy -p "hello"              # then open the printed Google OAuth URL
output_parser: default
command: claude -p
flags: [--output-format, json]
auth.check: claude -p "respond with ok"   # LLM round-trip, 20s
recovery: claude login
output_parser: default
command: grok
prompt_delivery: prompt-file
flags: [--prompt-file, "{{prompt_file}}", --output-format, json,
        --no-memory, --tools, web_search,web_fetch,
        --disallowed-tools, run_terminal_cmd, --no-subagents, --no-plan,
        --json-schema, "{{findings_schema}}"]
cwd: "{{neutral_cwd}}"                 # neutral cwd + neutral HOME (auth symlinked in)
auth.check: grok models                # lists models / login state (no round-trip)
recovery: grok login
output_parser:
  kind: unwrap-jsonpath
  wrap: "$.text"
  incomplete: { status_path: "$.stopReason", values: [Cancelled] }
  then: default-last                   # grok emits one object PER TURN — take the last

Grok is proprietary (xAI), not open-source — it joins the standard set mechanically as a CLI channel. Disable it with channels_disabled: ["grok"].

Three of those flags are load-bearing rather than cosmetic: --json-schema (substituted with the findings schema) is what makes the final answer land in $.text reliably; default-last is required because grok emits one schema-shaped object per turn; and the incomplete guard catches a stopReason: Cancelled envelope and re-dispatches once instead of parsing a truncated answer.

enabled: false                         # opt-in, like doc-conformance
command: opencode run
flags: [--pure]
cwd: "{{neutral_cwd}}"
env: { OPENCODE_PERMISSION: '{"*":"deny"}' }   # no OS sandbox flag — deny every tool
auth.check: printf "respond with ok" | opencode run --pure
recovery: opencode auth login
output_parser: default
timeout: 300

An open-source AI coding CLI offering an independent correctness / code-quality pass. Because opencode has no OS sandbox flag, every tool is denied via OPENCODE_PERMISSION so the review stays text-in / text-out with no execution surface. Creds live under the real $HOME (~/.local/share/opencode/auth.json). Enable with --channels opencode (alias opc) or channels: { opencode: { enabled: true } } in .mmr.yaml.

enabled: false                         # opt-in: runs up to 3 LLM calls
command: scaffold observe audit --profile=full --scope=all --output-mode=mmr-findings
output_parser: doc-conformance         # expects a JSON array of findings
timeout: 180

Enable with --channels doc-conformance or in .mmr.yaml.

The dispatcher

Adding a new channel — where it's clean vs. hard-coded. Clean (config only): a new subprocess channel (command + flags + auth + output_parser), output reshaping via the unwrap-jsonpath or regex-findings parser kinds, disabling/timeout overrides, and pointing the compensator at a different channel — all pure .mmr.yaml. Needs code: a brand-new named parser must be registered in core/parser.ts packages/mmr/src/core/parser.ts:257; and the COMPENSATING_FOCUS map carries per-channel focus text (falls back gracefully if absent). HTTP-endpoint channels (kind: http) are already supported via dispatchHttpChannel — pure .mmr.yaml, no extra code packages/mmr/src/config/schema.ts:144.

Scaffold wrappers

Direct mmr review runs the built-in CLI channels. The scaffold run wrappers add orchestration on top.

WrapperTargetAdds on top of mmr review
scaffold run review-prA PR (--pr)Auth checks, the Superpowers code-reviewer agent channel via mmr reconcile, consensus/verdict handling, the 3-strike-per-finding round bookkeeping, optional Beads issue bridge.
scaffold run review-codeLocal pre-pushSynthesizes a "delivery candidate" diff (committed + staged + unstaged), gathers file & standards context for the file-blind CLIs, then the same agent channel + round bounding. Untracked files aren't covered.
scaffold run post-implementation-reviewFull codebaseTwo phases — systemic review + per-story functional review via parallel agents — with its own report under docs/reviews/. (See its own doc for the exact channel layout.)

Foreground only. The wrappers' manual fallback runs Codex, Claude, Grok, and Antigravity as foreground Bash calls when the mmr CLI isn't available — never in the background. Background execution produces empty output.

Findings, reconciliation & verdicts

The Finding shape

Every channel's output parses into this common shape packages/mmr/src/types.ts:45.

{
  "id": "F-001",
  "category": "security",
  "severity": "P0",
  "location": "src/auth.ts:42",
  "description": "…",
  "suggestion": "…"
}

The location above (src/auth.ts:42) is illustrative. After reconciliation, each finding also carries confidence, sources[], agreement, a stable finding_key, a description_shingle (for fuzzy cross-round matching), and acknowledged packages/mmr/src/types.ts:54.

Stable identity (finding_key)

finding_key = sha1( normLocation | category | sha1(normDescription) | sha1(normSuggestion) )

Line numbers are stripped from the location and severity is excluded, so the same issue at P1 vs P2 collapses to one key packages/mmr/src/core/stable-id.ts:115. A character-5-gram shingle backs a Jaccard ≥ 0.7 fuzzy match. Intra-run, findings group by fuzzy shingle overlap packages/mmr/src/core/reconciler.ts:83; across rounds, the ack store reuses the same threshold so a re-worded finding still matches a prior ack packages/mmr/src/core/ack-store.ts:8.

Agreement & confidence

Agreement and confidence are derived per group during reconciliation packages/mmr/src/core/reconciler.ts:114.

SourcesSeverityAgreementConfidence
2+sameconsensushigh
2+differmajoritymedium
1P0uniquehigh
1compensating-*uniquelow
1otheruniquemedium

The gate & the four verdicts

The gate passes when every unacknowledged finding is below the fix_threshold packages/mmr/src/core/reconciler.ts:229 (default P2 packages/mmr/src/config/defaults.ts:16). Severity tiers run P0 (highest) → P1 → P2 → P3 (lowest).

The verdict is derived from gate result + how many channels actually reported, in this branch order packages/mmr/src/core/reconciler.ts:280:

  1. zero channels completed → needs-user-decision
  2. else a failed gate → blocked
  3. else fewer than min_completed_channels completed → needs-user-decision
  4. else some channels incomplete → degraded-pass
  5. else → pass
VerdictConditionExit
passGate passed, every dispatched channel completed0
degraded-passGate passed, at least min_completed_channels reported, but some channel failed / timed out / wasn't installed0
blockedAn unacknowledged finding sits at or above the threshold2
needs-user-decisionNo channel completed, or fewer than min_completed_channels did3 (but see the exception below)

The completion floor (mmr 4.0.0). A verdict now reflects how many reviewers actually reported, not just whether the gate passed. defaults.min_completed_channels defaults to 2 packages/mmr/src/config/schema.ts:269 — so a run where only one channel came back is needs-user-decision, not degraded-pass, even with zero findings. One reviewer agreeing with itself is not consensus.

Note blocked deliberately outranks the floor: a real blocking finding is actionable even when coverage was thin. Jobs created before 4.0.0 are re-read at a floor of 1 so old results don't retroactively change verdict.

Two further needs-user-decision outcomes short-circuit before any channel is dispatched:

needs-user-decision does not always exit 3. The verdict-derived case exits 3 packages/mmr/src/core/results-pipeline.ts:304, but the ratification gate short-circuits before the results pipeline ever runs and sets exit 2 directly packages/mmr/src/commands/review.ts:522 — the same code blocked uses. So do not infer the verdict from the exit code alone: a 2 means either "blocked by findings" or "a human must ratify a config/ack change". Read the verdict field when you need to tell them apart.

Proceed only on pass or degraded-pass. On blocked or needs-user-decision, surface the verdict and findings — don't merge automatically.

Degraded mode, compensation & auth

A channel is "degraded" when it's not_installed (no binary), auth_failed, timeout, skipped, or failed. The review doesn't stop — it tells you how to recover and, for transient degradation, compensates.

ChannelAuth checkRecovery
codexcodex login statuscodex login
claudeclaude -p "respond with ok"claude login
grokgrok modelsgrok login
antigravity (agy)agy -p "respond with ok"agy -p "hello"

Configuration (.mmr.yaml)

Config is layered: built-in defaults → ~/.mmr/config.yaml → project .mmr.yaml → CLI flags. Arrays replace; objects deep-merge.

version: 1
defaults:
  fix_threshold: P2            # gate severity
  min_completed_channels: 2    # completion floor (below it → needs-user-decision)
  timeout: 300                 # default per-channel timeout (s)
  format: json                 # json | text | markdown
  parallel: true
  job_retention_days: 7        # used by `mmr jobs prune`
  loop_control:
    max_rounds_default: 5
  compensator:
    channel: claude            # who runs a compensating pass (default: claude -p)
review_criteria: ["…"]         # extra criteria added to every prompt (see "Calibrating findings")
templates:                     # named criteria presets, selected by --template
  security: { criteria: ["…"] }
channels_disabled: ["grok"]  # opt OUT of a built-in (e.g. no grok installed)
channels:
  doc-conformance:
    enabled: true            # opt IN to a default-off channel
  # Bring-your-own model via channel inheritance:
  qwen-local:
    command: ollama run
    flags: ["qwen2.5-coder:32b", "--format", "json"]
    output_parser: { kind: unwrap-jsonpath, wrap: "$.response", then: default }
    auth: { check: "ollama list", timeout: 5, failure_exit_codes: [1], recovery: "ollama serve" }

Calibrating findings with review_criteria

review_criteria lines are injected between the core prompt and the diff packages/mmr/src/core/prompt.ts:51, so every channel sees them. The most useful thing to put there is what not to spend a finding on.

The built-in criteria packages/mmr/templates/core-prompt.md:9 ask five questions that are all about what is missing. The severity definitions do gesture at likelihood — P1 is scoped to "normal usage" — but no level says what evidence of reachability a finding needs, or how to grade something reachable but rare. On an early-stage codebase that yields a long tail of correct-but-unreachable edge-case findings, and gives reviewers no way to recommend removing code. The block below adds the missing calibration; it does not replace the built-in severity semantics.

version: 1
review_criteria:
  - "Trust boundaries are exempt from every rule below. Any input crossing a public API, exported library surface, CLI argument, HTTP handler, webhook, deserializer, file or database read, or any other boundary an outside party controls, is reachable by definition — you do not need to find a caller for it. Never downgrade a security, data-loss, or data-corruption finding for lack of an in-repository caller."
  - "For internal code only: before reporting an unhandled input or state, name the caller, flag, config value, or documented contract that can produce it. If you cannot name one and it is not behind a trust boundary, do not report the finding."
  - "Grade severity by impact AND demonstrated likelihood. An internal state that is reachable but rare in current usage is P2 or P3, not P1. Trust-boundary findings are graded on impact alone and are never downgraded for rarity."
  - "Also report what is unnecessary, not only what is missing: an abstraction with a single caller, a config knob never varied, a hand-rolled helper the standard library already provides, defensive code for a state that cannot occur. Make the suggestion a deletion. Validation at a trust boundary is never unnecessary."
  - "Do not report missing tests for internal behavior that has no caller yet, or for a branch you could not show is reachable. Missing tests for trust-boundary handling are always in scope."

Keep the first line if you keep any of them. An unqualified "name a caller in this codebase" bar suppresses exactly the findings you least want to lose — a repository contains no caller for a malicious HTTP request or a corrupt database row, so the rule reads those as unreachable and downgrades them. The trust-boundary exemption is what keeps the reachability bar from becoming a security-finding filter.

This changes what reviewers report and how they grade it. fix_threshold, the reconciliation logic and mmr ack are untouched — but that is not the same as "no effect on merges". A finding that goes unreported, or that lands below your threshold, stops blocking. That is the intent when the finding was noise, and it is why the trust-boundary exemption above is not optional.

Treat the block as a starting template, not a tuned setting. Do not validate a change to it by comparing one before run against one after run: MMR's run-to-run variance on an identical input is larger than most effects you would be looking for. Two consecutive baseline runs of the same PR through the same three channels, with no config change at all, returned 4 findings and then 1. To get a real answer, hold the diff and channel set fixed, repeat each condition many times, and compare the rate of findings you have labelled low-value against a rubric written down in advance.

Criteria are trust-gated and fail silently. mmr review --diff … is untrusted-head, so your .mmr.yaml is never read and the criteria vanish with no warning and no non-zero exit. --pr and --base read the file from the base branch, so it must be committed there. Verify with mmr review --pr <n> --dry-run | sed -n '/## Project Review Criteria/,/^## /p', which prints the whole section — grep -A<n> would cut it off at n lines and still look like confirmation.

Trust boundary. When reviewing a diff, project .mmr.yaml and acks are read from the diff's base ref, not the working tree — otherwise a PR could add a channel that exfiltrates secrets, or self-acknowledge its own findings. Use --config-base-ref / the --trust-project-* flags to control this in untrusted (e.g. CI) contexts.

Trust modes and the ratification gate

MMR classifies every run into one of three trust modes packages/mmr/src/core/trust-mode.ts:97:

modewhenproject config / acks
base-ref--config-base-ref, --pr (base branch resolved via gh), --base, --staged outside CI, or no input flag at all outside CI (resolves to HEAD)read from the trusted ref
untrusted-head--diff, anything in CI, or a failed base-ref resolutionnot read unless a --trust-project-* flag says so
non-gitnot a Git repositorysame as above

On top of that, in base-ref mode, there is a ratification gate: if the diff under review itself adds or modifies .mmr.yaml, or touches .mmr/acks/, the run stops with needs-user-decision and exit 2 before any channel is dispatched — including before --dry-run packages/mmr/src/commands/review.ts:487. A human has to ratify the change:

what the diff changesflag that ratifies it
.mmr.yaml--trust-project-config
.mmr/acks/**--accept-new-acks (or --trust-project-acks)

That is the point: a PR cannot quietly reconfigure the reviewer that is reviewing it. Both flags print a warning to stderr when used.

The gate is keyed on having a trusted base ref (baseRef !== undefined), so it does not fire in untrusted-head or non-git mode. That is not a hole: in those modes the working-tree .mmr.yaml and project acks are not loaded at all unless you pass a --trust-project-* flag, so there is nothing for the diff to smuggle in. Base-ref mode is the only one that would otherwise honor them, which is why it is the one that stops and asks.