Configuration¶
Scrutin is configured via .scrutin/config.toml in the project root, with a fallback at ~/.config/scrutin/config.toml. There are no SCRUTIN_* environment variables: the file is the only persistent source of truth, and any field can be overridden at invocation time with -s/--set key.path=value. The annotated template below is the authoritative reference: every option is documented inline, scrutin init writes a copy of it into your project, and make docs injects the same rendered template into this page at build time (the markers are the injection target; the source file stays lean on purpose).
# scrutin configuration
#
# scrutin is configured via .scrutin/config.toml in the project root. Fallback:
# ~/.config/scrutin/config.toml. There are no SCRUTIN_* environment
# variables: this file is the only persistent source of truth.
#
# Any field below can be overridden at invocation time with
# --set key.path=value (repeatable). The RHS is parsed as a TOML
# expression, falling back to a bare string for unquoted values. So
# --set run.workers=8, --set run.shuffle=true, --set run.tool=pytest
# and --set 'pytest.extra_args=["--tb=short"]' all Just Work.
#
# Precedence: built-in defaults < this file < --set overrides < surviving
# CLI flags. All fields are optional; every line below is commented out
# so the file is a no-op as-shipped.
# ---------------------------------------------------------------------------
# [[suite]]: explicit test-suite declarations
# ---------------------------------------------------------------------------
# When at least one [[suite]] entry is present, auto-detection is skipped
# entirely: you control exactly which tools run and where their
# files live. When absent (the default), scrutin auto-detects from
# marker files (DESCRIPTION, pyproject.toml, jarl.toml, ...) at the
# *project root* (this directory).
# Detected at `scrutin init` time: <auto-detected from the project root at init time>
#
# Terminology:
# - project root: where this .scrutin/config.toml lives. Anchors
# shared state (.scrutin/state.db, runner scripts, hooks).
# - suite root: the directory each suite's tool runs from. Drives
# the subprocess CWD and the SCRUTIN_PKG_DIR env var, so
# pkgload::load_all() / pytest find the right DESCRIPTION /
# pyproject.toml / .venv / ruff.toml / jarl.toml.
#
# Supported `tool` values: "testthat", "tinytest", "pointblank",
# "validate", "jarl", "pytest", "ruff", "great_expectations".
#
# Fields per [[suite]]:
# tool (required) tool name
# root (optional) suite root, relative to project root (default ".")
# Absolute paths allowed.
# run (optional) glob patterns for files the tool operates on
# (tests to execute, files to lint). Always globs,
# anchored under `root`. Default: plugin-provided.
# watch (optional) glob patterns for files watched to trigger
# reruns. Default: plugin-provided (or `run` for
# linters, which re-check what they operate on).
# runner (optional) path to a custom runner script, relative to
# the project root.
#
# Single-package project (default shape):
#
# [[suite]]
# tool = "testthat"
# # root / run / watch omitted → plugin defaults under project root
#
# Monorepo: two sibling packages under one scrutin invocation:
#
# [[suite]]
# tool = "testthat"
# root = "r"
#
# [[suite]]
# tool = "pytest"
# root = "python"
#
# Custom globs:
#
# [[suite]]
# tool = "pytest"
# root = "python"
# run = ["tests/**/test_*.py"]
# watch = ["marginaleffects/**/*.py"]
# ---------------------------------------------------------------------------
# [run]: execution parameters
# ---------------------------------------------------------------------------
[run]
# tool = "auto"
# Restrict auto-detection to a single tool. "auto" (default)
# enables every tool whose marker files are present. Ignored
# when explicit [[suite]] entries are configured.
# workers = 4
# Number of concurrent test-file slots per suite. Default:
# min(physical_cores, 8), minimum 2. In fork mode this is the
# semaphore width (how many children run in parallel); there is
# still exactly one parent process per suite.
# fork_workers = false
# Worker isolation strategy. When false (the default), workers are
# killed and respawned after every file: safe but pays startup cost
# per file. When true (Linux/macOS only), each suite has one
# long-lived parent that fork()s a copy-on-write child per test
# file: fast startup + full process isolation.
#
# Dangerous: forking a process that is itself multithreaded or that
# spawns its own forked workers (R's parallel::mclapply, Python's
# multiprocessing with the fork start method, BLAS/OpenMP threads,
# etc.) can deadlock or crash the child. Only turn this on if you
# know no code under test forks on its own. Auto-disabled on Windows
# where fork() is unavailable.
# max_fail = 0
# Stop after this many failing *files* (failed + errored). 0 (default)
# means unlimited. File-level, not expectation-level: one crashing
# file counts as one bad file regardless of how many assertions it
# took down with it.
# color = true
# ANSI color in plain-mode output. TUI and web always render colors
# regardless of this setting.
# shuffle = false
# Randomize test-file execution order to surface inter-file state
# leaks. Does not reorder expectations within a file.
# seed = 0
# Shuffle seed. Omit to draw a fresh seed from system time; the
# drawn seed is always printed so the run is reproducible by
# re-running with --set run.seed=<printed value>.
# timeout_file_ms = 0
# Per-file timeout in milliseconds. A worker that doesn't return
# results within this budget is killed and the file marked errored.
# 0 (default) disables the per-file timeout.
# timeout_run_ms = 0
# Whole-run timeout in milliseconds across all suites. When
# exceeded, every in-flight worker is cancelled. 0 (default)
# disables this gate.
# reruns = 0
# Re-execute failing files up to this many extra times before
# reporting. A file that passes on attempt > 1 is marked flaky in
# the history DB, in JUnit output, and in the plain-mode summary.
# reruns_delay = 0
# Milliseconds to wait between rerun attempts. Useful for flakes
# caused by external rate limits or eventual consistency in fixtures.
# ---------------------------------------------------------------------------
# [preflight]: startup checks that fail fast with actionable errors
# ---------------------------------------------------------------------------
# Each check runs once at scrutin startup, before any test worker
# spawns. Failures surface as a single clean error instead of per-file
# noise mid-run. Disable individual checks if they are wrong for your
# project, or set [preflight] enabled = false to skip them all.
#
# [preflight]
# enabled = true # master switch
# suite_roots = true # each [[suite]] root resolves to an existing dir
# run_globs = true # each suite's `run` matches at least one file
# command_tools = true # jarl / ruff are on PATH
# python_imports = true # `import <project>` works in the resolved venv
# r_pkgload = true # R `pkgload` package is installed
# ---------------------------------------------------------------------------
# [watch]: file-watcher settings (TUI and web only)
# ---------------------------------------------------------------------------
[watch]
# enabled = true
# Whether watch mode is on by default in the TUI and web. Override
# with --set watch.enabled=false. Plain / GitHub / JUnit reporters
# are always one-shot regardless.
# debounce_ms = 50
# Coalesce file-change events within this window before triggering
# a re-run. Raise if your editor writes many files in bursts.
# ignore = [".git", "*.Rhistory"]
# Glob patterns excluded from the watcher. Matches paths relative
# to the project root. The built-in ignore list also covers target/,
# node_modules/, __pycache__/, etc.
# ---------------------------------------------------------------------------
# [filter]: test-file selection
# ---------------------------------------------------------------------------
[filter]
# include = []
# Glob patterns: only run test files matching at least one pattern.
# Empty (default) means no include filter.
# exclude = []
# Glob patterns: skip test files matching any pattern. Applied
# after include.
# group = "fast"
# Activate a named filter group at startup (from [filter.groups.*]).
# Equivalent to `-s filter.group=fast` on the CLI. When set, the
# group's include/exclude/tools *replace* the top-level filter.
# The TUI (f/F) and web dropdown cycle this at runtime.
# Named filter groups. Activate one with `-s filter.group=NAME` or
# by setting `group = "NAME"` above. The selected group's
# include/exclude/tools replace the top-level [filter] entries.
#
# [filter.groups.fast]
# tools = ["pytest"] # restrict to these tools (empty = all)
# include = ["**/test_unit_*.py"]
# exclude = ["**/test_slow_*.py"]
# ---------------------------------------------------------------------------
# [env]: environment variables injected into every subprocess
# ---------------------------------------------------------------------------
# Applied to worker subprocesses and command-mode plugins. Keys are
# validated case-insensitively (Path and PATH cannot coexist: they
# silently collapse on Windows). Empty values are legal (set the var
# to empty). No interpolation, no unset semantics. [env] always wins
# over inherited parent environment on conflict.
#
# [env]
# RUST_LOG = "debug"
# DATABASE_URL = "postgres://localhost/test"
# ---------------------------------------------------------------------------
# [metadata]: run-provenance capture
# ---------------------------------------------------------------------------
# Controls what goes into the history DB and JUnit <properties>.
# Provenance (git SHA, branch, hostname, CI provider) is captured
# automatically when `enabled = true`.
#
# [metadata]
# enabled = true # set false for privacy / air-gapped builds
# ---------------------------------------------------------------------------
# [extras]: user-supplied key/value labels attached to every run
# ---------------------------------------------------------------------------
# Goes into the `extras` table of the history DB and the JUnit
# <properties> block. Values can be any TOML scalar (string, int,
# float, bool) and are coerced to strings at the storage/reporter
# boundary, so --set extras.build=4521 Just Works without quoting.
#
# [extras]
# build = 4521
# branch = "feature/auth"
# ---------------------------------------------------------------------------
# [agent]: send-to-LLM-agent handoff (`a` key in TUI Detail/Failure level
# and the "ask agent" button in -r web)
# ---------------------------------------------------------------------------
# When you press `a` on a failing test, scrutin assembles a markdown
# diagnosis prompt (outcome + error + windowed test source + windowed
# dep-mapped production source) and spawns the configured CLI agent in
# a fresh terminal window pointed at the project root.
#
# All three fields are optional; with no [agent] block scrutin uses
# claude + auto-detected terminal + 20 lines of context.
#
# [agent]
# cli = "claude" # or "codex", "aider", "gemini", ...
# context_lines = 20 # lines of source on each side of the failing line
#
# Terminal launch: when `terminal` is unset, scrutin picks one in this
# order: $TMUX set → tmux new-window; $TERM_PROGRAM matches a known
# terminal (ghostty / iTerm.app / WezTerm / Apple_Terminal / kitty /
# alacritty) → that terminal; macOS → Terminal.app; Linux → $TERMINAL,
# x-terminal-emulator, then common emulators on $PATH.
#
# Override with a template containing `{script}` (path to wrapper
# script) and / or `{cwd}` (project root):
#
# terminal = "ghostty -e {script}"
# terminal = "tmux new-window -c {cwd} {script}"
# terminal = "alacritty --working-directory {cwd} -e {script}"
# ---------------------------------------------------------------------------
# [hooks]: lifecycle hook scripts
# ---------------------------------------------------------------------------
# Process hooks run once per invocation from the Rust binary.
# Worker hooks are sourced by each subprocess on boot / shutdown.
#
# [hooks]
# startup = "scripts/before_run.sh" # runs before first worker spawns;
# # must be executable; failure aborts
# teardown = "scripts/after_run.sh" # runs after last result drains;
# # failure logs a warning only
#
# Language-level worker hooks apply to every tool in that language
# unless overridden by a tool-level entry.
#
# [hooks.r]
# worker_startup = "scripts/r_setup.R"
# worker_teardown = "scripts/r_teardown.R"
#
# [hooks.r.testthat]
# worker_startup = "scripts/testthat_setup.R" # tool-level override
#
# [hooks.python]
# worker_startup = "scripts/py_setup.py"
#
# [hooks.python.pytest]
# worker_startup = "scripts/conftest_hook.py"
# ---------------------------------------------------------------------------
# [python]: interpreter resolution
# ---------------------------------------------------------------------------
# When unset, scrutin auto-detects in this order: $VIRTUAL_ENV, then
# .venv/ and venv/ in the project root, then $CONDA_PREFIX, then
# python3 on PATH.
#
# [python]
# venv = "envs/test" # path to a virtualenv directory
# interpreter = "uv run python" # replaces auto-detection entirely;
# # split on whitespace so wrappers work
# ---------------------------------------------------------------------------
# Per-tool config
# ---------------------------------------------------------------------------
# Custom runner scripts: `scrutin init` writes the embedded defaults to
# .scrutin/runners/<tool>.<ext> (e.g. .scrutin/runners/testthat.R,
# .scrutin/runners/scrutin_pytest.py). Edit those files in place to swap package
# loading, add project setup, or tweak the reporter; the engine picks
# them up automatically over the embedded default whenever present.
# Delete a file in that directory to fall back to the built-in runner.
# To point at a runner somewhere else in the repo, set `runner` on an
# explicit [[suite]] entry instead (see `runner` under [[suite]]).
#
# [pytest]
# extra_args = [] # appended verbatim to pytest.main(),
# # e.g. ["--tb=short", "-vv"]
# ---------------------------------------------------------------------------
# [keymap.<mode>]: TUI keybindings (replace semantics)
# ---------------------------------------------------------------------------
# Each [keymap.<mode>] subtable fully replaces the default bindings
# for that mode (replace, not overlay): deleting a line unbinds the
# key; deleting the whole subtable restores scrutin's built-in
# defaults for that mode. Modes: normal, detail, failure, help, log.
#
# Key syntax: single chars (`j`, `J`), named keys (`Enter`, `Esc`,
# `Tab`, `Space`, `Up`, `Down`, `PageUp`, `Backspace`, `F1`..`F12`),
# and modifiers (`ctrl+x`, `alt+x`, `shift+x`). Action names live
# in scrutin-tui::keymap::Action.
#
# The defaults for every mode are written out below by `scrutin init`
# so you can edit in place rather than reconstructing them from docs.
# [keymap.normal]
# "j" = "cursor_down"
# "k" = "cursor_up"
# "Down" = "cursor_down"
# "Up" = "cursor_up"
# "Right" = "enter_detail"
# "Enter" = "enter_detail"
# "l" = "enter_detail"
# "Left" = "pop"
# "Esc" = "pop"
# "q" = "quit"
# "g" = "cursor_top"
# "G" = "cursor_bottom"
# "Home" = "cursor_top"
# "End" = "cursor_bottom"
# "PageUp" = "full_page_up"
# "PageDown" = "full_page_down"
# "J" = "source_scroll_down"
# "K" = "source_scroll_up"
# "r" = "open_run_menu"
# "x" = "cancel_file"
# "X" = "cancel_all"
# "/" = "filter_name"
# "o" = "filter_status"
# "O" = "filter_status_back"
# "t" = "filter_tool"
# "T" = "filter_tool_back"
# "f" = "filter_group"
# "F" = "filter_group_back"
# "s" = "open_sort_menu"
# "\" = "toggle_orientation"
# "(" = "shrink_list"
# ")" = "grow_list"
# "Space" = "toggle_select"
# "v" = "toggle_visual"
# "e" = "edit_test"
# "y" = "yank_message"
# "L" = "enter_log"
# "?" = "enter_help"
# [keymap.detail]
# "j" = "cursor_down"
# "k" = "cursor_up"
# "Down" = "cursor_down"
# "Up" = "cursor_up"
# "Right" = "enter_failure"
# "Enter" = "enter_failure"
# "l" = "enter_failure"
# "Left" = "pop"
# "Esc" = "pop"
# "h" = "pop"
# "q" = "quit"
# "g" = "cursor_top"
# "G" = "cursor_bottom"
# "Home" = "cursor_top"
# "End" = "cursor_bottom"
# "PageUp" = "full_page_up"
# "PageDown" = "full_page_down"
# "J" = "source_scroll_down"
# "K" = "source_scroll_up"
# "r" = "run_current_file"
# "R" = "open_run_menu"
# "x" = "cancel_file"
# "X" = "cancel_all"
# "/" = "filter_name"
# "o" = "filter_status"
# "O" = "filter_status_back"
# "t" = "filter_tool"
# "T" = "filter_tool_back"
# "f" = "filter_group"
# "F" = "filter_group_back"
# "s" = "open_sort_menu"
# "\" = "toggle_orientation"
# "(" = "shrink_list"
# ")" = "grow_list"
# "e" = "edit_test"
# "E" = "edit_source"
# "y" = "yank_message"
# "a" = "diagnose_with_agent"
# "L" = "enter_log"
# "?" = "enter_help"
# [keymap.failure]
# "j" = "cursor_down"
# "k" = "cursor_up"
# "Down" = "cursor_down"
# "Up" = "cursor_up"
# "Left" = "pop"
# "Esc" = "pop"
# "h" = "pop"
# "q" = "quit"
# "r" = "run_current_file"
# "R" = "open_run_menu"
# "x" = "cancel_file"
# "X" = "cancel_all"
# "/" = "filter_name"
# "e" = "edit_test"
# "E" = "edit_source"
# "y" = "yank_message"
# "a" = "diagnose_with_agent"
# "L" = "enter_log"
# "?" = "enter_help"
# [keymap.help]
# "j" = "cursor_down"
# "k" = "cursor_up"
# "Down" = "cursor_down"
# "Up" = "cursor_up"
# "g" = "cursor_top"
# "G" = "cursor_bottom"
# "PageDown" = "cursor_down"
# "PageUp" = "cursor_up"
# "Esc" = "pop"
# "q" = "pop"
# "?" = "pop"
# [keymap.log]
# "j" = "cursor_down"
# "k" = "cursor_up"
# "Down" = "cursor_down"
# "Up" = "cursor_up"
# "g" = "cursor_top"
# "G" = "cursor_bottom"
# "PageDown" = "cursor_down"
# "PageUp" = "cursor_up"
# "Esc" = "pop"
# "q" = "pop"
# "?" = "pop"
# "L" = "pop"