# Configuration reference

> Every setting in the global config (~/.rr/config.yaml) and the project config (.rr.yaml), with defaults and examples.

`rr` uses two configuration files: a **global config** for your personal host definitions and a **project config** for shareable project settings.

## Contents

- [Configuration overview](#configuration-overview)
- [Global config (~/.rr/config.yaml)](#global-config-rrconfigyaml)
- [Project config (.rr.yaml)](#project-config-rryaml)
- [Host resolution order](#host-resolution-order)
- [Sync](#sync)
- [Lock](#lock)
- [Tasks](#tasks)
- [Requirements](#requirements)
- [Output](#output)
- [Monitor](#monitor)
- [Duration syntax](#duration-syntax)
- [Validation rules](#validation-rules)
- [Minimal config](#minimal-config)

## Configuration overview

| Config | Location | Purpose | Share with team? |
|--------|----------|---------|------------------|
| Global | `~/.rr/config.yaml` | Host definitions and personal defaults | No |
| Project | `.rr.yaml` in project root | Sync rules, tasks, lock settings | Yes |

**Why the split?** Host configurations include personal SSH settings, directory paths, and machine-specific details that differ between team members. Keeping them in a global config means your `.rr.yaml` can be committed to version control without conflicts.

## Global config (~/.rr/config.yaml)

The global config stores your personal host definitions. Create it with `rr host add` or manually.

### Location

The global config is always at `~/.rr/config.yaml`. If the file doesn't exist, `rr` uses defaults.

### Complete global config example

```yaml
version: 1

hosts:
  mini:
    ssh:
      - mac-mini.local
      - mac-mini-tailscale
      - user@192.168.1.50
    dir: ${HOME}/projects/${PROJECT}
    tags:
      - fast
      - local
    env:
      GOPATH: /home/user/go
      DEBUG: "1"

  server:
    ssh:
      - dev-server
    dir: /var/projects/${PROJECT}
    tags:
      - linux

defaults:
  local_fallback: never
  probe_timeout: 2s

logs:
  dir: ~/.rr/logs
  keep_runs: 10
```

### Global config fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | `1` | Config schema version. Currently must be `1`. |
| `hosts` | map | `{}` | Remote host definitions (see below). |
| `defaults.local_fallback` | string | `never` | When to run locally: `never`, `on-unreachable` (hosts down or unconfigured), or `always` (also when every host is locked). A local run goes through the [local host](#local-host) if you have one. Booleans still work: `true` = `always`, `false` = `never`. |
| `defaults.probe_timeout` | duration | `2s` | How long to wait when testing SSH connectivity. |
| `defaults.rewrite_paths` | bool | `true` | Rewrite local absolute paths in commands and task args to their remote equivalents before running. |
| `logs.dir` | string | `~/.rr/logs` | Where run logs are written (single runs and parallel tasks). |
| `logs.keep_runs` | int | `10` | Keep this many recent runs per task. `0` disables count-based cleanup. |
| `logs.keep_days` | int | `0` | Delete logs older than this many days. `0` disables it. |
| `logs.max_size_mb` | int | `0` | Delete the oldest logs to stay under this total size. `0` disables it. |

Log cleanup runs after each run, in this order: `max_size_mb`, then `keep_days`, then `keep_runs`. `rr logs` lists log directories and `rr logs clean` applies the policy on demand (`--all` deletes everything, `--older 7d` deletes by age).

### Host fields

Each host entry configures a remote machine.

```yaml
hosts:
  mini:
    ssh:
      - mac-mini.local
      - mac-mini-tailscale
    dir: ${HOME}/projects/${PROJECT}
    tags:
      - fast
    env:
      DEBUG: "1"
    shell: "zsh -l -c"
    setup_commands:
      - export PATH=$HOME/.local/bin:$PATH
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ssh` | list | yes, unless `local` | SSH connection strings, tried in order. |
| `dir` | string | yes, unless `local` | Working directory on remote. Supports variable expansion. |
| `local` | bool | no | Make this host the machine `rr` runs on. Can't be combined with `ssh` or `dir`. See [Local host](#local-host). |
| `tags` | list | no | Tags for filtering with `--tag` flag. |
| `env` | map | no | Environment variables for commands on this host. See [How commands are built](#how-commands-are-built). |
| `shell` | string | no | Shell invocation format (e.g., `zsh -l -c`). Default uses `$SHELL -l -c`. |
| `setup_commands` | list | no | Commands to run before each command (e.g., `source ~/.nvm/nvm.sh`). |
| `require` | list | no | Tools that must exist on this host (verified before running commands). |

### SSH connection strings

Each entry in `ssh` can be:

- **Hostname**: `mac-mini.local`
- **User@host**: `deploy@server.example.com`
- **SSH config alias**: `dev-server` (from `~/.ssh/config`)
- **IP address**: `192.168.1.50`

`rr` tries each SSH alias in order until one connects. This is useful when a machine is reachable via multiple networks (e.g., local network vs. VPN).

**Passwordless SSH is required.** You must be able to run `ssh <alias>` without entering a password. See the [SSH setup guide](/docs/ssh-setup/) if you need to configure key-based auth.

### Local host

A host with `local: true` is the machine you run `rr` from, in rotation with your remote hosts instead of only as a fallback. Use it when your own machine is as fast as the remotes.

```yaml
hosts:
  dev:
    local: true
    tags: [fast]
    setup_commands:
      - export PATH=$HOME/.local/bin:$PATH
  m4-mini:
    ssh: [m4-mini.local, m4-mini-tailscale]
    dir: ~/rr/${PROJECT}
```

It's a host like any other:

- **Selection**: it's tried in host order (the project's `hosts:` list, or alphabetical), and `--host dev` and `--tag fast` pick it.
- **Locking**: it takes the same lock as a remote host, at `<lock.dir>/rr.lock` on this machine, so two `rr` runs don't both land on it. `rr unlock dev` releases it. When it's the only candidate (the project's only host, or picked with `--host` or `--tag`) and it's busy, `rr` waits up to `lock.timeout` (default 5m), as for any single host. When it's one of several hosts and they're all busy, `rr` cycles through them for up to `lock.wait_timeout` (default 1m), then fails. Projects only queue behind each other here when they use the same `lock.dir`, so leave it at the default (or set the same value) in every project that runs on this machine.
- **A killed `rr` doesn't free it early**: the lock records the process group of the job it's running (`job_pgid` in `info.json`). If `rr` is SIGKILLed, the job keeps running in your checkout, and the lock stays held, and never goes stale, until that job exits. After that, the dead `rr`'s lock is cleared at once, as on any host.
- **Parallel tasks**: it gets one worker, like each remote host, and takes subtasks from the shared queue.
- **Commands**: they're built as for a remote host: `setup_commands` and `shell` for every command, plus the host's `env` for tasks (`rr run` doesn't apply host `env` on any host), and the `require:` checks. Like an SSH session, a command starts in your home directory, then `cd`s into the project.
- **`rr status`, `rr doctor`, `rr monitor`**: it shows as reachable without an SSH probe, doctor's `--path`/`--requirements` checks run on this machine, and the monitor reads its metrics and lock locally.

What's different:

- **It runs in place**, in the local project directory (the directory holding `.rr.yaml`, or the current directory without one), with no rsync. So `ssh` and `dir` aren't allowed, and local paths in commands aren't rewritten. A run sees your working tree as it is, including edits you make while it runs, and anything it writes (build output, coverage files) lands in your checkout. Subtasks of a parallel task that land on it share that directory, the same as subtasks on one remote host share its `dir`.
- **It keeps your environment.** A remote command sources `~/.bashrc` and `~/.zshrc` first, because SSH sessions don't. A local host's command skips that and inherits `rr`'s environment, so an activated virtualenv or `nvm use` stays in effect.
- **Its shell is `$SHELL`**, the way a remote runs your login shell, unless `$SHELL` isn't a POSIX shell (fish, nu): then it's `/bin/bash`. Set `shell` to pick another.
- **It has no terminal**, like an SSH command without a pty: a command that reads `/dev/tty` (a password prompt) fails at once instead of hanging. Ctrl+C stops the command and anything it started in the background before the lock is released; a second Ctrl+C kills them and quits.
- **Sync and pull.** `rr sync` and `rr pull` never pick it: with no `--host` they use the first remote host, and `--host dev` is a config error, since there's nothing to sync to or pull from. A run's `pull:` or `--pull` copies the files from the project directory to the destination with a local rsync. When the destination is the project directory itself, nothing is copied and the pull phase is reported as skipped (`reason: same_dir`). Parallel subtasks get their own `<dest>/<name>_<index>/` copy, as on a remote host. `rr prune` reports it as skipped.

Only one host can be `local`, and it can't be named `local`: that name is what structured output reports for a bare local run (below). Local hosts aren't supported on Windows, since commands run through `/bin/sh`; run `rr` from WSL instead.

Add one with `rr host add --local --name dev` (`--tag` and `--env` work as for a remote host; it can't be combined with `--ssh` or `--dir`). The interactive `rr host add` and `rr init` also offer "This machine" when no host is local yet.

**Adding one to an existing setup:** a project without a `hosts:` list uses every global host, in alphabetical order. Adding a local host therefore puts this machine into the rotation of every such project, and first if its name sorts first. Give those projects a `hosts:` list, or pick a name that sorts after your remotes, if you don't want that.

How it relates to the other ways of running locally:

- `--local`, local mode (a project with `local_fallback` on and no `host`/`hosts`), and a `local_fallback` run go through the local host, as if `--host` had named it. They take its lock (waiting up to `lock.timeout`, and stopping with `INTERRUPTED` if you press Ctrl+C), get its `setup_commands`, `shell` and `require` checks plus the project's `defaults.setup` (tasks also get its `env`), and run in their own session with no terminal. A task runs at the project root; `rr run` keeps the directory you ran it from. A parallel task run with `--local` uses the local host as its one worker, and waits for its lock the same way: one `lock` `waiting` event in structured output, and `INTERRUPTED` on Ctrl+C before any subtask has started. Structured output names the host (`dev`), skips sync with `reason: in_place`, and `details.local_reason` or `details.fallback` still says why the run is here.
- Without a local host, those runs execute here directly: no lock, none of a host's settings, the command attached to your terminal, reported as host `local` with sync skipped as `local`.
- `local_fallback` still applies when no host can be used, but with a local host in the pool that rarely happens, since it's always reachable. When every host is locked and the local host is busy (in this run's pool or not, for example left out by `hosts:` or `--tag`), `rr` waits for a host (up to `lock.wait_timeout`) and then fails, even with `local_fallback: always`.

### Variable expansion

The `dir` field supports these variables:

| Variable | Expands to | Example |
|----------|------------|---------|
| `${PROJECT}` | Git repo name (from the `origin` URL, else the repo root's directory name), or the current directory name outside a repo | `myapp` |
| `${USER}` | Local username | `riley` |
| `${HOME}` | Remote user's home directory (sent as `~` for the remote shell to expand) | `/home/riley` |

```yaml
# If your local project is /Users/riley/code/myapp
dir: ~/projects/${PROJECT}
# Expands to: ~/projects/myapp
```

**Git worktrees:** in a linked worktree, `${PROJECT}` expands to
`<repo>@<worktree-name>` (e.g. `myapp@myapp-featurex`), so each worktree
syncs to its own remote directory instead of clobbering the main checkout's
mirror. The main checkout keeps its plain name. Expect a cold first sync per
worktree, and remember `preserve`d directories (like `.venv`) start empty in
the new remote dir. Disable per project with `sync.worktree_isolation: false`.

Removing a worktree locally leaves its remote directory behind, so a machine
that churns through worktrees fills the remote disk. Every sync removes the
remote directories of worktrees that no longer exist locally (disable with
`sync.prune_worktrees: false`), and `rr prune` cleans hosts that have not been
synced to since a worktree was removed:

```bash
rr prune                 # every project host
rr prune --dry-run       # list what would be removed
rr prune --host m4-mini  # one host
```

Prune only ever removes `<repo>@<worktree>` directories beside the project's
remote dir, never the main checkout's, and it skips directories whose sync
marker names a different machine.

## Project config (.rr.yaml)

The project config lives in your project root and contains settings that can be shared with your team.

### Location

`rr` searches for project configuration in this order:

1. Explicit path via `--config` flag
2. `.rr.yaml` in the current directory
3. `.rr.yaml` in parent directories, stopping at the git top level (the directory holding `.git`, which is a file in a linked worktree or submodule) or, outside git, at your home directory

The search never leaves the checkout you're in. A worktree created inside the main checkout (say under `.claude/worktrees/`) without its own `.rr.yaml` doesn't pick up the main checkout's: that would make the main checkout the project root, so a local host would run its code and a remote host would sync it. rr fails with `CONFIG_NOT_FOUND` instead, naming the `.rr.yaml` it skipped and how to copy it in. Commit `.rr.yaml` so every branch and worktree has it. Commands that only read hosts (`rr monitor`, `rr status`, `rr host list`, `rr unlock`) don't fail; they use your global hosts.

That refusal only applies when the `.rr.yaml` above sits inside an enclosing git checkout. One in a plain directory that holds your repos, like `~/code/.rr.yaml` over the repo `~/code/foo`, isn't any checkout's config: rr skips it, and a repo without its own `.rr.yaml` uses your global hosts.

### Complete project config example

```yaml
version: 1

# Reference hosts from global config
# If omitted, all global hosts are available for load balancing
hosts:
  - mini
  - server

# Or use a single host
# host: mini

sync:
  respect_gitignore: true
  exclude:
    - .git # bare pattern: .git is a file in linked worktrees
    - .venv
    - __pycache__/
    - "*.pyc"
    - node_modules
    - .mypy_cache/
    - .pytest_cache/
    - .DS_Store
  preserve:
    - .venv/
    - node_modules/
    - data/
  flags:
    - --compress
  invalidations:
    - lockfile: package-lock.json
      dirs: [node_modules/]

lock:
  enabled: true
  timeout: 5m
  wait_timeout: 1m
  stale: 90s
  dir: /tmp/rr-locks

tasks:
  test:
    description: Run all tests
    run: make test

  deploy:
    description: Build and deploy to staging
    hosts:
      - server
    steps:
      - name: Build
        run: make build
      - name: Deploy
        run: ./scripts/deploy.sh
        on_fail: stop

monitor:
  interval: 2s
  thresholds:
    cpu:
      warning: 70
      critical: 90
    ram:
      warning: 70
      critical: 90
    gpu:
      warning: 70
      critical: 90
  exclude: []
```

### Project config fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `version` | int | `1` | Config schema version. Currently must be `1`. |
| `host` | string | - | Single host reference (from global config). |
| `hosts` | list | all global hosts | List of host references for load balancing. |
| `local_fallback` | string | global setting | Overrides `defaults.local_fallback`: `never`, `on-unreachable`, or `always`. |
| `rewrite_paths` | bool | global setting | Overrides `defaults.rewrite_paths`. |
| `defaults` | object | - | Project defaults applied to every task (see below). |
| `require` | list | `[]` | Tools that must exist on remote hosts. |
| `sync` | object | see below | File synchronization settings. |
| `lock` | object | see below | Distributed lock settings. |
| `tasks` | map | `{}` | Named command sequences. |
| `monitor` | object | see below | Resource monitoring dashboard settings. |

**Note:** Use either `host` (singular) or `hosts` (plural), not both. If neither is specified, all hosts from your global config are available for load balancing.

### Project defaults

Settings under `defaults` apply to everything the project runs:

```yaml
defaults:
  setup:
    - source ~/rr-env.sh
  env:
    CI: "1"
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `defaults.setup` | list | `[]` | Commands prepended (with `&&`) before every command, after host `setup_commands`. Applies to tasks and parallel subtasks, locally and on remote hosts, and to ad-hoc `rr run` / `rr exec` on remote hosts only. |
| `defaults.env` | map | `{}` | Environment variables for all tasks, including parallel subtasks. Overrides host env; overridden by task env. See [How commands are built](#how-commands-are-built). |

### How commands are built

Every task command, single or parallel, local or remote, runs as one shell command chained with `&&`:

1. `cd` into the project directory (remote runs only).
2. Each setup command: host `setup_commands`, then `defaults.setup`.
3. One `export KEY="value"` per env key, in sorted key order. Host `env`, then `defaults.env`, then the task's `env`; a later layer replaces an earlier key.
4. The task command.

If any step fails, nothing after it runs and the task exits non-zero. `||`, `;`, and `&` inside a setup command or the task command only affect that command. `rr run` groups its command and setup commands the same way, so a failed `--cwd`, setup command, or cd stops the whole command. Env is exported after setup, so setup commands can't read env values, but a value can use variables that setup exported and keys that sort before it.

Env values are double-quoted, so the shell expands some things and leaves the rest alone:

| In a value | Result |
|------------|--------|
| `$VAR`, `${VAR}`, `${VAR:-default}`, `$(cmd)`, `$((1+1))` | Expanded by the shell that runs the task |
| `\$` | A literal `$`: `pa\$\$word` becomes `pa$$word` |
| `~` | Literal. Use `$HOME` instead: `PATH: "$HOME/.local/bin:$PATH"` |
| Double quotes, backticks, single quotes, spaces, `;`, `\|`, `&`, globs, newlines, other backslashes (outside `$(...)`) | Literal |
| Anything inside `$(...)` | Runs exactly as written, as its own command: quotes are real quotes and backslashes pass through, so `$(echo "$PATH" \| tr ':' '\n')` behaves as typed |

One edge case follows from the quoting: a literal backslash can't sit directly before an expanded variable, because `\$` always means a literal `$`.

Names must be valid shell variable names: letters, digits, and underscores, not starting with a digit. A value with an unclosed `${` or `$(`, or an unclosed quote inside `$(...)`, is rejected when the config loads, with an error naming the key. Commands run in the remote user's login shell (or `$SHELL` locally), which must be POSIX-compatible; fish is not supported (a `local: true` host uses `/bin/bash` in place of a non-POSIX `$SHELL`).

## Host resolution order

When you run a command, `rr` determines which host(s) to use in this order:

1. `--host` flag (explicit CLI argument)
2. `.rr.yaml` `hosts:` field (project's preferred hosts for load balancing)
3. `.rr.yaml` `host:` field (project's single preferred host)
4. All hosts from global config, alphabetically (default for load balancing)

Exception (local mode): if `.rr.yaml` sets `local_fallback` to `on-unreachable` or `always` and names no `host`/`hosts`, or `local_fallback` is on and no hosts are configured at all, commands run locally without trying any remote host (on the [local host](#local-host) when the global config has one). `--host` or `--tag` overrides local mode. Local-mode runs report `details.reason: "local_mode"` on their connect event (and `details.local_reason` on the result), and `--local` runs report `local_flag`; neither needs any hosts configured.

**Important:** The order of hosts in your `hosts:` list determines priority. The first host is tried first. If it's busy or unreachable, `rr` moves to the next host in the list. This gives you explicit control over which machines are preferred.

```yaml
# .rr.yaml
hosts:
  - gpu-box      # Tried first (highest priority)
  - mini-server  # Tried second
  - backup-host  # Tried last (lowest priority)
```

## Working directory

`rr run` and `rr exec` preserve the subdirectory you invoked them from, so relative paths keep the meaning they have locally:

```bash
cd backend
rr run "pytest tests/test_api.py"   # runs in <remote dir>/backend
```

The offset is reported as `remote_cwd` in the result envelope. It applies to local execution too, so a command means the same thing whether or not a remote host answered. If the directory wasn't synced (it matches a `sync.exclude` pattern, for instance), the command falls back to the project root rather than failing.

`--cwd` overrides the offset:

```bash
rr run --cwd backend "pytest tests/"   # explicit, ignores where you are
```

**Named tasks do not get an offset.** `rr test` means the same thing from every directory, since task commands are project-scoped and often embed their own `cd` (see [Tasks](#tasks)).

This changed in the release noted in the CHANGELOG: ad-hoc commands used to run at the project root no matter where you were. If you relied on that, `--cwd .` restores it.

When a relative path fails because it resolved differently than you expected, `rr` says so in the result `hint`, naming the directory it ran in and the fix. The hint needs the failing path to appear in stderr, so tools that report a missing file without naming it (`make` says only "no makefile found") fail without one.

## Exit codes and pipes

`rr` reports the exit code the remote command returned, unchanged. If your command pipes its output, the shell reports only the **last** stage's status, so a failing test runner can still exit 0:

```bash
rr run "pytest tests/ | tail -20"    # exit code comes from tail, not pytest
```

`rr` won't rewrite your command's semantics, since `cmd | grep -q pattern` tolerates upstream failure on purpose. Two things help:

- When a piped run collects zero tests, `rr` warns and sets `details.piped_exit_code` so the misleading exit code is visible.
- To propagate the failure, enable `pipefail` through the host's `shell` field:

  ```yaml
  hosts:
    mini:
      shell: "bash -o pipefail -c"
  ```

  Note `bash`, not `sh`: `dash` (Debian/Ubuntu's `/bin/sh`) doesn't support `pipefail`.

The same trap applies one level up, where no host config can reach it. Piping `rr` itself discards `rr`'s exit code:

```bash
rr test | tail -8          # exit code comes from tail; a failed suite reports 0
set -o pipefail            # in your shell or script, before the pipe
rr test | tail -8          # exit code comes from rr
```

Use `--tail N` instead of an external pager when you only want the end of the output; it prints after the result envelope and keeps the exit code intact:

```bash
rr test --tail 20
```

## Sync

Controls file synchronization behavior using rsync.

```yaml
sync:
  exclude:
    - .git
    - node_modules
    - "*.pyc"
  preserve:
    - node_modules/
    - .venv/
  flags:
    - --compress
```

### Sync fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `exclude` | list | see below | Patterns for files not sent to remote. |
| `preserve` | list | see below | Patterns for files not deleted on remote. |
| `respect_gitignore` | bool | `true` | Apply the repository-root `.gitignore` as extra exclude rules. Nested `.gitignore` files are not read. Explicit `exclude`/`preserve` patterns take precedence. |
| `flags` | list | `[]` | Extra flags passed to rsync. |
| `invalidations` | list | see below | Lockfiles that, when changed, delete remote install directories so they get reinstalled. |
| `worktree_isolation` | bool | `true` | Give each linked git worktree its own remote directory (`${PROJECT}` becomes `repo@worktree`). |
| `prune_worktrees` | bool | `true` | After a sync, remove remote `repo@worktree` directories whose worktree no longer exists locally. |

### Default excludes

If you don't specify `exclude`, these patterns are used:

```yaml
exclude:
  - .git # bare patterns for .git/.venv/node_modules: in linked
  - .venv # worktrees .git is a file, and node_modules may be a
  - __pycache__/ # symlink - dir-only patterns miss those (rsync exit 23)
  - "*.pyc"
  - node_modules
  - .mypy_cache/
  - .pytest_cache/
  - .ruff_cache/
  - .DS_Store
  - "*.log"
  - .claude/
  - .cursor/
  - .aider/
  - .copilot/
```

**Your `exclude` list replaces the defaults** - it isn't merged. If you
maintain your own list, prefer the bare `.git` / `.venv` / `node_modules`
patterns so linked worktrees sync cleanly. Note a bare pattern also matches
files with that name at any depth (e.g. a vendored fixture named `.git`).

### Default preserves

If you don't specify `preserve`, these patterns are used:

```yaml
preserve:
  - .venv/
  - node_modules/
  - data/
  - .cache/
```

**Note:** Preserved files are not deleted on the remote even if they don't exist locally. This is useful for dependencies that should be installed once on the remote.

### Lockfile invalidations

Preserved install directories go stale when a lockfile changes locally: rsync syncs the new lockfile, but the old `node_modules/` stays put and the package manager may not notice. Before each sync, `rr` compares each local lockfile's mtime with the remote directory's mtime and deletes the remote directory when the lockfile is newer. A remote directory that doesn't exist is left alone, since there's nothing to invalidate. This runs inside every sync, including `rr sync` and parallel runs. Your next install command then starts clean. `rr` only deletes; it doesn't run the install for you, so pair this with a task `setup` or an install task.

If you don't specify `invalidations`, these are used:

```yaml
invalidations:
  - lockfile: bun.lock
    dirs: [node_modules/]
  - lockfile: package-lock.json
    dirs: [node_modules/]
  - lockfile: yarn.lock
    dirs: [node_modules/]
  - lockfile: pnpm-lock.yaml
    dirs: [node_modules/]
  - lockfile: poetry.lock
    dirs: [.venv/]
  - lockfile: Pipfile.lock
    dirs: [.venv/]
```

`lockfile` is relative to the project root, and `dirs` are relative to the remote project directory. A dir that doesn't exist on the remote is skipped. If it exists but rr can't read its age (both GNU and BSD `stat` fail, or the command can't run), the sync fails and nothing is deleted. Like `exclude`, your list replaces the defaults. Set `invalidations: []` to turn the behavior off.

### Pattern syntax

Patterns use rsync filter syntax:

- `*.pyc` - Match files ending in `.pyc`
- `node_modules/` - Match directory named `node_modules`
- `/build/` - Match `build/` at the root only
- `**/*.log` - Match `.log` files in any subdirectory

## Lock

Distributed locking prevents multiple `rr` instances from running on the same host simultaneously.

```yaml
lock:
  enabled: true
  timeout: 5m
  stale: 90s
  dir: /tmp/rr-locks
```

### Lock fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Whether to use distributed locking. |
| `timeout` | duration | `5m` | How long to wait for a lock on a single host. |
| `wait_timeout` | duration | `1m` | How long to round-robin when all hosts are locked. |
| `stale` | duration | `90s` | When to consider a lock abandoned, measured from the holder's last heartbeat. |
| `dir` | string | `/tmp/rr-locks` | Directory for lock files on remote. |

### How locking works

1. Before running a command, `rr` atomically creates a `rr.lock/` directory inside the lock `dir` on the remote, with an `info.json` describing the holder. There is one lock per host and lock `dir`: projects share a lock only when they use the same host and the same `lock.dir`.
2. While it holds the lock, `rr` touches `info.json` every 30s as a heartbeat
3. If another instance holds the lock, `rr` waits up to `timeout`, retrying every 2 seconds. It reports the holder once when the wait starts (a `lock` `waiting` event, or the spinner text with `--pretty`). Ctrl+C or SIGTERM stops the wait: nothing runs, and `rr` exits 130 with `INTERRUPTED`
4. If the holder's heartbeat is older than `stale`, the lock is considered abandoned and taken over with a warning
5. If the holder is an `rr` process on this machine that is no longer running, the lock is taken immediately without waiting for `stale`. On a [local host](#local-host), the lock also records the job's process group, and it stays held (and never goes stale) while that job is still running
6. The lock is released when the command finishes

Waiters don't queue: each one retries on its own, so when the lock frees, whichever retries first gets it. Under steady contention a waiter can lose every round until `timeout`.

`rr unlock [host]` (or `rr unlock --all`) force-releases a stuck lock.

### Load balancing with multiple hosts

When multiple hosts are configured, `rr` distributes work automatically:

1. Tries each host with a non-blocking lock check
2. If a host is locked, immediately tries the next host
3. Locks held by dead processes on this machine are reclaimed automatically
4. If all hosts are locked, what happens depends on `local_fallback`:
   - `always`: runs locally right away with a loud warning (and `details.fallback` in structured output). If any lock holder is on this same machine (likely your own other run), it first waits up to `wait_timeout` for a host to free up.
   - `never` / `on-unreachable`: waits up to `wait_timeout`, cycling through the hosts, then fails with the lock holders listed. The wait emits a `connect` `waiting` event naming the holders, and Ctrl+C stops it with `INTERRUPTED` (exit 130) without falling back
   - If this machine's [local host](#local-host) is locked, whether or not it's one of the hosts tried, `rr` waits and fails as with `never`, whatever `local_fallback` says
   - A [host-restricted task](#host-restricted-tasks) whose `hosts:` list leaves out this machine also waits and fails as with `never`

```yaml
lock:
  enabled: true
  timeout: 5m        # Per-host lock wait time
  wait_timeout: 2m   # Total time to round-robin when all hosts locked
  stale: 90s
```

Disable locking if you're the only user of a remote host:

```yaml
lock:
  enabled: false
```

## Tasks

Named tasks let you define reusable command sequences.

### Simple task (single command)

```yaml
tasks:
  test:
    description: Run all tests
    run: pytest tests/ -v
```

Run with: `rr test`

### Passing extra arguments

Extra CLI args flow into single-command tasks. Use an `{args}` placeholder
to control where they land - essential for pipelines, where appended args
would bind to the last command instead of the one you mean:

```yaml
tasks:
  test:
    run: pytest {args:-tests/} -n 4 | tail -20
```

- `rr test -- tests/foo.py -k bond` runs `pytest 'tests/foo.py' '-k' 'bond' -n 4 | tail -20`
- `rr test` with no args uses the `{args:-default}` default: `pytest tests/ -n 4 | tail -20`
- Args are shell-quoted before substitution
- Without a placeholder, args are appended (quoted) to simple commands;
  compound commands (pipes, `&&`, redirections, `$()`) reject extra args
  and tell you where to add `{args}`
- Write `{{args}}` for a literal `{args}` in the command
- Multi-step (`steps`) tasks don't accept extra args
- Put flag-style args after `--` (`rr test -- -k bond -x`). Without it, `rr` parses them as its own flags and fails with `CONFIG_INVALID` and a hint to use `--`. That includes `-v`, which is no longer an `rr` flag. `-q`, `-p`, and `-m` are still `rr` flags, so they need `--` too.

Parallel tasks reject extra args unless they set `forward_args: true` (see [Forwarding args to subtasks](#forwarding-args-to-subtasks)).

### Multi-step task

```yaml
tasks:
  deploy:
    description: Build and deploy
    steps:
      - name: Build
        run: make build
      - name: Deploy
        run: ./scripts/deploy.sh
        on_fail: stop
```

### Task fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | no | Shown in `rr --help` and `rr tasks`. |
| `run` | string | if no steps/parallel/depends | Command to execute (simple tasks). |
| `steps` | list | if no run/parallel | Steps for multi-step tasks. |
| `parallel` | list | if no run/steps | Subtask names to run concurrently across hosts. |
| `setup` | string | no | Command to run once per host before parallel subtasks. Parallel tasks only: on any other task it never runs, and rr warns. |
| `depends` | list | no | Task dependencies to run before this task. |
| `hosts` | list | no | Restrict this task to specific hosts. |
| `env` | map | no | Environment variables for this task. See [How commands are built](#how-commands-are-built). |
| `require` | list | no | Tools that must exist for this task. |
| `pull` | list | no | Files to download from the remote after the task runs (see [Pulling files back](#pulling-files-back)). On a parallel task itself it has no effect and warns; set it on the subtasks. |
| `fail_fast` | bool | no | Stop all tasks on first failure (parallel/depends tasks). |
| `max_parallel` | int | no | Limit concurrent tasks (parallel tasks only). |
| `timeout` | duration | no | Per-subtask timeout (parallel tasks) or total timeout (depends tasks). Ignored for plain `run`/`steps` tasks. |
| `output` | string | no | Default output mode for parallel tasks: `progress` (default), `stream`, `verbose`, or `quiet`. CLI flags override it. Validated on every task; on a non-parallel task it has no effect and warns. |
| `forward_args` | bool | no | Parallel tasks only: forward extra CLI args to each subtask. |

**CLI flags for non-parallel tasks:** `--host`, `--tag`, `--local`, `--probe-timeout`, `--repeat N` (run the task N times in parallel across hosts, for flake detection), and `--tail N` (print the last N lines of the run log after the result). Tasks with `depends` also get `--skip-deps` and `--from`.

### Parallel task

Run multiple subtasks simultaneously across your host pool:

```yaml
tasks:
  test-backend:
    run: cd backend && pytest
  test-frontend:
    run: cd frontend && npm test
  test-opendata:
    run: cd opendata && python -m pytest

  # Runs all three subtasks in parallel across hosts
  test:
    description: Run all test suites
    parallel: [test-backend, test-frontend, test-opendata]
    fail_fast: false    # Continue even if one fails
    max_parallel: 3     # Limit concurrency (optional)
    timeout: 10m        # Per-subtask timeout (optional)
```

Run with: `rr test`

**How it works (work-stealing queue):**

1. All subtasks are placed in a shared queue
2. One worker per host pulls tasks from the queue (a subtask with `hosts:` only goes to a worker for one of its allowed hosts)
3. Files are synced and locks acquired once per host (not per task)
4. After first tasks complete, rr tracks host performance
5. Slow hosts wait before grabbing additional tasks, giving fast hosts priority
6. Output is captured and shown in a summary when complete
7. Logs are saved to `<logs.dir>/<task>-<timestamp>/` (default `~/.rr/logs`)

This performance-based work-stealing ensures efficient distribution across heterogeneous hosts. If you have 6 tasks across 3 hosts where one host is slower, the fast hosts grab more tasks (e.g., 3-2-1 distribution) instead of round-robin (2-2-2).

#### Setup phase (once per host)

When subtasks need shared setup (dependency installation, database migrations, etc.), use `setup` to avoid redundant work:

```yaml
tasks:
  test-all:
    setup: pip install -r requirements.txt   # Runs once per host
    parallel:
      - test-unit
      - test-integration
      - test-e2e

  test-unit:
    run: pytest tests/unit -v
  test-integration:
    run: pytest tests/integration -v
  test-e2e:
    run: pytest tests/e2e -v
```

Setup behavior:
- Runs exactly once per host, after file sync but before any subtasks
- If a host runs 3 subtasks, setup runs once (not 3 times)
- Setup failure aborts all subtasks on that host
- Works with both remote and local execution

#### Subtask environment and working directory

Each subtask runs with the same environment a single task gets: host `env`, then `defaults.env`, then the subtask's own `env`, with later entries winning. Host `setup_commands` and `defaults.setup` run before the subtask's command, after the `cd` into the project directory.

Subtasks that land on the same host run in the same remote directory. If two of them write the same file (a junit report, a coverage file), the later one overwrites the earlier one, so give each subtask its own output path.

#### Forwarding args to subtasks

By default a parallel task rejects extra CLI args: `rr test-backend -k bond` fails with `CONFIG_INVALID` and a hint to set `forward_args: true` and put task flags after `--`. Set `forward_args: true` to pass them to every subtask:

```yaml
tasks:
  test-api:
    run: pytest {args:-tests/api} -q
  test-models:
    run: pytest {args:-tests/models} -q

  test-backend:
    parallel: [test-api, test-models]
    forward_args: true
```

`rr test-backend -- -k bond` runs both subtasks with `-k bond`. Each subtask follows the same rules as a single task: args replace its `{args}` placeholder, or are appended (quoted) to a simple command, and a compound command without a placeholder is an error. Subtasks that use `steps` can't receive forwarded args. `{args:-default}` defaults apply even without `forward_args`. Absolute local paths in forwarded args are rewritten to project-relative paths (unless `rewrite_paths` is off).

A forwarded test filter can leave some subtasks with nothing to run, which pytest reports as a failure (exit 5).

#### Nested parallel tasks

Parallel tasks can reference other parallel tasks. When `rr` encounters a nested reference, it flattens the task tree before execution:

```yaml
tasks:
  # Split test suites for parallelization
  opendata-1:
    run: pytest opendata --test-group-count 3 --test-group 1
  opendata-2:
    run: pytest opendata --test-group-count 3 --test-group 2
  opendata-3:
    run: pytest opendata --test-group-count 3 --test-group 3

  backend-1:
    run: pytest backend --test-group-count 3 --test-group 1
  backend-2:
    run: pytest backend --test-group-count 3 --test-group 2
  backend-3:
    run: pytest backend --test-group-count 3 --test-group 3

  frontend:
    run: npm test

  # Group related subtasks
  test-opendata:
    parallel: [opendata-1, opendata-2, opendata-3]

  test-backend:
    parallel: [backend-1, backend-2, backend-3]

  # Reference parallel tasks - automatically flattened
  test:
    parallel: [test-opendata, test-backend, frontend]
```

Running `rr test` expands to 7 parallel tasks: `opendata-1`, `opendata-2`, `opendata-3`, `backend-1`, `backend-2`, `backend-3`, `frontend`.

Benefits:
- Run `rr test-opendata` to run just the 3 opendata splits
- Run `rr test` to run everything
- Add a 4th split to `test-opendata` and `test` automatically includes it
- No manual flattening or repetition required
- Diamond dependencies are deduplicated (if the same task is reachable through multiple paths, it runs once)

Use `--dry-run` to see the expanded task list:

```bash
rr test --dry-run
# Shows: test-opendata -> [opendata-1, opendata-2, opendata-3]
#        test-backend -> [backend-1, backend-2, backend-3]
#        Tasks to execute (7 total): ...
```

Circular references are detected during config validation.

**CLI flags for parallel tasks:**

| Flag | Description |
|------|-------------|
| `--stream` | Real-time interleaved output with `[host:task]` prefixes |
| `--verbose` | Full output shown when each task completes |
| `--quiet` | Summary only, no per-task output |
| `--fail-fast` | Stop all tasks on first failure (overrides config) |
| `--max-parallel N` | Limit concurrent tasks (overrides config) |
| `--dry-run` | Show execution plan without running |
| `--local` | Force local execution (ignore remote hosts) |
| `--no-logs` | Don't save output to log files |
| `--host` / `--tag` | Limit the host pool |

### Step fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | no | Identifier shown in output. |
| `run` | string | yes | Command to execute. |
| `on_fail` | string | no | Behavior on failure: `stop` (default) or `continue`. |

### Task dependencies

Define task execution order with the `depends` field. Tasks run their dependencies first, then execute their own command.

```yaml
tasks:
  lint:
    run: golangci-lint run
  test:
    run: go test ./...
  build:
    run: go build ./...

  # Linear dependency chain
  ci:
    description: Run full CI pipeline
    depends:
      - lint
      - test
      - build
```

Run with: `rr ci`

This executes: `lint` -> `test` -> `build` in order.

#### Parallel groups in dependencies

Run multiple dependencies simultaneously using the `parallel` syntax:

```yaml
tasks:
  lint:
    run: golangci-lint run
  typecheck:
    run: mypy .
  test:
    run: pytest
  deploy:
    run: ./deploy.sh

  ci:
    description: Run CI pipeline
    depends:
      - parallel: [lint, typecheck]  # Run simultaneously
      - test                          # Run after parallel group completes
      - deploy
```

This executes: `[lint, typecheck]` (parallel) -> `test` -> `deploy`

#### Orchestrator tasks (no command)

Tasks with only `depends` orchestrate their dependencies without running their own command:

```yaml
tasks:
  lint:
    run: golangci-lint run
  test:
    run: go test ./...

  # Just orchestrates - no 'run' command needed
  verify:
    description: Run all verification tasks
    depends:
      - lint
      - test
```

#### Dependency deduplication

Tasks are deduplicated automatically. In a diamond dependency pattern, shared dependencies run once:

```yaml
tasks:
  base:
    run: echo "base"
  left:
    depends: [base]
    run: echo "left"
  right:
    depends: [base]
    run: echo "right"
  top:
    depends: [left, right]
```

Running `rr top` executes: `base` -> `left` -> `right` (base only runs once)

#### Dependency CLI flags

| Flag | Description |
|------|-------------|
| `--skip-deps` | Skip dependencies, run only the target task |
| `--from <task>` | Start from a specific task in the chain |

```bash
rr ci                  # Run full dependency chain
rr ci --skip-deps      # Run only ci task (skip lint, test, build)
rr ci --from test      # Start from test, skip lint
```

#### Dependency validation

Dependencies are validated at config load time:

| Validation | Error |
|------------|-------|
| Missing reference | `task 'X' depends on non-existent task 'Y'` |
| Self-reference | `task 'X' can't depend on itself` |
| Circular dependency | `circular dependency detected: A -> B -> C -> A` |

#### Combining dependencies with other task features

Dependencies work with other task fields:

```yaml
tasks:
  setup:
    run: ./setup.sh

  deploy:
    description: Deploy with prerequisites
    depends: [setup]
    run: ./deploy.sh
    hosts:
      - production
    env:
      DEPLOY_ENV: staging
    timeout: 10m
    fail_fast: true
```

### Pulling files back

`pull` downloads files from the remote after the task finishes, whether it passed or failed (test reports are often most useful on failure):

```yaml
tasks:
  coverage:
    run: pytest --cov --cov-report=xml --cov-report=html
    pull:
      - coverage.xml            # to the current directory
      - src: htmlcov/
        dest: ./reports/        # to a specific local directory
```

Sources are paths or globs relative to the host's `dir`. `dest` defaults to the current directory and is created if missing. A failed pull is reported but doesn't change the task's exit code. After a run that ran on this machine (a [local host](#local-host), `--local`, or a fallback), the files are copied from the project directory instead, and the pull is reported skipped when `dest` is the project directory. For ad-hoc commands, use `rr run --pull <pattern> [--pull-dest <dir>]`, or `rr pull <pattern>` on its own.

**Subtasks of a parallel task** pull too, with three differences:

- Pulls run after every subtask has finished, pass or fail, one at a time, each from the host its subtask ran on.
- Each subtask's files land in `<dest>/<name>_<index>/` (`./<name>_<index>/` when `dest` is unset), the same stem as the subtask's log file: the name with `/ \ : * ? " < > |` replaced by `-`, then the subtask's position in the flattened `parallel:` list, starting at 0. `test:unit` listed first lands in `test-unit_0/`. The index makes every directory unique, so shards with the same output paths, or a subtask listed twice, don't overwrite each other locally.
- Nothing is pulled after Ctrl+C.

Subtasks that run on the same host share one remote directory, so they overwrite each other there unless each writes to its own path, such as a junit file named after the subtask. `pull:` on the parallel task itself does nothing and produces a config warning.

### Host-restricted tasks

```yaml
tasks:
  deploy:
    description: Deploy to production
    hosts:
      - server
    run: ./deploy.sh
```

This task only runs on the `server` host, regardless of the default. rr picks
among the task's hosts (in the project's `hosts:` order), so a busy or
unreachable host outside the list is never tried. `--host` must name one of
them, or the task is refused with `CONFIG_INVALID` before rr waits on that
host's lock. `--tag` picks among them too; a tag that only hosts outside the
list carry is refused up front with `CONFIG_INVALID`, naming the task's hosts.

`local_fallback` doesn't apply to a restricted task unless its list names
your local host: when its hosts are unreachable the run fails with
`SSH_CONNECTION_FAILED` naming them, and when they're all locked it waits for them as
with `local_fallback: never`. It never falls back to this machine just to be
refused there.

The restriction holds inside parallel groups too: a restricted subtask is
scheduled only on a host it allows, and the run fails up front if `--host` or
`--tag` leaves it with no host to run on.

`--local` (and local mode) overrides the restriction, for a task and for
every subtask of a parallel task: it's an explicit request to run on this
machine, the same way it overrides the project's `hosts:` list. A shared
`.rr.yaml` can't name each person's local host, so a pin can't allow it any
other way.

### Reserved task names

You cannot name a task after a built-in command. These names are reserved:

- `run`, `exec`, `sync`, `prune`, `pull`
- `init`, `setup`, `status`, `provision`
- `monitor`, `doctor`, `completion`, `logs`
- `help`, `version`, `update`, `host`
- `unlock`, `tasks`

## Requirements

The `require` field declares tools that must exist on remote hosts before commands run. rr verifies requirements after SSH connect but before file sync.

### Configuration levels

Requirements can be specified at three levels, which are merged together:

**Project level** (applied to all hosts and tasks):

```yaml
# .rr.yaml
require:
  - go
  - node
  - golangci-lint
```

**Host level** (applied to a specific host):

```yaml
# ~/.rr/config.yaml
hosts:
  gpu-box:
    ssh: [gpu.local]
    dir: ~/projects/${PROJECT}
    require:
      - nvidia-smi
      - python3
      - cuda
```

**Task level** (applied when running a specific task):

```yaml
# .rr.yaml
tasks:
  build:
    run: cargo build --release
    require:
      - cargo
      - rustc
```

### How it works

1. SSH connection established
2. Requirements merged from project + host + task (deduplicated)
3. Each tool verified with `command -v <tool>`
4. If any missing, error with actionable suggestions

### Built-in installers

A missing tool fails the run with a `DEPENDENCY_MISSING` error listing what's missing. `rr provision` installs missing tools on your hosts, for tools that have a built-in installer (`--check` reports without installing, `--yes` skips prompts, `--host` targets one host).

**Built-in installers:** `go`, `node`, `npm`, `yarn`, `pnpm`, `bun`, `deno`, `python`/`python3`, `pip`, `uv`/`uvx`, `rust`/`rustc`/`cargo`, `ruby`, `gem`, `java`/`javac`, `make`, `git`, `docker`, `kubectl`, `terraform`, `aws`, `gcloud`, `jq`, `curl`, `wget`, `rsync`, `ripgrep`/`rg`, `fd`, `fzf`, `tree`, `htop`, `tmux`, `vim`, `nvim`/`neovim`, `chromium`. Other tools can still be listed in `require`; `rr` checks for them but can't install them.

### CLI flags

Skip requirement checks:

```bash
rr run --skip-requirements "make test"
rr exec --skip-requirements "echo hello"
```

Check requirement status with doctor:

```bash
rr doctor --requirements
```

`rr doctor --requirements` also checks that rsync is installed on each host. A missing requirement is a doctor failure (exit 1), since `rr run` fails on it too.

## Output

Output is controlled by CLI flags. The old `output:` config section was never read and has been removed; if your `.rr.yaml` still has one, rr prints a config warning and ignores it. Delete the block.

| Flag | Effect |
|------|--------|
| (default) | Structured JSON: phase events on stderr, then a final result event. |
| `--pretty`, `-p` | Human-readable output with spinners and colors. |
| `--no-color` | Disable colors in pretty mode. |
| `--no-phases` | Suppress intermediate phase events (connect, sync, exec). The final result event is still emitted. |
| `--machine`, `-m` | No-op, kept for compatibility (JSON is already the default). |

The result event's `details` can include `summary` (test counts), `failures` (name, `file:line`, message), `no_tests`, `piped_exit_code`, `fallback`, `log_file`, `hint`, `remote_cwd`, and `path_rewrites`.

### Test output parsing

`rr` detects the test framework from the command and its output, and parses pytest, Jest, and `go test` results into `summary` and `failures`. Other runners (including `cargo test`) are passed through without parsing.

## Monitor

Controls the resource monitoring dashboard (`rr monitor`). Monitor settings live in the project config (`.rr.yaml`), not in `~/.rr/config.yaml`.

```yaml
monitor:
  interval: 1s
  timeout: 8s
  thresholds:
    cpu:
      warning: 70
      critical: 90
    ram:
      warning: 70
      critical: 90
    gpu:
      warning: 70
      critical: 90
  exclude:
    - slow-host
  alerts:
    enabled: false
    bell: true
    flash: true
    cooldown: 60s
    on_alert: ""
```

### Monitor fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `interval` | duration | `1s` | Time between metric updates. Minimum `500ms`. |
| `timeout` | duration | `8s` | Per-host connect and collect timeout. |
| `thresholds` | object | see below | Warning and critical percentages for metric coloring. |
| `exclude` | list | `[]` | Host names to hide from the dashboard. |
| `alerts` | object | see below | Threshold alerting (bell, card flash, hook). |

**Interval precedence:** the `--interval` flag wins over `monitor.interval`, which wins over the `1s` default. An invalid or sub-500ms value in config is an error, not a silent fallback.

```bash
rr monitor                    # uses monitor.interval, or 1s if unset
rr monitor --interval=2s      # flag wins regardless of config
```

### Thresholds

Each metric type (CPU, RAM, GPU) has warning and critical percentages. They drive both the numeric readouts and the sparkline/bar coloring in the dashboard, plus the colored cells in `rr monitor --once`:

- Below warning: healthy
- Warning to critical: warning
- At or above critical: critical

| Threshold | Default | Description |
|-----------|---------|-------------|
| `cpu.warning` | `70` | CPU percentage that turns the value warning-colored. |
| `cpu.critical` | `90` | CPU percentage that turns the value critical-colored. |
| `ram.warning` | `70` | RAM percentage for warning color. |
| `ram.critical` | `90` | RAM percentage for critical color. |
| `gpu.warning` | `70` | GPU percentage for warning color. |
| `gpu.critical` | `90` | GPU percentage for critical color. |

Unset (or zero) values fall back to 70/90. Disk usage is not configurable: it uses a fixed 80/95 pair, since `df` capacity normally sits high and the shared defaults would flag every healthy host.

### Excluding hosts

Use `exclude` to hide specific hosts from the monitor dashboard. This is useful for hosts that are:

- Slow to respond (causing dashboard delays)
- Temporarily offline for maintenance
- Not relevant for monitoring

```yaml
monitor:
  exclude:
    - dev-machine
    - staging-server
```

Excluded hosts stay fully usable for `rr run`, `rr exec` and `rr sync`. Exclusion applies after `--hosts` filtering, and `--hosts` wins, so you can still pull up an excluded host on demand. `--hosts` can also name a global host the project's `hosts:` list leaves out, such as the local host:

```bash
rr monitor                          # staging-server hidden
rr monitor --hosts=staging-server   # staging-server shown
```

If the exclude list empties the host set, the command errors instead of opening an empty dashboard.

### Alerts

Alerting is off by default. Turn it on to get notified when a host crosses its critical threshold.

```yaml
monitor:
  alerts:
    enabled: true
    bell: true
    flash: true
    cooldown: 5m
    on_alert: 'terminal-notifier -title "rr" -message "$RR_HOST $RR_METRIC at $RR_VALUE%"'
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Turns threshold alerting on. |
| `bell` | bool | `true` | Rings the terminal bell when an alert fires. |
| `flash` | bool | `true` | Draws the alerting host's card border in the critical color. |
| `cooldown` | duration | `60s` | Minimum time between re-fires for the same host and metric. Use `0s` to fire on every crossing. |
| `on_alert` | string | `""` | Shell command run locally when an alert fires. |

**When an alert fires:** a metric fires once when it crosses its `critical` threshold, and does not fire again until it drops back below `warning`. That hysteresis keeps a host hovering at the critical line from alerting on every sample. The cooldown is a second guard on top of it.

The header shows a count of active alerts whenever anything is firing, regardless of the `bell` and `flash` settings.

**`on_alert` hook:** runs on your machine (the one running `rr`), not the remote host, via `sh -c`. `rr` sets these variables in the hook's environment (note that `RR_HOST` here is the alerting host, unrelated to the `RR_HOST` input `rr init` reads):

| Variable | Value |
|----------|-------|
| `RR_HOST` | Name of the host that alerted |
| `RR_METRIC` | `cpu`, `ram`, or `gpu` |
| `RR_VALUE` | The metric value, to one decimal place |

Hook failures are ignored. There's nowhere safe to print an error inside a full-screen TUI, and a broken hook shouldn't take down the dashboard, so test your command outside `rr monitor` first.

## Environment variables

These environment variables affect `rr` behavior:

| Variable | Description |
|----------|-------------|
| `RR_HOST` | SSH host for `rr init` (non-interactive mode). |
| `RR_HOST_NAME` | Friendly name for the host in `rr init`. |
| `RR_REMOTE_DIR` | Remote directory path for `rr init`. |
| `RR_NON_INTERACTIVE` | Set to `true` to skip prompts in `rr init`. A non-empty `CI` variable does the same. |
| `RR_NO_UPDATE_CHECK` | Set to `1` to disable automatic update checks. |
| `RR_DEBUG` | Set to any value to print debug logs (lock acquisition and similar internals). |

**Example: non-interactive setup in CI**

```bash
export RR_HOST=user@build-server
export RR_HOST_NAME=ci-runner
export RR_REMOTE_DIR=/home/ci/projects/\${PROJECT}
export RR_NON_INTERACTIVE=true
rr init
```

## Duration syntax

Fields that accept durations use Go's duration format:

| Format | Meaning |
|--------|---------|
| `500ms` | 500 milliseconds |
| `5s` | 5 seconds |
| `2m` | 2 minutes |
| `1h` | 1 hour |
| `1h30m` | 1 hour 30 minutes |

## Validation rules

`rr` validates your config on load. Common validation errors:

| Error | Fix |
|-------|-----|
| "No hosts configured" | Add at least one host to `~/.rr/config.yaml` or run `rr host add` |
| "Project references host 'X' which doesn't exist in global config" | The host referenced in `.rr.yaml` doesn't exist in `~/.rr/config.yaml` |
| "host 'X' needs at least one SSH connection" | Add `ssh:` list to the host in global config |
| "host 'X' needs a 'dir'" | Add `dir:` to the host in global config |
| "host 'X' can't set both 'local: true' and 'ssh'" | A local host runs on this machine. Remove `ssh:`, or remove `local: true` for a remote host |
| "host 'X' is local, so it runs in the project directory and can't set 'dir'" | Remove `dir:` from the local host |
| "only one host can be local" | Keep `local: true` on one host |
| "a host can't be named 'local'" | `local` is reserved for rr's local fallback. Rename the host (for example `dev`) and update any `hosts:` lists that use it |
| "host 'X' sets 'local: true', which isn't supported on Windows" | Local hosts run commands through `/bin/sh`. Use a remote host, or run rr from WSL |
| "Can't use 'X' as a task name - that's a built-in command" | Rename the task to avoid built-in command names |
| "task 'X' has both 'run' and 'steps'" | Use either `run` or `steps`, not both |
| "task 'X' depends on non-existent task 'Y'" | Add the missing task or fix the dependency reference |
| "task 'X' can't depend on itself" | Remove self-reference from depends list |
| "circular dependency detected: A -> B -> A" | Break the cycle by removing one of the dependencies |
| "task 'X' has both 'parallel' and 'depends'" | Parallel tasks can't have dependencies; use depends inside subtasks instead |
| "parallel task 'X' references non-existent task 'Y'" | Add the missing subtask or fix the reference |
| "task 'X' has output 'Y' but it needs to be one of: ..." | Use `progress`, `stream`, `verbose`, or `quiet` |
| "... has an invalid variable name 'X'" | Env names must be letters, digits, and underscores, not starting with a digit |
| "... value for 'X' has a ${ that is never closed" | Close the `${` or `$(`, or write `\$` for a literal dollar sign |
| "Config file has some issues" | A value has the wrong shape, e.g. an unknown `local_fallback` mode. Use `never`, `on-unreachable`, `always`, or a boolean there. |

Unknown keys don't fail validation, but each one produces a config warning naming the key and the file, so a misspelled key doesn't silently do nothing. Removed keys (`output:`, the global `defaults.host`) and settings that have no effect where they're placed (`pull:` on a parallel task, `output:` on a non-parallel task) warn the same way. Warnings are emitted once per command: a `config` phase event with `status: "warn"` in structured mode, or a styled warning with `--pretty`. The config still loads.

## Minimal config

The smallest working setup requires a global config with at least one host:

```yaml
# ~/.rr/config.yaml
version: 1

hosts:
  myhost:
    ssh:
      - myserver.example.com
    dir: ${HOME}/projects/${PROJECT}
```

A project config (`.rr.yaml`) is optional. If not present, `rr` uses all hosts from your global config with default sync/lock settings.

```yaml
# .rr.yaml (optional, for project-specific settings)
version: 1

sync:
  exclude:
    - .git
    - node_modules
```

Everything else uses sensible defaults.
