---
url: https://docs.repoos.org/check.md
---
# Checks before merge

```bash
repoos check
```

One command is the whole definition of done. It exits non-zero on any failure,
so the same checks work locally, in CI, and inside the close-out pipeline. An
agent must get it green before handing a task back, and it runs again before
anything merges.

## The check plan

`repoos check` runs the plan your repo declares — not a pipeline RepoOS assumes
for you. Same command, whatever the stack: Go, Android/Gradle, Rust, JavaScript,
or a mix.

```toml
[check]
version = 1

[[check.steps]]
name = "build"
command = "go build ./..."
requires = ["go"]

[[check.steps]]
name = "tests"
command = "go test ./..."
requires = ["go"]
```

Steps run in the order you declare them, and each one ends in exactly one of
these — printed with its own icon so a skip never looks like a pass:

| Result | Icon | What it means |
| --- | --- | --- |
| Passed | `✔` | The command exited 0 |
| Failed | `✗` | The command ran and exited non-zero (the detail names it) |
| Timed out | `✗` | It exceeded `timeoutMs` and was killed |
| Missing prerequisite | `✗` | A binary the step needs isn't installed — the detail is install advice |
| Skipped | `⏭` | The step was explicitly excluded, and the detail says why |

A **required** step that can't run fails the gate. That's the whole point of
declaring `requires = ["go"]`: a machine without Go must not report a green
definition of done. Only a step you mark `required = false` is advisory.

### Step fields

| Field | Default | Meaning |
| --- | --- | --- |
| `name` | derived | Identifies the step in output and in `dependsOn`. Lowercase, no spaces. |
| `command` | — | Shell command to run. |
| `kind` | — | A built-in guard instead of a command (see below). |
| `fix` | derived for `format` | Command that fixes what the step checks, run by `repoos check --fix` and at handoff. A `kind = "format"` step defaults to the package.json `fmt` script; a raw command step has none unless declared. Never weakens close-out — see below. |
| `cwd` | repo root | Repo-relative directory to run in — a monorepo step can target `backend/`. |
| `timeoutMs` | `600000` | Kill the step after this long. |
| `required` | `true` | `false` makes a failure advisory instead of gating. |
| `profiles` | every profile | Which profiles include this step. `["full"]` keeps a slow step out of a routine run. |
| `alwaysRun` | safety suites | Test-file globs unioned with changed tests for `kind = "tests"`. Overrides `[check] alwaysRun`; an empty array disables the safety set. |
| `whenChanged` | always runs | Path globs; in changed-path mode the step runs only when one matches. |
| `requires` | — | Binaries that must be on `PATH`. |
| `runsOn` | any host | Host capabilities a **remote** run of this plan needs (`remoteValidation`), e.g. `runsOn = ["macos"]`. The runner routes the whole job to a host whose `os`/`labels` provide every capability; a job with no `runsOn` runs on any host, and local runs ignore the field. |
| `dependsOn` | — | Skip this step if a named earlier step failed. |

### Built-in `kind`s

`kind` runs a guard that inspects your repo and **skips with a stated reason
when it doesn't apply** — none of them assume a `package.json`, a Bun pipeline
or a JS build:

| Kind | What it checks | Skips when |
| --- | --- | --- |
| `staleness` | `src/` matches the last `dist/.build-info.json` build; a stale build is rebuilt with `bun run build` first | Your repo doesn't use RepoOS's build contract |
| `lockfile-sync` | `bun.lock` matches `package.json` | There is no `bun.lock` |
| `zero-runtime-deps` | `package.json` has empty `dependencies` | The package isn't named `repoos` (this is RepoOS's own invariant, not a rule for your repo) |
| `format` | Runs your `fmt:check` script; with `--fix`, runs the `fmt` script (or the step's `fix`) first | No such script |
| `lint` | Runs your `lint` script | No such script |
| `build` | Runs your `build` script | No such script |
| `tests` | Runs your `test` script (or a test directory) | No test suite |
| `ui-smoke` | Runs your smoke command | You declared none |
| `css-layers` | No unlayered universal/bare-element selectors | No `[check] uiStylesheet`, or it isn't Tailwind v4 |
| `theme-contrast` | Button gradients valid; token pairs meet ≥3:1 | No `uiStylesheet`/`themeScopes` |
| `hardcoded-colors` | No `#hex`/`rgba(255,…)` literals in component `<style>` blocks without a `/* hardcode-ok: <reason> */` marker | No `[check] hardcodedColorDirs` |
| `bare-require` | No bare `require` in ESM source | The package isn't `"type": "module"`, or no source root |
| `task-assets` | No committed binaries under your task/input/story dirs | Never — it reads `workDir`/`inputsDir`/`storiesDir` |

## Opting the stylesheet guards in

The full `[check]` field list, types, and defaults are in the
[repoos.toml reference](/configuration#previews-and-checks).

The CSS-layering and theme-contrast guards carry no RepoOS-shaped default path,
and token names are project-specific, so they read your vocabulary from
`[check]`:

```toml
[check]
uiStylesheet = "src/app.css"
backdropToken = "--bg"                   # page background; semi-transparent tokens composite over it
gradientTokens = ["--btn-primary-bg"]   # must resolve to a gradient, not a solid color

[[check.themeScopes]]                    # one row per theme block
selector = ":root"
name = "dark"
inherits = ["dark"]                      # earlier scopes whose tokens it inherits

[[check.contrastPairs]]                  # fg/bg pairs checked for ≥3:1 contrast
fg = "--txt"
bg = "--bg"
```

`themeScopes` are evaluated in order; each inherits the declarations of the
scopes named in `inherits` (later wins), so a light variant can inherit a dark
base and override only what differs. A `uiStylesheet` that doesn't exist is
called out rather than silently skipped.

A block you declare is only read if the guard can parse it, and two things stop
it — both quiet, since a theme that isn't checked just isn't reported on:

* A `:root[data-ui-theme="…"]`-style block with no matching `themeScopes` row is
  never evaluated. Declare one row per appearance.
* The scanner reads top-level blocks line by line: the selector must open the
  block on one line ending in `{`, one declaration per line, and no `/* … */`
  comment between declarations — a comment line corrupts the key that follows it
  and drops that token from the check.

A `contrastPairs` token may resolve to hex (including 8-digit `#rrggbbaa`) or
`rgb()`/`rgba()`, spaced or not. A color the guard cannot read is *skipped*
rather than failed, so an unparseable value means the pair is never checked —
prefer 8-digit hex over a low-alpha `rgba()` when a pair's background needs
transparency, since it is unambiguous. Semi-transparent colors are composited
over the `backdropToken` (the page background) before contrast is measured. A
gradient is judged on its worst stop, and every stop counts.

The **UI smoke test** is a command only you can know. Declare it either way:

* a `smoke` script in `package.json`, or
* `[check] uiSmoke = "..."` in `repoos.toml` (config wins).

With neither, the step skips with a clear message rather than pretending your
UI was tested. RepoOS's own repo dogfoods this same mechanism — it declares a
`smoke` script instead of being special-cased.

## Hard-coded colors and the rendered contrast audit

Token pairs can't see a component that styles itself. Two guards catch that
class of bug, both opt-in through `[check]`:

**`hardcoded-colors` (source).** Declares its roots with
`[check] hardcodedColorDirs = ["src/ui-app/src"]` and scans every component
`<style>` block (`vue`/`svelte`/`astro`/`html`) for `#hex` and `rgba(255,…)`
literals. A literal is allowed when the rule carries a marker comment —
`/* hardcode-ok: <reason> */` on the offending line or above it — which
exempts the rest of that rule block. One reason documents one rule; a literal
with no marker fails the step. Stylesheets are deliberately out of scope:
their literals are the theme token vocabulary, which `theme-contrast` already
checks.

**Rendered contrast (this repo's `contrast:audit` script).** Where
`theme-contrast` measures nine token pairs, the rendered audit boots the built
app in headless WebKit and measures what actually renders: for every
`[[check.themeScopes]]` scope in both light and dark, it opens a fixed set of
screens (board, drawer tabs, Agents, Settings, Context, the new-task drawer,
toasts), walks every visible text node, composites translucent ancestor
backgrounds down to the first opaque color, and fails anything under the WCAG
floor — 4.5:1 for body text, 3:1 for large text. Gradients are judged on their
worst stop (the same convention as `theme-contrast`); raster images and
gradient-clip text report as *unchecked* rather than guessed at; disabled
components and fully transparent text are skipped (WCAG exempts both). The
allowlist is `[[check.contrastExempts]]` (selector + reason) or a
`data-contrast-ok` attribute on the element — never scattered ignores.

Add it as a plain command step (it needs the build, like the smoke test):

```toml
[[check.steps]]
name = "rendered-contrast"
command = "bun run contrast:audit"
requires = ["bun"]
whenChanged = ["src/ui-app/**"]
dependsOn = ["check-fmt:check", "check-lint", "build"]
```

It skips with install advice when Playwright/WebKit is missing, exactly like
the smoke step.

## Auto-formatting before the check

A format-only violation otherwise costs a whole handoff round-trip, so the
`format` step can fix itself before it is checked:

```bash
repoos check --fix          # run each format step's fixer, then the gate
```

The fixer is the step's `fix` command, or — for a `kind = "format"` step with
none — the package.json `fmt` script (the conventional counterpart of
`fmt:check`). A raw `command` step is never guessed at: declare `fix = "..."`
if it has one.

Handoff runs the same fixers just before it commits, so the committed tree is
the formatted tree the gate then verifies. **Close-out never does this**: the
merge gate must keep failing an unformatted committed tree, and only `--fix`
and handoff opt in.

## The failed-steps summary

Every run ends with a fixed-position summary — failed step names and the exact
command that reruns each one — so it always survives `tail -20`:

```
  ── Failed steps ──
  ✗ check-fmt:check — fix: bun run fmt · rerun: repoos check --step check-fmt:check
```

`--step <name>` (repeatable, or comma-separated) runs only the named steps, so
the rerun command is genuinely one step rather than the whole gate. The summary
is printed after the terminal `N check(s) failed.` line; the close-out parser
reads the `── Results ──` block above it and never sees it.

## Profiles

A profile is a named subset of the plan. Steps with no `profiles` belong to
every profile; a step listed only in `["full"]` stays out of a routine run:

```bash
repoos check                 # default profile (also used at handoff)
repoos check --profile full  # every step, including the slow ones
```

Close-out always runs `--profile full` against the merged candidate — the merge
gate is not the place to skip anything.

## Changed-path mode (fast pre-review pass)

```bash
repoos check --changed main
```

With `--changed <ref>` (or `REPOOS_CHECK_CHANGED=<ref>` in the environment),
steps that declare `whenChanged` run only when a changed path matches their
globs; steps without it still run. This is a **fast pre-review pass** — an
agent's self-check before handoff. Close-out runs the **full profile**
(`--profile full`), but since #0724 it may *reuse* a handoff-proven result or
**scope** the tests to what changed (see "Close-out gate scope" below).

When `[remoteValidation] enabled = true`, `repoos check` runs install + build +
tests on the remote runner first (unless `--local-tests` or the remote half already
ran), then local guards only with `REPOOS_SKIP_TESTS=1`. After the first run on
a host, RepoOS usually uploads only new commits (not the full git history) using
a persistent mirror on that machine when the host's `/opt/repoos/validate.sh` is
current (see `docs/remote-validation.md` — older copies still work but receive a
full `HEAD` bundle until you update the script). The task Debug tab's
remote-validation events show bundle size and upload time for each run. The
Remote runners tab and Settings → Remote validation show each host's
`validate.sh` generation and a one-line install command when an update is needed. With `--changed` /
`REPOOS_CHECK_CHANGED`, the **remote** test step uses the same verified merge-base as the local
selector (#1001): the runner receives the base commit sha and materializes it from the bundle when
the checkout has no branch ref (for example `main`). If that base cannot be verified, the remote
run logs why and falls back to the full suite. Handoff runs the full suite on the runner unless it reuses a recent
self-check as described below, and default close-out runs
changed tests plus safety suites (below). Managed
engineers (`REPOOS_AGENT=1`) always use the board's runner (Hetzner or Tailscale).
Run `repoos check` once before handoff — not after every edit — to avoid WIP
checkpoint churn. Handoff reuses a passing local or remote-backed self-check for
that task when it proves the same commit, a clean worktree, unchanged source on
main, and the same merge base (task bookkeeping on main may advance). Full and
`changed:main` coverage qualify; partial `--step` runs, skipped suites, older
records without this evidence, and lookup errors do not.
Any new commit, dirty file, or source change on main runs the normal handoff gate.
Format, lint, build staleness, task assets and the remaining local guards still
run. The transcript and Checks detail say `reused check <id>`.

The internal server environment variable `REPOOS_HANDOFF_CHECK_MAX_AGE_MS`
limits reuse age (default one hour; zero disables it; invalid values disable
reuse). This deployment knob is deliberately not a `repoos.toml` feature key.
Close-out keeps its own combined-tree validation policy.

The link probe separates
SSH setup latency from payload throughput and retries once before skipping a
host, so checks from high-latency networks can use a runner when the actual
transfer rate meets `remoteValidation.linkThroughputFloorBps`. Its sample size
is controlled by `remoteValidation.linkProbeBytes`. Pass `--local-tests` to force
the full local suite even when remote validation is enabled.

## Failed-first re-runs and isolation triage

Two accelerators sit on top of the tests step, and neither can turn a red run
green:

* **Failed-first.** After a failed run, the next interactive/CLI or pre-review
  `repoos check` for the same task re-runs the files that failed last time
  **first**, and stops immediately (seconds, not minutes) if they still fail. If
  they pass, the full suite still runs to completion. This is an ordering
  optimisation, never a skip: a green result is only reported after the profile's
  normal scope — the whole suite, or the `--changed` subset — has run on the
  final tree.
* **Isolation triage.** When a failed tests step names a small number of files
  (at most three), each is re-run alone `check.isolationRuns` times (default 3)
  and the result is stored on the run and shown in the Checks → Runs tab, e.g.
  `src/x.test.ts: passed 3/3 alone`. It is **informational**: it never changes the
  run's outcome and never retries to green. Per AGENTS.md, a test that only fails
  under load and passes alone is still a real bug — a pass in isolation is not
  proof of a flake, only a clue.

Both are disabled for close-out and release: those gates never apply the
failed-first ordering or isolation-triage accelerators, and the remote close-out
gate never uses them. (See "Close-out gate scope" below for the changed-path safety set.) Set
`check.isolationRuns = 0` to disable isolation triage entirely. The setting is
also available in Settings → Advanced.

### Close-out gate scope

Close-out no longer runs every test by default. With `closeOut.gate = "scoped"`
(the default), Move to done runs the full check profile, but its tests step runs
changed-path tests against the merged candidate's merge base with current main,
**plus** a short always-run safety set. This applies even when the candidate tree
matches the handoff-tested tree. Both local and remote runs compute the union
before execution; passing file filters alongside Vitest's `--changed` would
incorrectly compute an intersection.

The defaults are `close-out-gate-mode.test.ts` (landing decisions),
`handoff.test.ts` (review handoff), `git-worker-lifecycle.test.ts` (git worker
startup and shutdown), `boot-timing.test.ts` (server boot), and
`frontmatter.test.ts` (task document parsing). These are `**/<filename>` globs.
Settings → Advanced → **Always-run test suites** controls `[check] alwaysRun`.
A tests step's `alwaysRun` array overrides that list; `[]` disables it.

```toml
[[check.steps]]
name = "tests"
kind = "tests"
alwaysRun = ["**/critical.test.ts"]
```

If the merge base, git diff, or changed-test discovery cannot be computed, the
test wrapper prints a warning and runs the full suite. A failing scoped gate
also retries once with the full suite. `closeOut.gate = "full"` and matching
`[check] fullSuitePaths` continue to force the full suite. Explicit
`closeOut.gate = "reuse"` retains the earlier handoff reuse policy.

To run every test on demand, use `repoos check --profile release` or unscoped
`bun run test`. The release profile includes every declared step and ignores
`--changed` and `REPOOS_CHECK_CHANGED`. Release validation still runs every test.

### Test-file timings

Every Vitest pass emits structured per-file durations, including failed files.
Local and remote check history stores these in `check_runs.test_file_timings`.
The check summary and `repoos checks timings` show the ten slowest files per run.
Durations measure Vitest's accumulated test and hook execution time, in
milliseconds; they exclude collection and environment setup and are not additive
wall-clock time when files run concurrently. Older history rows show no file
samples. Timing capture does not change whether a test passes.

### Individual test-case timings

Each finished Vitest case also emits a structured `::repoos-test-case::` record
(file, full name, stable case id, retry index, duration, pass/fail/skip). These
land in `check_run_test_cases` in `.repoos/checks.db`, keyed to the check run.
They are independent of the truncated step output log.

* **File total vs case time:** a file row is Vitest's module total (tests plus
  hooks in that file). A case row is Vitest's test-case duration: it includes
  that case's `beforeEach` and `afterEach` hooks as well as the test body. It
  does not include file collection or environment setup, or suite-level hooks
  that Vitest does not attribute to the case. Case times in one file do not add
  up to the file total because file totals also include suite-level hooks and
  other module work, and files may execute concurrently.
* **Compare runs:** `repoos checks timings --tests --run-id <id>` lists the
  slowest cases for one run; omit `--run-id` to search the last ten runs.
  `--recent-runs`, `--limit`, `--offset`, and `--json` paginate and export a
  stable JSON object. The Checks **Runs** tab loads the top cases when you expand
  a run (without pulling every case into the table).
* **Retention:** case rows are deleted when their check run falls out of the
  same bounded history as `check_runs` (newest 1000 runs).

### Cross-cutting steps

A step with **no** `whenChanged` runs whatever changed. That is how you keep a
contract or integration check from being skipped just because only one side of
a change moved — an Android client and a TypeScript backend, or a Vue frontend
and a Go backend. Give the inferred per-stack steps path globs (they already
carry them) and add one explicit cross-cutting step for the contract:

```toml
[[check.steps]]
name = "cross-stack-contract"
command = "make contract-test"
profiles = ["integration"]     # keep it out of a routine run
```

A change that touches both stacks still matches both sets of globs, so the fast
mode never hides a cross-stack change; the contract step runs on top of that.

## The Checks page

The **Checks** page shows what `repoos check` will run without running it. For
the profile you pick it lists every step in order — command, `cwd`, timeout,
dependencies, profile membership, and the paths `whenChanged` watches — and says
for each one whether it will run or why it is skipped. A required step whose
declared tool is missing is shown as a **missing prerequisite with the exact
command to install it**, never as a success, and the last completed run's
result, duration and command output are shown alongside.

## Run history

Every check run — local or remote, pre-review or close-out or release, and bare
`repoos check` invocations — is recorded in `.repoos/checks.db` with the machine
that ran it, how long it took, whether it was the full suite or a changed-path
pass, which steps were skipped, and what failed. The history lives in the MAIN
checkout: a standalone `repoos check` inside a task worktree records into the
same file the server reads, not a per-worktree store. The Checks page's **Runs**
tab shows this history as a sortable table across all tasks, with a per-machine
summary (runs, pass rate, median full-suite duration) above it — so "how long
does a full suite take on bee vs mini?" and "was that failure a fluke?" are
questions the table answers, not archaeology in log files. A run whose caller's
deadline passed mid-dispatch is recorded as **cancelled**, not failed, so a
give-up never reads as a gate the branch failed.

Each completed step records its name, outcome, and duration. The CLI prints a
**Step timings** summary. On **Runs**, expand a successful or failed run to see
its steps. The step report covers the latest 300 runs and shows median and p90
duration, failures out of executed samples, and skips. Skipped steps and older
runs without timings do not contribute duration or failure samples. The report
combines machines and scopes; compare individual runs before changing a gate
based on a small or mixed sample. Remote install, build, tests, and offloaded
browser steps supply timings when the runner uses the current validation script.

When the test suite fails, the run also records the name of every failing test
(`file > suite > test`) in a `failed_tests` column, and the Runs tab shows the
count and lists the names on hover. This is what tells a repeat offender from a
one-off: `sqlite3 .repoos/checks.db "select failed_tests from check_runs where
failed_tests is not null"`. Remote validation runs record the same fields from
the runner's output when Vitest names are present.

The **Remote runners** tab shows each configured host live: health, every job
holding or waiting for a host slot (server-dispatched and standalone
`repoos check` self-checks), with task id or worktree, gate phase, age, and
queue position; what the server has queued next; and the last completed run.
**Refresh** shows a spinner, then "Updated just now" (or an error toast).
Idle means no holders and no waiters on that host.

On the task page, a routine check stays on the status line under the preview
("Running checks…") and its result lives in the Debug tab. A chip appears in
the header only when that run is longer than expected.

### Slow-run alerts (#0720)

While a check, handoff gate, close-out stage, or bundle upload is **still
running**, RepoOS compares its elapsed time to the rolling median of the last
30 **passing** runs of the same kind (phase + remote/local + scope). It needs
at least five samples before it says anything. When elapsed exceeds
**1.5×** that median (configurable in **Settings → General** as "Flag slow
checks above (× median)"), you get:

* One **bell** item per run (`Slow check`) with elapsed vs typical, host, and
  the current phase when known (upload, install, build, tests). It clears
  automatically when the run finishes — nothing to dismiss.
* An amber **slow** badge on the task card (handoff / close-out hints) and the
  **Remote runners** row for that job. The task drawer instead shows one red
  header chip, **Slow** plus the elapsed time.
* A persistent **Checks slow lately** notice when three or more of the last
  ten completed runs of a kind were slow, naming the common factor (same host,
  local load, and so on).

Sleep gaps on the control machine are excluded the same way as the stuck-agent
watchdog (#0678), so a laptop suspend does not count as a slow run. History
comes from `.repoos/checks.db`; see `docs/check-slowness.md` for how the
server assembles running jobs and medians.

### When a check is waiting (#0974)

Before its first step a check can sit still for a while — the pressure guard
(`waitForPressureRelief`), a cross-process heavy slot, or a Playwright slot.
A waiting check says so, in three places:

* **The run's own output** prints `waiting: <reason>` when the wait starts,
  repeats as `waiting: <reason> for Nm` every minute, and at ten minutes adds
  "… queued checks are normal — keep waiting; do not end your turn". A handoff check's output
  streams into the task transcript, so the wait is readable there, live.
* **The task card** shows a `waiting for a check slot · Nm` chip (with queue position) whose
  tooltip reads `waiting: <reason> for Nm`. It replaces the old silent `queued`
  chip. The drawer shows the same hint next to the check chip.
* While a **local check** is running for an active engineer, the card shows
  `checking · …` with step/test progress and keeps **Pause work** disabled until
  the gate finishes — agent output can go quiet without tripping `stuck · silent`
  (#1000). The driver brief and the CTO decision digest use that same heartbeat:
  a live check is not reported as a stalled engineer, and the CTO does not
  restart that engineer while the heartbeat is fresh.
* A check with no valid progress heartbeat for three minutes is shown as
  `check stalled`. The card offers **Cancel stalled check** after an explicit
  confirmation; RepoOS signals the registered process group, escalates to
  `SIGKILL` if needed, and enables pause/restart only after process exit is
  observed. A missing or malformed heartbeat is treated as stalled evidence.
* **The run record** stores the wait (`waited: { reason, ms }`) alongside the
  steps, so a finished run still says "this one sat 26 minutes before it
  started".

Queue markers are a separate, non-blocking waiting state, with the reason and
start time. Engineers keep waiting and hand off after the check passes; a
normal queue wait does not set `needs_input`. Only an abnormal wait requests
input: a dead slot holder, a holder without a heartbeat for **30 minutes**, or
a wait exceeding the handoff deadline. The flag and waiting badge clear when
the gate acquires its slot. Pressure waits follow the configured pressure
guard and only escalate when they exceed the handoff deadline.

Separately, git-queue waits are visible per job: `repoos stats` lists each
in-flight git worker job with its lane (bulk/fast), queue wait and run time,
and the `STUCK` request log carries the same fields.

## Bootstrapping a plan with `repoos init`

When a repo has no check plan yet, `repoos init` inspects its durable signals
(a package manifest, `go.mod`, `Cargo.toml`, a Gradle wrapper, a Makefile or
justfile) and writes a **proposal** to `repoos.check-plan.proposed.toml`. The
proposal is not configuration: `repoos check` never reads it. Review and edit
it, then move its `[[check.steps]]` into `repoos.toml` (interactive init offers
to do this for you). Until then `repoos check` **skips** the gate with a "No
check plan configured — nothing to verify" reminder (exit 0, outcome
`skipped` — never rendered as passed). A starter plan that ran before anyone
looked at it would make the gate lie; a plan that resolves but contains errors
still fails the gate.

## When a repo declares nothing

Nothing is assumed. If a repo declares no `[[check.steps]]`, `repoos check`
falls back, in order, to:

1. **Your legacy `[check]` keys** (`uiSmoke`, `uiStylesheet`, …) — still
   supported, with a migration warning pointing at `--print-plan`.
2. **Inference from repo markers** — `go.mod` → `go build ./...` +
   `go test ./...`; `Cargo.toml` → `cargo build`, `cargo fmt --check`,
   `cargo test`; a committed `gradlew` → `./gradlew assemble` + `./gradlew
   test`; `package.json` scripts → the matching `format`/`lint`/`build`/`tests`
   /`ui-smoke` steps.
3. **Nothing.** A repo with no recognisable stack gets no steps at all — and
   that **skips** the gate (exit 0, outcome `skipped`) with a reminder instead
   of failing it: nothing was verified because there is nothing configured,
   which is fine while a repo is still in its planning phase, but the gate is
   never green — it is never recorded or displayed as "Checks passed".
   Config mistakes (malformed rows, an unusable step) keep failing as errors,
   and `--changed <ref>` with a ref git cannot resolve keeps exiting non-zero.

**Handoff and close-out (#0697).** A skip stays correct for a docs-only or
planning-phase repo. If a branch **newly adds** a buildable project marker
(`package.json`, `go.mod`, `Cargo.toml`, or `gradlew` — including under a
subdirectory) while `repoos.toml` still has no usable `[[check.steps]]`, the
gate would skip and verify nothing. RepoOS **fails handoff and close-out**
instead of waving that through, with a message to run `repoos check --print-plan`, commit the steps, and get a real green gate. The server does
not silently run an inferred plan at handoff: inference is a convenience for
local `repoos check`, not a substitute for committing what the gate should
run, and a bare manifest without conventional scripts still infers to an empty
plan. The automatic reviewer is told when the handoff gate skipped and, if the
task names a finite proof command, to run that instead of assuming checks
passed.

The reminder is actionable: run `repoos check --print-plan` for a starting
`[[check.steps]]` TOML once the repo has a `package.json`, `go.mod`,
`Cargo.toml` or `gradlew`, commit the steps, and `repoos check` runs the gate
again. The Checks page (and a task's Debug tab) offer the same as a one-click
"file a task" shortcut that creates a pre-filled *Set up check.steps in
repoos.toml* task through the normal task-creation route.

Inference is a convenience, not a configuration: `repoos check` says so, and
`repoos check --print-plan` prints the resolved plan as `[[check.steps]]` TOML
you can commit.

## Using it in CI

Because it's one command with a non-zero exit code, `repoos check` is a drop-in
CI step:

```yaml
- run: bun install --frozen-lockfile
- run: repoos check
```

## Going deeper

The step-by-step audit of which steps are genuinely generic versus still
RepoOS-shaped lives in RepoOS's own build context:
[the check-step genericity audit](../docs/audits/2026-09-check-step-genericity-audit.md)
— a repo-relative link that ships in the RepoOS source checkout, not on this
site. It's written for people working on RepoOS itself; #0446 implemented its
recommendation, which is this declarative plan.
