Checks before merge
repoos checkOne 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.
[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 kinds
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.
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]:
[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 matchingthemeScopesrow 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
smokescript inpackage.json, or [check] uiSmoke = "..."inrepoos.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):
[[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:
repoos check --fix # run each format step's fixer, then the gateThe 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:
repoos check # default profile (also used at handoff)
repoos check --profile full # every step, including the slow onesClose-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)
repoos check --changed mainWith --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 checkfor 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--changedsubset — 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.isolationRunstimes (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.
[[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
beforeEachandafterEachhooks 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-idto search the last ten runs.--recent-runs,--limit,--offset, and--jsonpaginate 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:
[[check.steps]]
name = "cross-stack-contract"
command = "make contract-test"
profiles = ["integration"] # keep it out of a routine runA 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 aswaiting: <reason> for Nmevery 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 · Nmchip (with queue position) whose tooltip readswaiting: <reason> for Nm. It replaces the old silentqueuedchip. 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 trippingstuck · 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 toSIGKILLif 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:
- Your legacy
[check]keys (uiSmoke,uiStylesheet, …) — still supported, with a migration warning pointing at--print-plan. - Inference from repo markers —
go.mod→go build ./...+go test ./...;Cargo.toml→cargo build,cargo fmt --check,cargo test; a committedgradlew→./gradlew assemble+./gradlew test;package.jsonscripts → the matchingformat/lint/build/tests/ui-smokesteps. - 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:
- run: bun install --frozen-lockfile
- run: repoos checkGoing 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 — 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.