Skip to content

repoos.toml reference ​

This page is the definitive reference for RepoOS project configuration. Every field is optional; a repo with no repoos.toml still runs on defaults.

RepoOS separates project settings from secrets:

WhereGood forCommitted?
repoos.toml (repo root)project behavior, board settings, previews, checks, auth UX, releases, deploymentsYes — git-tracked
.env (repo root, gitignored)RepoOS provider/auth/infrastructure secrets and legacy process overridesNo
registered project environment filesapplication secrets granted to named local consumersNo
machine / service environmentmachine-wide provider keys and infrastructure credentialsNo

No secret belongs in repoos.toml. Set RepoOS provider and session secrets in .env or the machine environment; use named project environments for application secrets. The one secret field the Settings UI will write to the file is whisper.apiKey — and you should still prefer REPOOS_WHISPER_KEY in .env. The full secret contract — precedence, rotation, and the safe .env.example — is in Environment and secrets.

How values are resolved ​

  1. The repo-root .env is loaded at startup, filling in values the real environment hasn't already set.
  2. Real shell or service environment variables always win over .env.
  3. repoos.toml is the committed source of non-secret config.
  4. A supported environment variable overrides the corresponding repoos.toml field (for example NTFY_BASE_URL overrides ntfyBaseUrl, and REPOOS_RESEND_API_KEY fills [auth.emailProvider]).

repoos.toml is deliberately small and flat. Nested sections use [section] headers and arrays of tables use [[section]] rows. The parser supports any valid TOML scalar string form: basic ("..."), literal ('...'), multi-line basic ("""..."""), and multi-line literal ('''...'''). Inline tables and single-line arrays are supported, including inline tables nested in arrays. Multi-line arrays are not accepted; the only place the parser accepts both [check] and [checks] spellings is the [check] section, kept for backwards compatibility.

A write from Settings or repoos config set rewrites the rows it touches as single-line basic strings ("..."), so a hand-written '''...''' value keeps the same decoded text — only its quoting style may change. Rows it does not touch are preserved byte-for-byte, including their multi-line strings.

The Settings UI edits the most common fields directly, and its raw repoos.toml editor can edit the whole file — including [preview], [check], [release], [stories], [[deployments]], [[distribution]], [worktrees], [tunnel], and anything the tabs don't surface. On the Settings page, use Search settings in the page header (or press ⌘K / Ctrl+K while on Settings) to jump to a control by name, config key, or section — a result links to the named section that holds it. The General tab is split into named sections, each with its own URL (for example /settings?tab=general&section=model-routing), so a section can be linked directly the way a task can. Keys that only exist in the raw file open the repoos.toml tab. The global top-bar search (⌘K elsewhere) still searches tasks, docs, and settings together.

Annotated starter repoos.toml ​

Copy this and delete anything you don't need. It shows the documented defaults plus a realistic value for each optional section. The example is illustrative, not a recommended starting point: it renames board columns and includes sections most repos never use, so delete what doesn't apply to you before committing.

The example keeps optional features off by default (release.enabled = false, autoEngineeringMode = false, worktrees.inheritEnv = false, and so on) so copying it wholesale is safe.

toml
# repoos.toml — all fields optional; delete a line to take its default.
# This file is committed to git. Never put a secret here; the one secret field
# the Settings UI will write is whisper.apiKey, which belongs in .env as
# REPOOS_WHISPER_KEY instead.
#
# Top-level keys must come before the first [table] header — in TOML a bare
# key after a header belongs to that table.

# ── Layout and repository paths ──────────────────────────────────────────
workDir = "work"          # task markdown files
docsDir = "docs"          # context docs an agent reads first
skillsDir = "skills"      # reusable skills
inputsDir = "inputs"      # user-submitted inputs and attachments
storiesDir = "stories"    # story definitions (when [stories] is on)
cacheDir = ".repoos"      # derived state; safe to delete
taskExtensions = [".md"]  # file extensions treated as tasks

# ── Board behavior ───────────────────────────────────────────────────────
defaultStatus = "inbox"        # draft | inbox | ready | active | review | done
defaultAssignee = "ai"         # unassigned | ai | human
defaultTaskMode = "freeform"   # freeform | manual
maxActiveTasks = 3             # 1-20; used when auto-engineering is on
autoEngineeringMode = false
skillSuggestions = false
worktreeWarnThreshold = 20     # 0 disables the warning

# ── Server and UI ────────────────────────────────────────────────────────
servePort = 7171   # omit to derive a stable per-repo port (7200-7999)
activityAnimationStyle = "orbital-pixel"   # compact agent-activity indicator; see table below
activityAnimationSize = "16"               # fixed pixels: 8, 12, 16, 20 or 24; see table below
activityAnimationConcurrency = 6            # simultaneous visible animations; 0 disables motion
strictBuild = false
autoReload = true

# ── Close-out (Move to done) ─────────────────────────────────────────────
closeOut.timeoutMs = 360000  # 6-minute budget per close-out attempt; 0 = no limit

# ── Budget controls (agent drivers) ──────────────────────────────────────
# Steer agent drivers (CLI/API sessions) to a human-approved subset of
# coding agents + models. Empty = unrestricted. Pause an entry with
# paused/pausedUntil/pausedReason (agent) or pausedModels (one model)
# instead of deleting it. Edit on the Agents page's Budget Controls tab,
# or as [[budget.allowlist]] rows here.
# [[budget.allowlist]]
# cli = "opencode"
# models = ["big pickle", "deepseek v4"]
# paused = false

# ── Approval policy (opt-in; default off) ────────────────────────────────
# approval.enabled = true
# approval.autoApprove.areas = ["api", "data", "docs", "chore"]
# approval.autoApprove.types = ["chore"]
# approval.autoApprove.uiAreas = ["web", "ui-app", "frontend"]
# approval.autoApprove.machineryPaths = ["src/server/", "src/core/", ".githooks/", "repoos.toml", "AGENTS.md"]
# approval.autoApprove.allowP0 = false   # p0 always needs a human

# ── Autopilot kill switch ────────────────────────────────────────────────
# automation.paused = false   # true halts every automatic CTO/policy action

# ── Agents ───────────────────────────────────────────────────────────────
maxConcurrentAgents = 5  # omit to size from this machine's CPU count
ctoSkipHealthy = true    # skip the CTO model call while the board is healthy
# cto.actions = []       # opt-in allowlist: restart-stalled-agent, refresh-main-install, requeue-closeout-after-env-fix
# cto.nudgeIdleMs = 300000        # 5 minutes without worktree activity before the idle nudge
# cto.nudgeCooldownMs = 3600000   # 60 minutes before the same task is nudged again

# ── Notifications ────────────────────────────────────────────────────────
ntfyEnabled = false
ntfyTopic = ""
ntfyBaseUrl = "https://ntfy.sh"

# ── Board column labels (display only; status IDs never change) ──────────
[board.columns]
draft = "Ideas"
inbox = "Backlog"
ready = "Selected for development"
active = "In progress"
review = "Code review"
done = "Shipped"

# ── Worktrees and runtime ────────────────────────────────────────────────
[worktrees]
inheritEnv = false  # off by default; opt in to link .env into worktrees

[watchdog]
enabled = true
stalenessMs = 300000
autoTransition = true
phantomDetectMs = 180000
engineerCrashFailoverThreshold = 3
engineerCrashFailoverWindowMs = 1800000

[whisper]
provider = "none"  # none | groq | openai
# apiKey = "..."   # a secret — prefer REPOOS_WHISPER_KEY in .env

# ── Areas (optional task-area vocabulary) ─────────────────────────────────
[[areas]]
name = "web"
description = "The main web app"
[[areas]]
name = "cli"

# ── Task previews ────────────────────────────────────────────────────────
[preview]
command = "bun run dev --port {port}"
readyPath = "/"
readyTimeoutMs = 10000

[[preview.targets]]
name = "Landing page"
areas = ["landing", "web"]
command = "bun run dev --port {port}"
cwd = "landing"

# ── Checks ───────────────────────────────────────────────────────────────
[check]
version = 1
uiSmoke = "bun run smoke"
isolationRuns = 3              # re-run a failing test file alone this many times
uiStylesheet = "src/styles.css"
backdropToken = "--bg"
gradientTokens = ["--btn-primary-bg"]

[[check.steps]]                   # the gate's plan; omit to infer from the repo
name = "build"
command = "bun run build"

[[check.themeScopes]]
selector = ":root"
name = "dark"
inherits = ["dark"]

[[check.contrastPairs]]
fg = "--txt"
bg = "--bg"

# ── Authentication ───────────────────────────────────────────────────────
[auth]
enabled = false
sessionMaxAge = 2592000       # seconds; values under 300 are read as days
bootstrapAdmin = "you@example.com"

[auth.emailProvider]
type = "resend"
fromAddress = "otp@send.example.com"

[auth.google]
clientId = "your-client-id.apps.googleusercontent.com"

# ── Releases, deployments, distribution ──────────────────────────────────
[release]
enabled = false           # true shows the Releases UI and API
provider = "git-tag"
branch = "main"
versionFile = "package.json"
tagPrefix = "v"
remote = "origin"
repository = "owner/repo"
workflow = ".github/workflows/release.yml"

[[deployments]]
name = "Dashboard (prod)"
service = "Dashboard"
branch = "prod"
provider = "cloudflare-workers"
url = "https://app.example.com"
dashboard_url = "https://dash.cloudflare.com/…"
subdir = "app"

[[distribution]]
name = "npm"
kind = "npm"
package = "@scope/package"
url = "https://www.npmjs.com/package/@scope/package"
install = ["npm install -g @scope/package"]

# ── Stories (cross-area delivery tracking, on by default) ────────────────
[stories]
enabled = true            # false hides the Stories page and task Story field
excerptBytes = 4096       # bytes of story definition agents see in their prompt

# ── Tunnels (managed by `repoos tunnel`) ─────────────────────────────────
[tunnel]
enabled = false
provider = "cloudflare"
name = "repoos-local"
domain = ""
tunnel_id = ""

# ── Remote validation ────────────────────────────────────────────────────
[remoteValidation]
enabled = false
serverType = "cax31"
location = "hil"
snapshotId = ""
sshKeyName = ""
idleShutdownMinutes = 8
maxServerLifetimeMinutes = 120
fallbackToLocal = false
retryOtherHosts = true         # retry on another healthy tailscale host before giving up (default true with 2+ hosts)
useForReleases = false
browserCaptureMode = "local"  # local | runner-available | runner-only

The sections below document each field: its type, default, whether it is committed (everything in this file is, with the noted whisper.apiKey exception), and its effect and constraints.

Layout and repository paths ​

toml
workDir = "work"
docsDir = "docs"
skillsDir = "skills"
inputsDir = "inputs"
schedulesDir = "schedules"
remindersDir = "reminders"
storiesDir = "stories"
cacheDir = ".repoos"
taskExtensions = [".md"]
FieldTypeDefaultCommittedEffect
workDirstringworkyesDirectory holding task markdown files, relative to the repo root. This is the board.
docsDirstringdocsyesDirectory holding context docs an agent reads before working.
skillsDirstringskillsyesDirectory holding reusable skills (skills/<name>/SKILL.md).
inputsDirstringinputsyesDirectory holding user-submitted inputs and their attachments. Must be repo-relative; an absolute or escaping value falls back to the default with a warning.
schedulesDirstringschedulesyesDirectory holding scheduled task definitions (Agents → Scheduled Tasks). Must be repo-relative; an absolute or escaping value falls back to the default with a warning.
remindersDirstringremindersyesDirectory holding reminder notes (Agents → Reminders). Must be repo-relative; an absolute or escaping value falls back to the default with a warning.
scheduledTasks.enabledbooleanfalsenoWhen true, the control-plane server runs enabled schedules on a cadence. Off by default; previews and task worktrees never run schedules.
storiesDirstringstoriesyesDirectory holding story definitions (used when Stories are enabled). Must be repo-relative; an absolute or escaping value falls back to the default with a warning.
cacheDirstring.repoosyesDerived state only — logs, indexes, cached databases. Delete it and RepoOS rebuilds from the task files; nothing of record is lost.
taskExtensionsarray of strings[".md"]yesFile extensions treated as tasks.

All paths are relative to the repo root. repoos.toml and AGENTS.md always stay at the root regardless of how these are set. Changing workDir, cacheDir, or taskExtensions triggers an index refresh.

These configured paths are authoritative: RepoOS and the agents it runs read the board and project docs from exactly these directories. If another tool or a set of project docs assumes a different layout, change repoos.toml (or reconcile the docs) — never move the directories so the config no longer matches where your content lives.

On the repoos/ layout, repoos init writes workDir, docsDir, skillsDir, storiesDir, inputsDir, schedulesDir, remindersDir and cacheDir together, all under the namespace, so every RepoOS-owned directory stays in one place. A layout where workDir is namespaced but storiesDir is still top-level (as init wrote before #0703) scatters stories to the repo root; repoos doctor warns about that mismatch and suggests storiesDir = "<namespace>/stories", but never moves anything for you.

Existing projects keep their current paths. The schedulesDir and remindersDir defaults are schedules and reminders, so a repository created before those keys followed the namespace keeps working unchanged. RepoOS never moves a directory silently. To relocate either one, set the key in repoos.toml and move the files yourself, for example:

sh
git mv schedules repoos/schedules
git mv reminders repoos/reminders

then add schedulesDir = "repoos/schedules" and remindersDir = "repoos/reminders" to repoos.toml.

Board behavior ​

toml
defaultStatus = "inbox"
defaultAssignee = "ai"
defaultTaskMode = "freeform"
maxActiveTasks = 3
autoEngineeringMode = false

[autoEngineering]
pmVeto = false

skillSuggestions = false
worktreeWarnThreshold = 20
FieldTypeDefaultCommittedEffect
defaultStatusselectinboxyesStatus new tasks start in. One of draft, inbox, ready, active, review, done.
defaultAssigneeselectaiyesDefault assignee for new tasks: unassigned, ai, or human. Defaults to ai, so new tasks are picked up by an agent; existing tasks keep their own assignee.
defaultTaskModeselectfreeformyesNew-task flow: freeform (the AI writes the task) or manual (a form). Any other value falls back to freeform.
maxActiveTasksnumber3yesCap on simultaneously active tasks when autoEngineeringMode is on. Must be 1–20.
autoEngineeringModebooleanfalseyesWhen true, RepoOS event-driven dispatch starts ready tasks automatically, up to maxActiveTasks (see below).
autoEngineering.pmVetobooleanfalseyesOff by default: selection is fully deterministic (priority, then critical-path weight, then creation order). When on, a PM pass runs only when there are more eligible ready tasks than open slots and two candidates would collide (same area or a declared shared path); it may reorder or defer, never invent work.
skillSuggestionsbooleanfalseyesWhen true, a finished task may generate a high-bar, evidence-gated skill suggestion task. Off by default; a single session never creates one.
worktreeWarnThresholdnumber20yesAdvisory ceiling on registered git worktrees. Above it the Control page's Codebase card turns amber and the server logs a warning with safe-candidate / retained / external counts. Cleanup itself runs automatically; the warning never blocks a task. Set 0 to disable.
board.agentPopoverCloseSecondsnumber15yesSeconds before the task-card agent popover fades closed. Hover, focus, or a click inside restarts the timer. 0 leaves the popover open until it is dismissed.

Board column labels ​

toml
[board.columns]
draft = "Ideas"
inbox = "Backlog"
ready = "Selected for development"
active = "In progress"
review = "Code review"
done = "Shipped"

board.columns.draft, board.columns.inbox, board.columns.ready, board.columns.active, board.columns.review, and board.columns.done rename the six column labels shown in the UI and CLI. You can edit them from Settings → Advanced → Work board column labels; clearing a field restores that column's default. These are display labels only: the canonical status IDs never change, and transitions, frontmatter, and API/CLI status inputs are unaffected.

Auto-engineering mode (autoEngineeringMode) ​

When autoEngineeringMode = true, the server runs a dispatch pass whenever something frees capacity or new work becomes eligible:

  • a task leaves active for review (a slot opens),
  • a task moves from inbox to ready,
  • a dependency lands on main (dependency-merged trigger),
  • the server starts up,
  • or autoEngineeringMode / maxActiveTasks changes in config.

Each pass:

  1. Counts active tasks (not archived) and computes free slots up to maxActiveTasks.
  2. Builds the candidate list: ready tasks that are not archived, not needs_input, not held (hold: true or a hold tag), and have no unmet dependsOn blockers.
  3. Orders candidates deterministically: priority, then critical-path weight (transitive dependents on the full board), then creation order, then task id; takes as many as fit in the open slots.
  4. When autoEngineering.pmVeto is on and there are more eligible tasks than slots and two candidates would collide (same area or a declared shared paths entry), calls the PM agent once (recorded as a dispatch session) to reorder or defer only — never to invent tasks. On PM failure, the deterministic order stands.
  5. Starts the selected tasks through the normal /start path.

Outcomes (no-capacity, no-ready-work, selected, …) and which picker ran are persisted for the Control page. This is optional automation — default is off.

Task body sections (underspecified check) ​

RepoOS can flag stub tasks with needs_input / underspecified. A well-formed task body normally includes these headings (each with real prose, not placeholders):

  • ## Problem
  • ## Desired UX — required only when the task's area includes a UI slice (web, ui, or ui-app). Pure server/docs/cli tasks may omit it.
  • ## Acceptance criteria
  • ## Notes for AI

The body outside ## Original prompt should be at least ~400 characters. The PM "flesh this out" flow and repoos new / task PATCH paths run the same check.

RepoOS also flags needs_input / needs-human-step when acceptance criteria need a real device, account, or third-party sign-up. A keyword scan is only a tripwire: when Model routing → Check for mixed AI and human steps is on, the decision model must agree before the flag is set, and needs_input_detail quotes the matched criterion plus the model's bounded reason. Clearing the flag (or repoos update --human-step-override "<reason>") is sticky by default (Remember cleared needs-human-step flags): RepoOS stores a fingerprint of the acceptance criteria so later edits do not re-flag until the criteria materially change. Prefix criteria with [human] or [ai]; when both tags are present, repoos split <id> (or Split human steps in the drawer) moves the [human] lines to a held human task that depends on the AI task.

When the needs human step banner appears, its primary action asks the PM to check whether any criteria truly require a person. The PM either records a human-step override for a false positive or creates a held human task and narrowly scopes the original; use Split human steps to apply explicit [human] / [ai] tags directly.

Both checks are agent-oriented. A task assigned to a human (--assigned-to human) is the human's own checklist: it skips both, ## Notes for AI is optional for it, and its acceptance criteria are still shown.

Constraints: a label is a string of at most 40 characters; blank labels, duplicates of another column's label, or over-length values fall back to that column's default. Any column you don't override keeps its default. The default labels are Draft, Inbox, Ready, Active, Review, Done.

Server and UI ​

toml
servePort = 7171
strictBuild = false
activityAnimationStyle = "orbital-pixel"
activityAnimationSize = "auto"
FieldTypeDefaultCommittedEffect
servePortnumberderived per repoyesPort repoos serve binds by default. If omitted, RepoOS derives a stable port from the repo's path in the 7200–7999 range, so two checkouts never collide. Must be 1–65535. --port on the command line overrides it.
strictBuildbooleanfalseyesWhen true, a stale build makes repoos exit with an error instead of printing a warning. REPOOS_STRICT_BUILD=1 (environment) and the --strict-build flag do the same without changing the file.
autoReloadbooleantrueyesAutomatically hands over to a replacement server when a newer build is available. Edit in Settings → General; changing it takes effect after the next server start. After three failed handovers the server stops retrying and the UI reports that a restart is needed.
activityAnimationStyleselectorbital-pixelyesMotion beside coding, reviewing, and other agent-activity labels in the web UI. Values: orbital-pixel (default compact orbit), cursor-scan-pulse, breathing-brackets, micro-dot-matrix, tiny-signal-bars, spark-diamond, arcade-hopper (alien that jumps and tilts), puppy-tracks (two alternating paw prints), bird-quick-trail (fast alternating bird footprints), rgb-keystorm (multicolor keyboard with a moving key wave, typing fingers and rising code), wheelie-courier (motorcycle and rider doing repeated wheelies), boo-swoop (ghost swooping with a rippling hem and orbiting wisps), pumpkin-spell (bouncing jack-o'-lantern with a witch hat and rising sparks), or three-dots (legacy horizontal dots). Omitted config uses orbital-pixel. Edit in Settings → General → Activity animation; the page previews all three activity states before save. Honors prefers-reduced-motion with a static indicator.
activityAnimationSizeselect16yesFixed pixel width of the activity animation, rendered at the same size on every surface (task cards, the Dev page and the Settings preview) regardless of the surrounding font. Values are the quoted strings "8", "12", "16", "20", "24"; a hand-written numeric TOML value (16) is normalized to the same value. Omitted config uses 16. Legacy character-width values ("auto", "1"–"3") still load and are mapped onto the pixel ladder by ordinal, so existing configs keep their relative size. Edit in Settings → General → Activity animation with a single slider (no Auto toggle; the default is marked with a tick); the preview updates live. Honors prefers-reduced-motion at every size.
activityAnimationConcurrencynumber6yesMaximum number of visible activity indicators animated simultaneously. Additional and off-screen indicators hold a still frame without changing their footprint. Values are integers 0–24; 0 pauses all motion. Hidden documents and prefers-reduced-motion also pause animation. Edit in Settings → General → Activity animation.

Appearance is not a repoos.toml setting. The dark/light/system theme and the UI design language are per-browser preferences stored in the browser, so they never travel with the repo.

Attention (notification bell) ​

toml
attention.spendAlertUsd = 0
attention.slowRunMultiplier = 1.5
FieldTypeDefaultCommittedEffect
attention.spendAlertUsdnumber0yesWhen provider-reported board spend reaches this USD total, the notification bell shows a spend alert. 0 disables the alert. Estimates and unknown costs are never counted. Edit it in Settings → Notifications. The same feed is available as GET /api/attention (see Notices and notifications).
attention.slowRunMultipliernumber1.5yesWhile a check, handoff gate, close-out stage, or bundle upload is running, flag it in the bell when elapsed time exceeds this multiple of the median of recent passing runs of the same kind (at least five samples). Must be ≥ 1. Edit it in Settings → General. See Checks — slow-run alerts.

Close-out (Move to done) ​

toml
closeOut.timeoutMs = 360000
closeOut.gate = "scoped"              # "scoped" (default) | "reuse" | "full"
closeOut.candidate = "symlink-main"   # or "own-install"
closeOut.installCommand = ""          # optional shell install for candidates / main refresh
closeOut.postPublishCommand = ""      # optional shell install in main after lockfile-changing merges
FieldTypeDefaultCommittedEffect
closeOut.timeoutMsnumber360000 (6 min)yesTotal wall-clock budget for one close-out attempt — from when the job leaves the queue until it reaches failed, done, or is removed by a user cancel. A close-out that runs past it is aborted: in-flight build/check/publish children are killed, the throwaway candidate worktree is torn down, and the job is recorded as a retryable failed whose reason starts close-out timed out after …. The task stays in review with its feature branch and worktree untouched, so Move to done can be retried. The single validating retry, main-drift resyncs, and remote validation (host-pool queue wait included) all spend the same budget, and per-step child timeouts are capped to whatever budget remains. 0 disables the ceiling (the unbounded pre-#0573 behaviour). Invalid or negative values are ignored with a [closeOut] timeoutMs … console warning and fall back to the default.
closeOut.gatestring"scoped"yesHow much of the merge gate a Move to done re-runs (#0724). The candidate already passed the identical full gate at handoff, so the close-out compares the candidate tree against the tree the handoff gate recorded and picks a mode. "reuse" runs the cheap steps and skips the suite when the candidate tree is identical to the tested tree or main advanced with bookkeeping only; "scoped" (default) runs changed-path tests against current main plus the always-run safety suites, even for an identical handoff tree; "full" always runs the whole suite. Releases and paths under check.fullSuitePaths always run the full suite, and a scoped failure is retried once as the full suite. Invalid values are ignored with a warning and fall back to "scoped".

Edit it in Settings → General → "Close-out timeout" (presets plus Off (no limit)), or set any value directly in repoos.toml. A timeout is a failure with an error card; Stop MTD on the task drawer is a user cancel and stays badge-free — see docs/close-out-pipeline.md for how the three outcomes differ.

| closeOut.candidate | string | symlink-main | yes | How the throwaway candidate worktree gets dependencies. symlink-main reuses the primary checkout's node_modules (fast). own-install runs a frozen install in the candidate — use for monorepos or when Vite reports Denied ID on symlinked paths. [worktrees] candidate is an alias when closeOut.candidate is unset. | | closeOut.installCommand | string | (empty) | yes | Optional shell command to install dependencies for candidates, and to refresh main after a lockfile-changing merge when postPublishCommand is empty. When empty, RepoOS infers bun install --frozen-lockfile, npm ci, etc. from lockfiles. | | closeOut.postPublishCommand | string | (empty) | yes | Shell command run in the primary checkout after a merge that changed package.json or a lockfile. Replaces the automatic lockfile install — use for Python venvs, Cargo, or other stacks. |

Edit Close-out candidate dependencies, Close-out install command, and Post-merge install command in Settings → General.

Budget controls (agent drivers) ​

An agent driver is an AI session that operates RepoOS through the CLI/API — overriding engineers, starting tasks, restarting stuck runs. It has repeatedly picked agent + model pairs that are too expensive. This optional allowlist steers an agent driver to a human-approved subset; a human using the web UI is never restricted and keeps the full list. Leave it empty and nothing is restricted — agent drivers behave exactly as they do today.

toml
[[budget.allowlist]]
cli = "opencode"
models = ["big pickle", "deepseek v4"]

[[budget.allowlist]]
cli = "claude code"
models = ["sonnet"]
FieldTypeDefaultCommittedEffect
budget.allowlist[].clistring(required)yesA coding-agent CLI id (the AGENT_CLIS string, e.g. opencode, claude code). An agent driver may only use a listed CLI.
budget.allowlist[].modelsarray of strings(required)yesThe model ids allowed for that CLI. A model not listed is refused. Rows for the same cli are merged; a row with no models is dropped.
budget.allowlist[].pausedbooleanfalseyesPauses the whole agent (every model on the row) without removing it. A paused entry is never selected by defaults, Start, dispatch, failover or agent drivers.
budget.allowlist[].pausedUntilISO date/time(none — indefinite)yesWhen the pause ends. The entry resumes automatically at that time with no restart.
budget.allowlist[].pausedReasonstring(none)yesFree-text reason shown with the Paused chip and in fallback logs.
budget.allowlist[].pausedModelsarray of strings or { model, pausedUntil?, pausedReason? }(none)yesPauses individual models. Use the object form to give one model its own resume date/reason. A bare "default" model selection is treated as paused if any model on its row is paused (fail closed).

Edit it on the Agents page → Budget Controls tab (/agents?tab=budget-controls), which lists each allowed agent with its models collapsed by default and adds entries through the shared coding agent + model selector. Each row and model chip has Pause / Resume controls; Pause opens a dialog for an optional resume date/time and reason. From a driver shell, use repoos agents pause <agent> [--model <model>] [--until <date>] [--reason "…"] and repoos agents resume <agent> [--model <model>]. The budget.allowlist key is also in the Settings schema so search can find it. A pause you set is stored in repoos.toml. A pause RepoOS records after a quota error (reason starting out of usage since) is stored in the gitignored .repoos/budget-runtime.json instead, so it does not dirty main. Your pause on the same agent or model wins. An expired pausedUntil resumes with no rewrite. On startup, automatic pause fields still sitting in repoos.toml are copied into that runtime file and then read from there.

When an allowlist entry is paused, selection falls back to the next allowed pair that is not paused and records the swap (and the reason) in the task's activity log. If every allowed pair is paused, starting a task fails with "All allowed agent+model pairs are paused" instead of retrying the same broken pair. A per-task override a human set in the UI keeps working even if that pair is paused; an agent driver's attempt to set a paused override is refused.

Enforcement: when an agent driver sets an engineer/reviewer override (repoos override <id> --cli … --model …, or a direct PATCH /api/tasks/:id with cliOverride/modelOverride) to a pair outside the allowlist, the server refuses with a clear error listing every allowed pair. The server tells an agent driver from a human by the loopback CLI token: a browser UI session never carries it, so the human path always falls through. repoos override --help prints the allowlist so a driver can choose an allowed pair.

Adaptive model routing ([model_routing]) ​

An optional decision model can choose the coding agent and model for PM, engineer and reviewer runs. It is off by default: with the section unset, RepoOS uses the configured role defaults and task overrides exactly as before.

Shipped (#0791 + #0792 + #0793): src/core/model-routing/ (System One codec, TypeSafe Jev and OpenRouter adapters, candidate filter, policy, handback re-evaluation, restart-safe decision records), the six model_routing.* keys in getConfigSchema(), TOML parsing, and the Model routing tab on the Agents page (provider, mode, roles, timeout, credential presence, configuration probe, and recent decisions). Routing is now invoked on the PM, engineer and reviewer run paths (including review handback). #0812 gives the decision model real inputs (a context-budgeted brief, per-candidate model facts and project history, task progress, and a stated objective). The only remaining follow-up is the human-only live credentialed smoke test (#0794). A self-hosted Clef-Flash adapter is retained in the code and its fixtures, but it is not a selectable provider (#0809).

toml
[model_routing]
mode = "shadow"              # off | shadow | automatic
provider = "jev"             # jev | openrouter
model = "jev-latest"         # or a pinned id such as "jev-1.13.0"
roles = ["engineer", "reviewer", "pm"]
timeoutMs = 8000             # 500–60000
humanStepCheck = false       # model check when keywords trip (default off)
humanStepSticky = true       # remember cleared flags (default on)
KeyDefaultMeaning
model_routing.modeoffoff never calls the router. shadow records a recommendation for each decision but never changes the selection. automatic applies the recommendation, within the budget allowlist and role limits.
model_routing.providerjevWhich decision backend answers: direct TypeSafe Jev or OpenRouter System One.
model_routing.modeljev-latestProvider model id.
model_routing.baseUrl—Retained so an existing [model_routing] table with the self-hosted adapter still parses. It has no UI and is not a selectable provider.
model_routing.rolesall threeWhich runs consult the router. A role left out keeps its configured agent.
model_routing.timeoutMs8000One decision call's ceiling. A timeout falls back; it never blocks the run.
model_routing.humanStepCheckfalseWhen acceptance criteria trip the keyword scan, ask the decision model whether verification truly requires a human — the keyword alone never sets the flag. Uses the same provider as routing. It only blocks a start when mode = "automatic"; in shadow it records the verdict without blocking, and when the provider is off, unavailable, or fails, the keyword check runs instead.
model_routing.humanStepStickytrueWhen a human clears a needs-human-step flag, record a fingerprint of the acceptance criteria so later rescans skip the flag until the criteria materially change.

Choosing a provider ​

TypeSafe Jev (hosted, direct). Jev is served by POST https://api.typesafe.ai/v1/systemone with Authorization: Bearer <key>. Get a key from the TypeSafe console, then put it through the Model providers tab on Agents (or the Model routing tab's masked save control). Keys land in the gitignored .env under the fixed name TYPESAFE_API_KEY — never in repoos.toml.

Set model_routing.provider = "jev".

OpenRouter (Jev / TypeSafe via System One). OpenRouter exposes the same System One contract at POST https://openrouter.ai/api/v1/systemone, billed to your OpenRouter account. Use this when you already have REPOOS_OPENROUTER_API_KEY for the Agents page but not a separate TypeSafe key. Bare ids such as jev-latest and jev-1.14 are mapped in the OpenRouter adapter to ~typesafe/jev-latest and typesafe/jev-1.14 before the request is sent; ids that already use the typesafe/ or ~typesafe/ prefix are unchanged. This is a distinct provider option in config (provider = "openrouter"), not a hidden transport inside the Jev adapter — switching between direct Jev and OpenRouter changes only endpoint and credential env, not the RepoOS routing policy.

toml
[model_routing]
provider = "openrouter"
model = "jev-latest"

Save REPOOS_OPENROUTER_API_KEY on the Model providers tab — routing reuses that key, so the Model routing tab shows no key field for it and links to Model providers instead. When the key is missing, routing reports the credential as not set and the provider as not ready.

Secret handling ​

Routing keys follow the same convention as the other provider keys: they live in the gitignored .env file or the process environment and are never written to repoos.toml, logged, or echoed back to the browser. Env var names are fixed per provider in the model-providers registry — do not add a free-text “key variable” field. See docs/api-keys.md for the agent-facing checklist.

Shadow evaluation first ​

Configure routing on the Agents page (Model routing tab) or in TOML, then start in mode = "shadow". Each eligible run records what the router would have chosen, the candidates it considered, and the provider's signals, while the run itself keeps the configured agent. The Recent decisions list on the same tab shows, per run, the role, the configuration actually used, the shadow recommendation when it differs, the response time, and the provider's token usage (or dollar cost when it reports one) — rendered from the recorded factors only. Each card expands (collapsed by default) to the question that was sent and the answer that came back. Each card's timestamp reads as a relative time ("13 hours ago"); hover or focus it for the exact local time. A fallback is written in plain words rather than as an error: an insufficient-evidence fallback says the model was not confident enough and the role default was used, with the top probability and the threshold. Compare those recommendations with real outcomes before enabling automatic.

The Test configuration button checks parsing, provider requirements, and whether the required key is set — it does not call the hosted API. Test live connection issues one real decision call when you are ready; while it runs it shows a waiting state, then a result card (green outline on success, red on failure) that opens collapsed to the summary — the outcome, the model that answered, the cost or token usage, and the chosen candidate with its probability — and expands (one click) to the full detail: the test question, the answer and each option's probability, and the response time. A failure adds a plain reason and what to check. Use it sparingly and never in automated tests.

Automatic mode, pins and limits ​

  • An explicit human pin (a task's CLI or model override) always wins; the router is not consulted for a pinned run.
  • Candidates are filtered in code, not by the model, against the budget allowlist ([[budget.allowlist]]), harness availability and reasoning support. An empty allowlist is unrestricted; a non-empty one is a hard ceiling.
  • The router cannot widen permissions or budgets. It only chooses among candidates that already pass these filters.
  • Every decision is persisted under .repoos/model-routing/decisions.json and survives a server restart. The record keeps the bounded brief that was sent and the provider's answers, so a card's details view renders exactly what was asked and returned. The decision call's token usage is recorded in the Tokens/session accounting under the routing session type (never pm).

What the decision model is sent (#0812) ​

The router used to send a short brief capped at 6,000 characters, three yes/no questions and a candidate list labelled only cli / model / reasoning. It now sends enough to decide:

  • A brief budgeted to the decision model's context window. The window is looked up from provider metadata and the brief may use a fixed share of it (DEFAULT_ROUTING_BRIEF_SHARE, 25%), converted at ~4 characters per token. When the window is unknown, the safe fixed fallback (6,000 characters) applies.
  • Per-candidate facts. Each candidate's label carries its price per 1M input and output tokens, context size, whether it is subscription or pay-as-you-go, and this project's own history for the role (runs, pass rate, average tokens, turns, cost and review passes). Unknown fields are omitted rather than shown empty, and a reasoning level appears only when the candidate actually has one.
  • Task progress. Engineer and review rounds so far, the latest review verdict, diff size, whether UI shots are involved, and time open.
  • An explicit objective. The suitability question states what "best" means: complete this kind of task well (the review passes first time) in the fewest rounds, at the lowest cost that still meets that bar.

Prices and context sizes come from a curated, dated snapshot of provider metadata (src/core/model-routing/facts.ts); project history is read from the project's own sessions database (src/server/candidate-history.ts), not RepoOS's. Each decision record logs the brief size, the budget it was given, the decision model's context window and the fact line offered for every candidate, so a decision can be explained afterwards. This does not change who may run: the budget allowlist, harness checks and human pins are still applied first.

Review handback re-evaluation ​

When review sends work back, the router compares continuing the existing engineer configuration with switching to another eligible one or escalating to the most capable. The decision uses the recorded review findings and the task's attempt history, and it is bounded — one switch, one escalation, then a human is asked. It never bypasses review, checks, approval or close-out, and it never edits a reviewed worktree.

Two rules matter most:

  • A failure classified as infrastructure (provider error, timeout, missing harness, a gate or verification that could not run, a mechanical clamp of the reviewer's verdict) is not attributed to the coding model — the configuration is kept rather than penalized.
  • Handback switches and escalations are drawn only from same-harness alternatives, because a cross-harness switch cannot resume the engineer's session id. A switch prefers a genuinely different model; the decision record always shows the configuration actually used.

Candidate filtering (library behavior) ​

Before any provider call, filterCandidates in src/core/model-routing/candidates.ts drops configurations that fail deterministic checks: budget allowlist, harness availability, reasoning support, the run's role (pm, engineer, or reviewer must appear in the harness's supportedRoles), and required context size (requiredContextTokens must not exceed the harness's maxContextTokens). The run paths supply those facts from the detection cache (harness.ts provides the static capability table), and candidate-set.ts builds the candidate universe from the allowlist (or the configured agents when the allowlist is empty), pairing each cli+model with the reasoning levels its harness supports.

Scoring and missing cost/time (library behavior) ​

The routing policy scores every candidate on the same components (suitability, task-intensity, economy). When normalized cost or time is unknown for a candidate, the policy uses a fixed neutral midpoint (ROUTING_NEUTRAL_COST_TIME, currently 0.5) rather than omitting economy terms — missing measurements do not penalize a candidate relative to one with data.

Fallbacks (library behavior; applied on the run path in #0792/#0793) ​

When the router cannot produce a trustworthy decision, the run uses the configured fallback if it is itself eligible, and otherwise stops with a clear message. On the run paths (model-routing-wiring.ts) a fallback or block never changes the agent: the run proceeds with the canonical configured/pinned agent, exactly as with routing off. The fallback applies for:

  • no eligible candidates (the run is blocked with the reasons);
  • a provider error, timeout, or an unreachable server;
  • a malformed or unsupported response;
  • insufficient evidence (provider confidence below the calibrated floor).

A failing router never silently disables review and never selects a pair the allowlist forbids.

Limitations ​

  • Provider confidence is reported by the model; it is not an established measure of real-world accuracy. The thresholds are explicit, versioned policy constants, and should be calibrated on your own runs before you rely on them.
  • Historical outcome data informs the evaluation report; it does not by itself make the router learn. Fine-tuning and a full bandit optimizer are out of scope.

Settings import/export ​

Settings → Import/Export copies settings between projects. Choose which of the five transferable groups to include — the Agent+Model allowlist, Agent+Model favorites, starred coding agents, starred themes, and remote validation — and download them as a repoos_<repo>_export_<timestamp>.toml file (saved to the browser's default Downloads folder). On the target project, drop that file onto the same tab (or choose it) to review what it contains and apply it: only the settings present in the file change, everything else stays as it is.

The allowlist and remote validation are written into the target's repoos.toml (the Hetzner provider details transfer too); the favorites and stars are browser-local and land in the target browser's storage. Per-host OS, labels and concurrency ([[remoteValidation.tailscaleHosts]] row attributes) are not transferred — only the host list is.

UI handoff verification ​

toml
uiVerification.enabled = true
uiVerification.viewportWidths = [1024, 375]
uiVerification.autoRefreshStaleOnce = true
FieldTypeDefaultCommittedEffect
uiVerification.enabledbooleantrueyesWhen enabled, tasks with a shot capture plan run browser checks at handoff (console errors, failed same-origin requests, horizontal overflow, blank captures) before review. It also blocks on missing or wrong visual evidence: a declared highlight/selector that matched nothing, a login/redirect that landed away from the declared route, or a declared assert condition (element count/text) that failed. Missing Playwright skips with a visible note.
uiVerification.autoRefreshStaleOncebooleantrueyesWhen enabled (default), a reviewer must explicitly mark stale UI evidence as the only blocker. RepoOS then refreshes it at the current HEAD once and starts one new review; if the evidence still cannot be refreshed (or the task moved on), it stays in review for a human. Set false to wait for a manual refresh and Review again.
uiVerification.viewportWidthsnumber[][1024, 375]yesViewport widths (px) used for horizontal overflow checks during handoff verification.

Edit all three keys in Settings → General. Evidence is written under .repoos/ui-verification/<task-id>.json and surfaced on the task drawer Changes tab; it records the exact captured URL, whether the declared route matched, every assertion's outcome, and the tested tree/plan identity. Each capture also carries its own console errors, failed same-origin requests, overflow and blank-capture issues, so the Changes tab can show a green check or red cross per shot: hover, focus or tap the icon for that shot's plain-words verdict and its own issues, while an issue tied to no single shot appears once under the list. The record shape therefore includes a per-capture issues array (added in #0753) alongside the handoff-wide issues — capture behaviour and gating are unchanged. Captured PNGs live under work/.attachments/<task-id>/shots/ in the main checkout (not the task worktree), which is where a reviewer looks.

Mechanical review verification ​

toml
reviewVerification.guardTestCheck = true
reviewVerification.testGlobs = ["**/*.test.ts", "**/*.test.tsx", "**/*.test.js", "**/*.test.mjs"]
FieldTypeDefaultCommittedEffect
reviewVerification.guardTestCheckbooleantrueyesKill switch for the guard negative-test check. When a check guard a project declares in [[check.steps]] changed, a test must prove bad input is rejected. The check only ever fires for guards a project actually declares, so it never blocks a repo whose plan has no built-in RepoOS guard; set false to silence it entirely.
reviewVerification.testGlobsstring[]["**/*.test.ts", "**/*.test.tsx", "**/*.test.js", "**/*.test.mjs"]yesGlobs identifying the test files counted as negative-test evidence. Set these for a Swift, Python, Go or other non-JS project (e.g. ["**/*Tests.swift"], ["**/test_*.py"], ["**/*_test.go"]).

Edit both keys in Settings → General. The check appends a ## Automated verification section to the review report and clamps a reviewer's `good to go` to needs some work on failure; the reason is recorded in the task activity note and the follow-up message to the engineer. See Review and close-out → Automated verification and docs/review-verification.md.

A command capture target ([[shot.targets]], below) has no browser page or console, so its evidence in the same file is a commandCaptures record instead: the target, command, cwd, exit status, stdout/stderr tail and the images it produced (with sizes), plus the same tested-tree identity and plan fingerprint. The handoff gate writes it for command-only tasks exactly as it writes the browser record for web ones, stale-capture detection applies the same way, and the reviewer prompt says browser verification does not apply (the console-error check is skipped, never blocking). See Command capture targets.

Repeated review classes and review timeout ​

toml
review.humanFlagAfterPasses = 5
review.timeoutMinutes = 15
FieldTypeDefaultCommittedEffect
review.humanFlagAfterPassesnumber5yesCompleted review passes on one task before the next report opens with a summary of the bug classes earlier passes kept finding, and the task gets a human-attention flag (review-human-attention). Default 5. The flag is separate from the auto-bounce review-rounds-exhausted backstop: it counts every completed pass, including manual Review again, and clears when a clean review or a new engineer run confirms progress.
review.timeoutMinutesnumber15yesMinutes one review pass may run before it is stopped. A large diff on a slow cheap model can legitimately take longer than the default; raise this when reviews of big branches keep getting cut off. Values are clamped to 1–240. A pass that hits the limit is recorded as timed out after N minutes (not "no usable report"), retried once automatically on the same reviewer, and only flagged for a human if that retry also times out.

Edit both keys in Settings → General (Automation & approval). A task can bounce between active and review many times when each pass finds one more instance of the same class; this is the signal to stop and look at the pattern rather than pay for another round. See Review and close-out → Repeated review classes.

Approval policy (opt-in auto Move to done) ​

toml
approval.enabled = false
approval.autoApprove.areas = ["api", "data", "docs", "chore"]
approval.autoApprove.types = ["chore"]
approval.autoApprove.uiAreas = ["web", "ui-app", "frontend", "mobile"]
approval.autoApprove.machineryPaths = ["src/server/", "src/core/", ".githooks/", "repoos.toml", "AGENTS.md"]
approval.autoApprove.allowP0 = false
automation.paused = false
FieldTypeDefaultCommittedEffect
approval.enabledbooleanfalseyesMaster switch. When true, tasks in review that match configured areas or types, passed the handoff gate, received a clean reviewer verdict (good to go), have no blocking bugs in the report, are not tagged human-only, and pass branch/handoff checks can Move to done without a human click. Each auto-approval is recorded in the task activity log (auto-approved by policy: …) and in the notification bell.
approval.autoApprove.areasstring[][]yesTask area values eligible for auto-approval (any match). Empty means match by type only.
approval.autoApprove.typesstring[][]yesTask type values eligible (any match). Empty means match by area only.
approval.autoApprove.uiAreasstring[]built-in UI listyesAreas treated as UI work. Tasks touching these areas are never auto-approved unless handoff screenshots succeeded (at least one capture, no shots: failed activity note). When unset, defaults to web, ui, ui-app, frontend, and mobile.
approval.autoApprove.machineryPathsstring[]built-in machinery listyesRepo-relative path prefixes that always keep a task on the human path, whatever its area or type. When unset, defaults to src/server/, src/core/, src/cli/, src/commands/, .githooks/, repoos.toml, AGENTS.md, and docs/adr/. A prefix ending in / matches the directory tree; a bare file name matches that file exactly. If RepoOS cannot read the branch's changed paths it fails closed (keeps the task human).
approval.autoApprove.allowP0booleanfalseyesWhen false (the default), a p0 task always waits for a human, no matter which area or type rule it matches. Set true only to let p0 work auto-approve.
automation.pausedbooleanfalseyesMaster kill switch for all automatic actions — policy auto-approval, CTO safe actions, and the idle-engineer nudge. Your configured policy and allowlist are kept, so turning it back off resumes them. A human's explicit action (a UI button or API call) still runs.

Edit these in Settings → General. Tag any task human-only to keep it on a human approval path regardless of policy. UI verification and console-error gates (#0680) may tighten the UI evidence rule later; until then, screenshot success is the guard.

The conditions an auto-approval must pass, in order: not human-only; in review; not needs-input; not a p0 (unless allowP0); matches an area or type; a clean good to go verdict with no blocking bugs; no lingering last_check_failure; no changed path under the machinery list; the branch exists and merges cleanly; the worktree matches its handoff snapshot (lock SHA equals HEAD); main is clean; and for UI areas, successful handoff screenshots. Any failure keeps the task for a human.

Worktrees and runtime ​

toml
[worktrees]
inheritEnv = false
FieldTypeDefaultCommittedEffect
worktrees.inheritEnvbooleanfalseyesWhen true, RepoOS symlinks the main checkout's .env into each task worktree so worktree-local build or preview commands can read project secrets.
worktrees.candidatestring(see closeOut.candidate)yesSame as closeOut.candidate when the close-out section does not set it.
worktrees.installCommandstring(empty)yesSame as closeOut.installCommand when the close-out section does not set it.
git.tuneForScalebooleanfalseyesEnables Git fsmonitor and the untracked cache to speed the git status scans the index build runs in every checkout. Existing explicitly configured core.fsmonitor / core.untrackedCache values, including false, are never overwritten. See Git status acceleration.

Git status acceleration ​

When enabled, startup from the main checkout tunes two Git settings that make repeated git status scans cheaper: core.fsmonitor (Git's built-in filesystem monitor daemon) and core.untrackedCache. Both are best-effort; Git falls back to a full scan when a daemon is unavailable.

git.tuneForScale defaults to false because every active worktree can start its own daemon, even when finished worktrees are cleaned up. Turn it on in Settings → General → Performance when faster status scans justify that cost. Only an unset key is written: if you have set core.fsmonitor or core.untrackedCache yourself, in .git/config or globally, RepoOS leaves your value alone even when the setting is on.

Preview and shot servers (REPOOS_PREVIEW_CHILD=1 or --preview-overrides) and any server rooted in a linked worktree skip the tune, because worktrees share the repository's .git/config and a preview start must not rewrite it. When a worktree is removed — review close-out, Move to done, or worktree GC — RepoOS runs git fsmonitor--daemon stop in that checkout first, so daemons do not accumulate for finished tasks. repoos doctor warns when more than eight fsmonitor daemons are running, names the CPU/memory cost, and prints the fix. Turning the RepoOS setting off does not unset a Git value it previously wrote; run git config core.fsmonitor false in the main checkout to prevent new daemons, then git fsmonitor--daemon stop in affected checkouts to stop existing ones.

inheritEnv is off by default and must be opted into deliberately. Every worktree is another place RepoOS's root .env configuration is reachable. When enabled, RepoOS creates a symlink (not a copy) at <worktree>/.env pointing to the main checkout's .env. This is legacy RepoOS process configuration; it does not select or grant a named project environment. A process running with the user's permissions can follow that link and read the file, so enable this only when that exposure is acceptable. The link is created only when all of these hold:

  • The main checkout has a .env file.
  • The worktree does not already have its own real .env.
  • The worktree's own .gitignore (or any active ignore rule) covers .env — RepoOS checks this with git check-ignore inside the worktree, not in main. If the path is not ignored, no link is made to prevent an accidental secret commit.

Runtime selection is controlled by environment variables, not repoos.toml: REPOOS_RUNTIME (auto, bun, or node) and REPOOS_BUN_PATH (an explicit Bun binary). These are the supported public runtime overrides — see Environment and secrets for the full list.

Apple-signed git on macOS (automatic) ​

On macOS, RepoOS puts a one-entry directory containing only a git symlink to Apple's /usr/bin/git first on PATH for itself and everything it spawns (engineers, repoos check, tests). Reason: an ad-hoc-signed git (Homebrew, mise and similar) is re-validated by macOS (syspolicyd/trustd) on every launch, and RepoOS and its test suites launch git hundreds of times per task. Measured on a MacBook Air, a 289-test git-heavy run took about 160 s with Homebrew git first and about 80 s with Apple's. It needs the Xcode Command Line Tools (xcode-select --install); without them nothing changes. Only git is redirected, so other Homebrew tools are untouched. Opt out with REPOOS_PREFER_APPLE_GIT=0. Enabling "Developer Tools" for your terminal in Privacy & Security did not help in the same measurement.

Agents ​

toml
maxConcurrentAgents = 5

[watchdog]
enabled = true
stalenessMs = 300000
autoTransition = true
phantomDetectMs = 180000
engineerCrashFailoverThreshold = 3
engineerCrashFailoverWindowMs = 1800000

[whisper]
provider = "none"
# apiKey = "..."
FieldTypeDefaultCommittedEffect
maxConcurrentAgentsnumberderived from CPU countyesHow many agent CLI processes (start/send/chat) may run at once; extras queue. Must be 1–16. The default is computed from this machine's CPU count and capped sensibly.
heavySlotsselect (1–8)2yesHost-wide budget for engineer builds, tests and local checks in every RepoOS checkout on this machine. Extra heavy jobs queue in the shared ~/.repoos/local-check-slots pool (localChecks.maxConcurrent follows it).
vitest.maxWorkersselect (1–16)2yesVitest worker cap for processes an engineer launches. Exports VITEST_MAX_WORKERS and REPOOS_TEST_WORKERS, and applies --maxWorkers to direct and Bun package invocations.
localChecks.maxConcurrentselect (1–8)2yesCross-process cap on local repoos check runs on this machine; extras queue with a visible position (#0836). Shares the heavy-slot pool when heavySlots is set.
localChecks.queueTimeoutMsnumber2700000 (45 min)yesHow long a queued local check may wait for a slot before failing with an actionable timeout (minimum 60000).
localChecks.staleAfterMsnumber75000yesShow when a live local check holder has no recent heartbeat after this age (minimum 15000).
localChecks.reclaimGraceMsnumber150000yesGrace after a live check holder stops reporting before reclaiming its managed process group (minimum 15000).
playwright.maxConcurrentselect (1–4)1yesShared cap on headless Playwright work (smoke, contrast audit, shots, UI verification).
playwright.workersselect (1–8)2yesPlaywright workers inside one engineer-launched run. The heavy-command shim applies --workers to playwright test and exports PLAYWRIGHT_WORKERS. Separate from playwright.maxConcurrent.
playwright.queueTimeoutMsnumber2700000 (45 min)yesHow long queued Playwright work may wait for the shared browser slot before timing out (minimum 60000).
pressureGuard.enabledbooleantrueyesWhen on, delay new local checks, browser steps and engineer starts while paging, reclaimable memory, compressor size, load or idle CPU crosses a limit. Running engineers are never killed.
pressureGuard.maxLoadPerCpunumber3yesBlock new work when the 1-minute load average per CPU exceeds this value.
pressureGuard.minAvailableMemRationumber0.08yesBlock starts when reclaimable memory falls below this fraction of total RAM.
pressureGuard.maxSwapUsedRationumber0.5yesWarn when swap in use exceeds this fraction of total swap (occupancy alone does not block — see activity rates below).
pressureGuard.maxCompressorRationumber0.35yesWarn when the macOS memory compressor holds more than this fraction of total RAM. The fraction alone does not block.
pressureGuard.maxCompressorBytesnumber8589934592yesBlock when the memory compressor holds at least this many bytes (default 8 GiB). On macOS this only warns: the system's own memory-pressure level decides, and only "critical" blocks.
pressureGuard.minIdleCpuPercentnumber5yesBlock when sampled CPU idle falls below this percentage.
pressureGuard.activityWindowSecnumber30yesRolling window for swap I/O and pageout rates from vm_stat (macOS) or /proc/vmstat (Linux).
pressureGuard.maxSwapIoBytesPerSecnumber1048576yesBlock when sustained swap in+out over the activity window exceeds this rate (bytes/s). Default 1 MiB/s.
pressureGuard.maxPageoutBytesPerSecnumber262144yesBlock when sustained pageouts over the activity window exceed this rate (bytes/s). Default 256 KiB/s.
pressureGuard.minMemoryFreePercentnumber10yesBlock when macOS memory_pressure -Q reports system-wide free memory below this percentage.
pressureGuard.maxPsiMemoryAvg10number25yesBlock when Linux /proc/pressure/memory some avg10 exceeds this value (0–100).
remoteValidation.offloadBrowserStepsbooleanfalseyesOpt-in: run browser gate steps on a runner when feasible; otherwise local under the Playwright cap.
remoteValidation.localFallbackselectserializeyesWhen remote validation falls back locally: serialize (one suite at a time), allow (use localChecks.maxConcurrent), or off (refuse fallback).
remoteValidation.healthCheckbooleantrueyesRead runner swap and available memory before leasing a host, using the pressureGuard thresholds. Pressured hosts are skipped; if every host is pressured the run queues with the reason and the exact fix instead of failing the branch's tests. Docker on macOS reads the Linux VM's numbers, not the Mac's swap.
ctoSkipHealthybooleantrueyesWhen true (the default), the CTO monitor skips its model call while the board is healthy — no stuck tasks, a fresh build and a normal process check — and skips again when the structural digest is unchanged (an idle board with nothing in active/review needing attention does not launch an agent every tick). Set false to run a full CTO pass whenever the material signal changes.
cto.actionsstring[][]yesOpt-in allowlist of bounded recovery actions the CTO may run automatically (rate limited, audited in the bell). Values: restart-stalled-agent (dead active engineer), refresh-main-install (lockfile install in main), requeue-closeout-after-env-fix (refresh main then re-queue a failed env close-out). Empty means report-only. Humans can also invoke POST /api/cto/actions/<id>. Every automatic action is recorded in the task activity log and the notification bell, and all of them stop at once when automation.paused = true.
cto.nudgeIdleMsnumber300000 (5 min)yesHow long an active engineer may show no worktree activity before the CTO sends one completion reminder. The nudge is held while an engineer process is running a known step (check, tests, build, headless browser), a remote-validation wait or review is active, a handoff gate is finalizing, an undelivered message is already queued, or machine memory pressure is above the pressureGuard thresholds. Minimum 30000; invalid values fall back to 300000.
cto.nudgeCooldownMsnumber3600000 (60 min)yesHow long the CTO waits before nudging the same task again after a completion reminder, and after recording a held-nudge reason in the activity log. Minimum 60000; invalid values fall back to 3600000.

Restart strategy for a dead engineer. restart-stalled-agent picks one of two shapes from how the previous session ended: a network stall, timeout, or interrupted turn resumes the same conversation; a real crash, an exit with no handoff, or a task the CTO has already restarted twice this episode starts a fresh conversation in the same worktree. The choice is written into the task's activity log (CTO action: restart-stalled-agent (resume|fresh) · …). The action is rate limited per task (6/hour by default) and never fires while the agent is running, the task is paused, or its worktree has recent activity. | watchdog.enabled | boolean | true | yes | Whether active-task staleness monitoring runs. | | watchdog.stalenessMs | number | 300000 (5 min) | yes | Milliseconds of silence before an active task is a candidate-stuck. Minimum 60000; smaller values are ignored. | | watchdog.autoTransition | boolean | true | yes | Whether a stuck task auto-transitions out of active — to review when its worktree holds work, else back to ready. When false, it is only flagged needsInput. | | watchdog.phantomDetectMs | number | 180000 (3 min) | yes | How long a runner entry may have no live engineer process and no live child before it is cleared and the same agent restarted. A live child (check, test, remote-validation wait) is never touched. Minimum 15000; smaller values are ignored. Automatic restarts are limited to 3 per task per hour and written to the activity log and the Debug tab. repoos start on a phantom entry clears it immediately and starts. | | watchdog.activeWithoutEngineerRecoverMs | number | 600000 (10 min) | yes | How long an active task may have no live engineer before RepoOS automatically restarts it on the next allowlisted, non-paused agent pair. Held and human-assigned tasks are never restarted. When recovery fails, the task is flagged and a notice is recorded. Minimum 60000. | | watchdog.engineerCrashFailoverThreshold | number | 3 | yes | Crashes or usage/auth failures for the same agent+model inside the failover window before RepoOS switches the task to the next unpaused [[budget.allowlist]] pair. Below it the engineer is resumed and the task stays active. Minimum 1; a crash never requests a handoff by itself. A human-set task override is never replaced — that case is surfaced for a human. | | watchdog.autoPauseOnUsageLimit | boolean | true | yes | Immediately pause an agent+model after explicit quota/HTTP 429 output, then fail the active task over even when it has a human pin. An advertised reset time is the pause expiry. RepoOS does not probe that pair while the pause is still in the future, or while the pause has no date. After the reset time, a low-token probe runs at most once per backoff interval (starting at 10 minutes and doubling up to 6 hours). A probe that is still over the limit extends the pause to the reset time in that error and stays off the Mission Control recent-failures list. A successful probe resumes the pair. A pause with no date stays paused until you resume it. These automatic pauses (reason starting out of usage since) are stored in the gitignored .repoos/budget-runtime.json, not in repoos.toml, so they do not dirty main or block close-out. A human Pause on the same agent or model wins. On startup, automatic pause fields still present in repoos.toml are copied into that file (the file wins for the same pair) and then ignored. Turn off to treat quota failures as ordinary provider failures. | | watchdog.engineerCrashFailoverWindowMs | number | 1800000 (30 min) | yes | How far back RepoOS counts engineer crashes for the failover threshold. A clean turn clears the count. Minimum 60000; smaller values are ignored. | | whisper.provider | select | none | yes | Voice-to-text provider for text areas: none, groq, or openai. | | whisper.apiKey | string | "" | exception | API key for the selected whisper provider. Accepted here, but a secret — prefer REPOOS_WHISPER_KEY in .env. |

The lifecycle roles and custom agents are stored as [[agents]] rows (name, cli, model, enabled, instructions, skills) and are managed from the Agents page. Prefer the UI over hand-editing them. See Agents for the supported CLIs and roles.

Default role prompts and adopting updates. A project with no [[agents]] rows gets the built-in reviewer, engineer, pm and CTO prompts from DEFAULT_AGENTS the first time RepoOS starts. An existing project keeps its own instructions text untouched — RepoOS never rewrites a customized role. To adopt a newer built-in default, open Settings → repoos.toml (or the Agents page's instructions field) and copy the role text from GET /api/config's agentsMeta.defaults array into your own [[agents]] row, keeping any project-specific rules you already added. Project rules such as a runtime choice, files that must not be committed, or a required dependency policy belong in AGENTS.md and that project's own role text, never in the built-in defaults.

The built-in supervisor is not exposed as a repoos.toml key.

Areas ​

toml
[[areas]]
name = "web"
description = "The main web app"

[[areas]]
name = "cli"
toml
areas = ["web", "core"]
FieldTypeDefaultCommittedEffect
areas.namestringnoneyesOne declared area name ([[areas]] name = "…"). Required on a row; unusable rows are dropped.
areas.descriptionstringnoneyesOptional one-liner shown in the area picker and given to the PM agent.
areasarray of strings[]yesFlat shorthand for declaring names without descriptions.

A task's area is the part of the product its work lands in (web, server, core, …), and a task can carry several — area: [web, core] in frontmatter, shown as one chip per area in the UI and web, core in plain text. Legacy server + ui-app values keep parsing (the + spelling is accepted forever), and any file still carrying it is rewritten to the comma form by the one-time migration at server boot.

The effective vocabulary the task drawer's area multi-select — and the PM agent's task-authoring prompt — offers is the [[areas]] names merged with every [[preview.targets]].areas value, so a repo that has only configured previews already gets sensible options. The first entry is what a New task starts on; with no vocabulary at all the field starts empty and is free text. Editing either source updates the picker live; Settings → General → "Areas" edits the declared list (descriptions are TOML-only extras). When a source shrinks — through Settings, the raw repoos.toml editor, or a direct file edit followed by a reload — tasks whose areas no longer sit in the vocabulary get an advisory log warning, never an error, because new areas typed in the picker always stay allowed. Save an area first used as free text ("add 'x' to repoos areas") in the picker to adopt it into the declared list.

With no [[areas]] rows and no preview targets, the area field is free text only — the picker degrades to its type-an-entry mode, not a blocking select.

Previews and checks ​

toml
[preview]
command = "bun run dev --port {port}"
readyPath = "/"
readyTimeoutMs = 10000

[[preview.targets]]
name = "Landing page"
areas = ["landing", "web"]
paths = ["landing/**"]
command = "bun run dev --port {port}"
cwd = "landing"

[[preview.services]]
name = "API"
command = "bun run api --port {port}"

[[preview.targets]]
name = "Full stack"
areas = ["web"]
services = ["API"]
command = "bun run web --port {port} --api {api.port}"

[preview] configures the read-only preview RepoOS starts from a task worktree when a task is in active or review.

FieldTypeDefaultCommittedEffect
preview.commandstringunsetyesDefault preview command, used when no target matches the task's area.
preview.cwdstringworktree rootyesSubdirectory of the worktree to run the default command in.
preview.readyPathstring/yesPath polled for readiness, relative to the preview URL. A missing leading slash is added.
preview.readyTimeoutMsnumber10000yesHow long to wait for the default command to answer before giving up. Raise it for a command that also builds first.
preview.pathsarray of strings[]yesRepo-relative globs (see below) declaring which files the default (main-app) preview serves for shot resolution (#0594). Test artifacts are never counted as UI evidence (#0603).
preview.targets[].namestringderived from areasyesHuman label for diagnostics and the preview picker.
preview.targets[].areasarray of strings[]yesTask area: values this target serves, matched case-insensitively.
preview.targets[].pathsarray of strings[]yesRepo-relative globs (see below). A changed file matching any glob selects this target for repoos shot, independent of the task's area. Test artifacts are never counted as UI evidence (#0603).
preview.targets[].commandstringrequiredyesCommand that boots the target. Rows without one are dropped.
preview.targets[].cwdstringworktree rootyesSubdirectory of the worktree to run the command in.
preview.targets[].readyPathstring/yesPer-target readiness path (ready_path is also accepted).
preview.targets[].readyTimeoutMsnumber10000yesPer-target readiness timeout (ready_timeout_ms is also accepted).
preview.targets[].servicesarray of strings[]yesNames of [[preview.services]] to boot before this target's main command (#0681).
preview.targets[].kindweb or nativewebyesNative targets build and launch a local app without a URL.
preview.targets[].platformmacos, ios or androidrequired for nativeyesPlatform label shown in the task drawer.
preview.targets[].build_commandstringrequired for nativeyesBuild command run in the task worktree before launch.
preview.targets[].launch_commandstringrequired for nativeyesLaunch command run after a successful build.
preview.targets[].launch_modeforeground or handoffforegroundyesDeclares what the launch command's exit means. foreground (default): the launcher's lifetime is the app's — any exit during startup is a failed launch, and any later exit (even a clean one, e.g. the user quitting a macOS open -W app) ends the preview. handoff: a one-shot launcher that exits 0 once the app has launched (e.g. a simctl boot && install && launch shell) — the preview stays running until Stop. An unknown value drops the row and repoos doctor names it.
preview.targets[].stopstringunsetyesOptional platform command run when stopping the target.
preview.targets[].devicestringunsetyesSimulator or emulator label displayed in the drawer.
preview.targets[].requiresstringunsetyesRequired tool name for install guidance.
preview.targets[].timeout_msnumber20 minutesyesNative build timeout.
preview.targets[].envtable{}yesAdditional environment variables for build and launch.
preview.services[].namestringrequiredyesLabel used to reference the service from a target and in diagnostics.
preview.services[].commandstringrequiredyesCommand that boots the service on its own OS-assigned port. Rows without one are dropped.
preview.services[].cwdstringworktree rootyesSubdirectory of the worktree to run the service in.
preview.services[].readyPathstring/yesReadiness path on the service's own URL (ready_path is also accepted).
preview.services[].readyTimeoutMsnumber20000yesPer-service readiness timeout (ready_timeout_ms is also accepted).

{port} and {host} are replaced at runtime, and the command's environment also receives PORT and HOST. Each companion service also exposes {<name>.port} / {<name>.url} (name lowercased, non-alphanumerics to _) and REPOOS_PREVIEW_<NAME>_PORT / REPOOS_PREVIEW_<NAME>_URL to every command in the preview. Services become ready before the main command starts, so a web target can proxy /api to the branch's own API instead of the primary checkout. RepoOS owns every port and lifecycle; never hardcode a port in a preview command. A task with no usable preview configuration gets an actionable "no preview configured" message rather than booting a random app. Previews are one-at-a-time and a new request evicts the previous preview. The control-plane server re-reads repoos.toml when it changes on disk (including after a merge to the primary branch), so new targets appear without restarting repoos serve.

Native targets run locally on the machine hosting the control plane. They use the same one-at-a-time slot and run their build and launch commands from the task worktree; the card shows the platform and device and has no URL. Starting one reports building (a long build shows a visible building state in the drawer) until the app is launched, then running; Stop during the build kills the build. For example, set kind = "native", platform = "ios", build_command to an xcodebuild simulator build, launch_command to xcrun simctl install plus launch, launch_mode = "handoff" (that one-shot shell exits 0 when the app is up while the simulator keeps running it) and stop to xcrun simctl terminate. Use corresponding xcodebuild/open -W commands for macOS or Gradle/adb commands for Android with the default launch_mode = "foreground" — a launcher that stays alive while the app runs (open -W, an adb shell ending in adb logcat), whose exit ends the preview (see the preview.targets[].launch_mode row above). All tool-specific behavior belongs in these configured commands. Remote runners cannot display a native app on the control-plane host's screen. See docs/previews.md for full examples and placeholder details. These targets differ from [[dev.targets]] kind = "process" because they use the task's worktree, and from [[shot.targets]] because they launch a live app instead of capturing an image.

preview.targets[].paths drives repoos shot, which picks the target to screenshot from the task's changed files rather than its up-front area:. preview.paths does the same for the default target — without it the main app was reachable only through area resolution, and a mixed diff touching the app plus (say) one docs file screenshotted just the docs. A glob uses * within one path segment, ** across segments (including none), and ? for one non-slash character — landing/** matches every changed file under landing/. When no target's globs match, repoos shot falls back to the area match (then the default command); --target overrides either way.

The dev-target counterpart of these services is [[dev.services]] (#0843): the same companion-service model — boot before the target, its own port, {<name>.port}/{<name>.url} substitution — for the things you start yourself from the Dev page rather than for task previews. See Dev target services below.

[[preview.targets]] and [[preview.services]] are deliberately TOML-only. The Settings UI is built on a flat key = value schema, which cannot express a per-row sub-field of an array of tables; there is no control for target or service rows today. Editing repoos.toml is the supported path, and repoos shot's area/target mismatch warning is what makes a stale area: visible. This is the documented exception to the "every feature setting needs a Settings control" rule, not an oversight.

Command capture targets (non-web apps) ​

A repo whose product is a native macOS app, a mobile app or a desktop app has no dev server to point at — so a [[preview.targets]] dev server plus a headless browser cannot produce evidence shots. Declare [[shot.targets]] instead: each row is a command that renders image files offscreen, which RepoOS collects as the task's shots with the same captions, stale-tree detection, Changes-tab display and gitignore rules as web shots (#0761).

toml
[[shot.targets]]
name = "macOS app"
areas = ["mac"]
paths = ["Sources/Divot/**", "Tests/DivotTests/**"]
command = "swift test --filter RenderShots"
outputGlob = ["*.png"]
requires = "xcrun"
timeoutMs = 120000

[[shot.targets]]
name = "iOS app"
areas = ["ios"]
paths = ["ios/**"]
command = "xcrun simctl io booted screenshot \"$REPOOS_SHOT_DIR/home.png\""
requires = "xcrun"
FieldTypeDefaultCommittedEffect
shot.targets[].namestringderived from areasyesTarget name, matched by declared ## Shots entries and --target.
shot.targets[].areasarray of strings[]yesTask area: values this target serves, matched case-insensitively.
shot.targets[].pathsarray of strings[]yesRepo-relative globs. A changed file matching any glob selects this target for the automatic handoff capture and repoos shot. Test artifacts are never counted (#0603).
shot.targets[].commandstringrequiredyesShell command that renders the images. Rows without one are dropped.
shot.targets[].cwdstringworktree rootyesSubdirectory of the worktree to run the command in.
shot.targets[].outputGlobarray of strings["*"]yesGlobs selecting the produced images, relative to REPOOS_SHOT_DIR (output_glob is also accepted). ** spans directories.
shot.targets[].requiresstringunsetyesA binary that must exist on PATH. A missing binary records a visible shots: skipped note — never a failed handoff.
shot.targets[].timeoutMsnumber120000yesCapture timeout. On expiry the command is killed and the tail of its stdout/stderr is recorded as a visible note (timeout_ms is also accepted).
shot.maxAutoShotsnumber12yesCap on how many images an automatic (undeclared) command capture attaches per task. A command's outputGlob can match dozens of files; past this many the extra ones are dropped and the task notes N more not shown. Declared ## Shots entries are never capped.

The command runs in the task's worktree. RepoOS creates a fresh, gitignored directory and passes its absolute path to the command as REPOOS_SHOT_DIR (and the worktree root as REPOOS_WORKTREE); every image the command writes there and matching outputGlob is collected. RepoOS never passes a tracked path, so a command that ignores the variable writes nothing RepoOS collects. A command problem never fails a handoff: stdout/stderr is recorded as a shots: failed — <tail> / shots: skipped — <reason> activity note and the handoff proceeds. A command that exits non-zero but still wrote images has them collected anyway, with a visible warning naming the exit code — so a partial failure still produces shots and is always recorded, never silently.

At handoff RepoOS also writes the capture's provenance to .repoos/ui-verification/<task-id>.json (the same evidence file web captures use): the target, command, cwd, exit status, stdout/stderr tail, each image's size, and the tested tree identity. A reviewer reads that record and the PNGs instead of a browser console — a command-target task is never blocked on a console log it cannot produce.

Capturing only what changed (#0769). A command target's outputGlob often matches a whole fixed snapshot set (the bar plus every popover), so an automatic capture attaches dozens of images and the one that shows the change may not be among them. A ## Shots entry can hand the selection to the command instead:

  • env — an object of extra environment variables for the command, e.g. {"DIVOT_SNAPSHOT_ONLY": "worldclock"}. Keys must be plain identifiers; a value is passed through the environment only and is never interpolated into the command string, so a declared value cannot be shell-evaluated.
  • args — extra tokens appended to the command line, each shell-quoted as data.
  • glob — a glob narrowing which produced files become this shot, relative to REPOOS_SHOT_DIR, e.g. "*worldclock*.png".

Several entries may name the same target, each with its own env/glob and its own label; each runs the command and its results are listed under that label. Identical entries collapse as before. env/args/glob are rejected for a web preview target (they only make sense for a command) — the CLI and the write paths report that as an error rather than silently dropping them.

json
[
  { "target": "macOS app", "label": "World clock popover",
    "env": { "DIVOT_SNAPSHOT_ONLY": "worldclock" }, "glob": "*worldclock*.png" },
  { "target": "macOS app", "label": "Battery popover",
    "env": { "DIVOT_SNAPSHOT_ONLY": "battery" }, "glob": "*battery*.png" }
]

With the project's command honoring the filter variable — e.g. command = "swift test --filter RenderShots" where the test reads DIVOT_SNAPSHOT_ONLY and renders only that view — each entry captures just its one image. The same selection is available ad hoc from the CLI: repoos shot --target "macOS app" --env DIVOT_SNAPSHOT_ONLY=worldclock --glob '*worldclock*.png'.

Web preview targets keep working unchanged, and a repo may declare both: a diff that touches a web target's paths is shot from the browser, and a diff that touches a command target's paths runs that command. Declare a shot for a non-web target in the task's ## Shots section exactly like a web one ({"target": "macOS app", "label": "Menu bar open"} — no route); page-only fields (route, selector, highlight, steps) are ignored for a command target. repoos shot --target "macOS app" works from the CLI too.

A short recipe for the common platforms, and how declarations and skip messages behave, are in Previews and shots.

[[shot.targets]] is deliberately TOML-only, for the same reason as [[preview.targets]]: an array of tables cannot be expressed by the flat Settings schema. The Settings UI lists the shot.targets key so search points you at this section, but editing repoos.toml is the supported path.

Preview-only overrides ​

A [preview.<base key>] table deep-merges over the base configuration only when a preview starts, so a local preview can run with deliberately different settings without weakening normal startup:

toml
[auth]
enabled = true          # normal `repoos serve` still requires login

[preview.auth]
enabled = false         # local previews skip the OTP/login step

The dotted key here is preview.auth.enabled. The effective configuration a preview boots with is resolved in this order, later winning:

  1. built-in defaults,
  2. the base repoos.toml,
  3. the [preview.*] overlay,
  4. explicit command-line flags (--port, --host).

Because the overlay deep-merges, an override changes only the keys it names: [preview.auth] enabled = false leaves auth.sessionMaxAge, auth.bootstrapAdmin, and every other [auth] setting at their base values. Only keys the parser supports can be overridden; an unknown override is ignored with a warning rather than silently accepted as a typo.

Overrides apply only to the preview runtime — a managed preview child (REPOOS_PREVIEW_CHILD=1, set by the preview manager) and the UI-test preview harness. A normal repoos serve, repoos check, or any other command reads the base configuration and ignores [preview.*] entirely, so this is a local preview/UI-test convenience that never alters normal or production-like startup.

Supported commands and the escape hatch:

  • repoos serve applies the overlay automatically when it is a preview child. Pass --preview-overrides to force it on for a manual UI-test preview, or --no-preview-overrides to run a preview against the base configuration.
  • Managed task previews (the Preview button, and the ::repoos-preview-request:: agent signal) always apply the overlay.

Preview startup reports the effective override keys it applied — in the server log, the preview status, and the task transcript. When an override disables auth, the listener stays on 127.0.0.1 unless --host was passed explicitly, so an auth-less local preview is never accidentally exposed beyond loopback.

[preview.auth] is a preview override, not a preview target key such as preview.command or preview.targets[]. Keep target keys under [preview] itself.

[check] ​

repoos check runs a plan your repo declares, so the same gate works for any stack (Go, Gradle/Android, Rust, JavaScript, or a mix):

toml
[check]
version = 1
uiStylesheet = "src/styles.css"
backdropToken = "--bg"
gradientTokens = ["--btn-primary-bg"]

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

[[check.themeScopes]]
selector = ":root"
name = "dark"
inherits = ["dark"]

[[check.contrastPairs]]
fg = "--txt"
bg = "--bg"

A step runs command, or a built-in kind (see Checks before merge for the kinds and their skip conditions). With no [[check.steps]] at all, the gate falls back to the legacy keys below, then to inference from repo markers — and a repo with no recognisable plan fails rather than reporting green. ([checks] is accepted as an alias for [check].)

FieldTypeDefaultCommittedEffect
check.versionnumber1yesCheck-plan schema version.
check.defaultProfilestringdefaultyesProfile used when --profile isn't passed.
check.stepsarray of tablesunsetyesThe plan: one row per gate step, run in order. Fields below.
check.steps.namestringderived from kindyesIdentifies the step in output and in dependsOn.
check.steps.commandstringunsetyesShell command to run.
check.steps.kindselectunsetyesBuilt-in guard instead of a command.
check.steps.cwdstringrepo rootyesRepo-relative directory to run in.
check.steps.timeoutMsnumber600000yesKill the step after this long.
check.steps.requiredbooleantrueyesfalse makes a failure advisory instead of gating.
check.steps.profilesarray of stringsall profilesyesProfiles that include this step. ["full"] keeps it out of a routine run; close-out runs --profile full.
check.steps.whenChangedarray of stringsalways runsyesPath globs; in changed-path mode the step runs only when one matches.
check.steps.requiresarray of stringsunsetyesBinaries that must be on PATH; a missing one fails a required step with install advice.
check.steps.dependsOnarray of stringsunsetyesSkip this step when a named earlier required step failed.
check.uiSmokestringunsetyesCommand for the UI smoke step. Overrides a smoke script in package.json; with neither, the step skips.
check.isolationRunsnumber3yesAfter a failed tests step names a few test files, re-run each alone this many times and record passed N/N alone / failed N/N alone on the run. Informational only — it never turns a failed run green. 0 disables it. Interactive/CLI and pre-review runs only; close-out and release never use it.
check.uiStylesheetstringunsetyesRepo-relative stylesheet the CSS-layering and theme-contrast guards read. With it absent, both skip.
check.themeScopesarray of tablesunsetyesTheme blocks for the contrast guard. Each row is documented just below.
check.themeScopes.selectorstringrequiredyesCSS selector that opens the block, e.g. :root[data-ui-theme="clear"].
check.themeScopes.namestringrequiredyesVariant name used in failure messages, e.g. clear-dark.
check.themeScopes.inheritsarray of strings[name]yesNames of earlier scopes whose declarations this scope inherits, in order (later wins).
check.contrastPairsarray of tablesunsetyesForeground/background token pairs checked for contrast.
check.contrastPairs.fgstringrequiredyesForeground token, e.g. --txt.
check.contrastPairs.bgstringrequiredyesBackground token, e.g. --bg.
check.gradientTokensarray of stringsunsetyesTokens consumed as background-image, which must resolve to a gradient.
check.backdropTokenstringunsetyesToken to composite semi-transparent colors over before measuring luminance.
check.bareRequireDirsarray of stringstsconfig includeyesSource roots the bare-require() guard scans. Absent, it uses the repo's tsconfig include/files minus exclude.
check.bareRequireExcludesarray of stringstsconfig excludeyesPaths or globs the bare-require() guard skips. Consulted only with bareRequireDirs.
check.hardcodedColorDirsarray of stringsunsetyesSource roots the hardcoded-colors guard scans for #hex / rgba(255,…) literals in component <style> blocks. Stylesheets are deliberately out of scope — their literals are theme tokens, checked by theme-contrast.
check.contrastExemptsarray of tablesunsetyesSelectors the rendered-contrast audit allows below the WCAG floor, each with a reason. Rows missing either half are dropped.
check.alwaysRunarray of stringsfive safety suitesyesTest-file globs unioned with changed-path tests, including default close-out. Defaults cover close-out, handoff, git-worker, server boot and task frontmatter parsing. Settings → Advanced exposes Always-run test suites; a tests step can override the list with alwaysRun. Release runs every test.
check.fullSuitePathsarray of stringsunsetyesRepo-relative path prefixes that force the full close-out suite even when closeOut.gate would reuse or scope it (#0724) — machinery the changed-path scoping cannot reason about, e.g. a CI pipeline or the check engine's own config.
check.contrastExempts.selectorstringrequiredyesCSS selector to exempt (matched against the text's element and its ancestors), e.g. .code-pane.
check.contrastExempts.reasonstringrequiredyesWhy the block is intentionally off-contrast — this is what makes the exemption reviewable.

See Checks before merge for what each step does and when it runs.

Authentication ​

toml
[auth]
enabled = false
sessionMaxAge = 2592000
bootstrapAdmin = "you@example.com"

[auth.emailProvider]
type = "resend"
fromAddress = "otp@send.example.com"

[auth.google]
clientId = "your-client-id.apps.googleusercontent.com"
FieldTypeDefaultCommittedEffect
auth.enabledbooleanfalseyesWhether login is required. Takes effect on the next repoos serve.
auth.sessionMaxAgenumber2592000 (30 days)yesSession lifetime. Values under 300 are read as days; values of 300 or more as seconds.
auth.bootstrapAdminstringunsetyesAdmin email used to claim the first account on a fresh install. Cleared after bootstrap.
auth.emailProvider.typeselectunsetyesEmail OTP provider. The only supported value today is resend.
auth.emailProvider.fromAddressstringunsetyesSender address for email OTPs. Not sensitive.
auth.emailProvider.fromNamestringunsetyesOptional sender display name; defaults to RepoOS at <repo>.
auth.google.clientIdstringunsetyesOptional Google OAuth client ID. Not sensitive.

Auth secrets belong in .env, never in committed config — the Settings API refuses to write them to repoos.toml: REPOOS_RESEND_API_KEY, REPOOS_GOOGLE_CLIENT_SECRET, REPOOS_AUTH_SESSION_SECRET, and the local-only REPOOS_AUTH_DEV_BACKDOOR_CODE. See Authentication for setup and Environment and secrets for the secret contract.

Releases, deployments, and distribution ​

Release configuration ​

toml
[release]
enabled = true
provider = "git-tag"
branch = "main"
versionFile = "package.json"
tagPrefix = "v"
remote = "origin"
repository = "owner/repo"
workflow = ".github/workflows/release.yml"

The [release] block is dormant — and the Releases UI hidden — unless release.enabled = true. RepoOS only reads the other release.* keys once it sees a boolean enabled.

FieldTypeDefaultCommittedEffect
release.enabledbooleanfalseyesTurns the Releases UI and API on.
release.providerselectgit-tagyesRelease provider. The only supported value today is git-tag.
release.namestringunsetyesOptional display name for the release surface.
release.branchstringmainyesBranch a release must be cut from.
release.versionFilestringunsetyesProject manifest holding the committed semantic version.
release.tagPrefixstringvyesPrefix prepended to the version for the git tag.
release.remotestringoriginyesGit remote that receives the annotated tag.
release.repositorystringunsetyesOptional owner/repo, used only to link to the resulting release.
release.workflowstringunsetyesOptional workflow path shown as release context. RepoOS does not execute it.

Deployment rows ​

toml
[[deployments]]
name = "Dashboard (prod)"
service = "Dashboard"
branch = "prod"
provider = "cloudflare-workers"
url = "https://app.example.com"
dashboard_url = "https://dash.cloudflare.com/…"
subdir = "app"

Each [[deployments]] row models one service-and-branch pair and turns the Deployments nav item and API on. A row missing name or branch is dropped.

FieldTypeDefaultCommittedEffect
deployments.namestringrequiredyesHuman label, e.g. Dashboard (prod).
deployments.servicestringnameyesGroups rows of the same service across branches into one row with a column per branch.
deployments.branchstringrequiredyesBranch whose pushes deploy this target.
deployments.providerstringunsetyesProvider label, e.g. cloudflare-workers. Informational only.
deployments.urlstringunsetyesThe live target, rendered as the row's primary link.
deployments.dashboard_urlstringunsetyesOptional provider-dashboard URL. Note the snake_case spelling.
deployments.subdirstringunsetyesRepo subdirectory this service lives in, scoping the freshness lookup so an unrelated push doesn't read as a deploy.

Distribution destinations ​

toml
[[distribution]]
name = "npm"
kind = "npm"
package = "@scope/package"
url = "https://www.npmjs.com/package/@scope/package"
install = ["npm install -g @scope/package"]

A release can list one or more install destinations, shown in the Releases page's Published to section. name is required; other rows are validated and dropped rather than poisoning the section.

FieldTypeDefaultCommittedEffect
distribution.namestringrequiredyesHuman channel name, e.g. npm or GitHub Releases.
distribution.kindselectunsetyesPublic version lookup: npm, homebrew, github-release, or custom. An unrecognized value means no automatic check.
distribution.urlstringunsetyesSource link for the channel. Must start with http(s)://; may contain {tag} or {version}.
distribution.packagestringunsetyesPackage/formula identifier, used as the lookup key for kind = "npm".
distribution.repositorystringunsetyesowner/repo for kind = "github-release".
distribution.versionUrlstringunsetyesExplicit URL to fetch the published version from. Required for homebrew and custom.
distribution.versionRegexstringunsetyesRegex with one capture group used to read the version. Must compile; invalid patterns are dropped.
distribution.installarray of stringsunsetyesInstall commands, each independently copyable.

See Deployments and releases for how these render.

Stories ​

toml
[stories]
enabled = true
FieldTypeDefaultCommittedEffect
stories.enabledbooleantrueyesShows the Stories page, its navigation item (between Work and Checks), and the task drawer's Story field. On by default; set false to hide all three.
stories.excerptBytesnumber4096yesHow many bytes of a story's definition the engineer and reviewer prompts include as shared background (see What agents see). Clamped to 512–65536.

Story definition files live under storiesDir (default stories, a top-level repo-relative layout key — see Layout and repository paths). Existing repos need no migration; moving the directory means moving the files and setting the key together, with a server restart.

With stories enabled, the task drawer shows Story next to Area on the top row of the details form instead of Assigned to; assignee stays in task frontmatter and remains writable from the CLI (repoos update <id> --assigned-to).

Stories are an optional grouping over tasks: a delivery slice that spans several technical areas and owners. You can register a story up front with a markdown file under stories/ (from the Stories page New story flow, or by adding a file in git), and/or tag tasks with a matching story: value in frontmatter — from the Story field in the task drawer or in the New task panel, or with repoos new "…" --story "Project updates email" / repoos update <id> --story "Project updates email". Task tags drive counts, progress, and completion; definition files add name and description before any task exists. A story has no worktree, agent, branch, or status of its own. The Stories page merges registered definitions with tagged tasks.

New task in a story panel's Tasks tab opens the normal New task panel with that story already selected — and shown, so you can change or clear it — and lands you back on the story once the task exists. The button is there even when the story has no tasks yet.

New story saves and commits the story file as soon as you submit, so you can leave the pane and stories/ never leaves main with uncommitted changes. The PM agent then works in the background: it names the story (unless you gave it a name), writes up the scope, and tags any existing untagged tasks that belong to it. The story shows PM is working on the Stories page until the agent finishes. If the agent fails, the story stays as you wrote it and the reason appears in a notification.

Verbatim stories (no PM) ​

An agent or script that already has the finished story does not want the PM to rewrite it or retag tasks. Use the CLI or the API instead of the UI flow:

bash
repoos story new "Project updates email" --body "Scope, outcomes, non-goals."
repoos story update 0007 --body "Rewritten scope."   # keeps the stable number

POST /api/stories { name?, body } and PATCH /api/stories/:key { name?, body? } do the same over HTTP, and POST /api/stories/freeform { …, pm: false } is the freeform route with the flesh-out — and the task tagging — switched off. All of these write and commit the definition exactly as given, keep the story's number across a rename, and start no agent. The UI's New story keeps the PM flesh-out by default. See repoos story.

A story counts as complete only when every one of its tasks is done, and there is no manual completion control. The Stories page header has a Hide Completed Stories toggle for when finished slices clutter the list: it is off by default (every story shows, exactly as before the toggle existed), and turning it on hides only the stories whose tasks are all done. The choice is remembered per browser, like the sort order. A story that is registered but still has no tasks is not complete, so it stays visible.

Story names are whitespace-normalized and matched case-insensitively, so Project updates email and project updates email group together under one stable display name. Clearing the field removes the task from every story.

What agents see ​

When a tagged task with a registered story is picked up, the engineer and the reviewer both get a Story context block in their prompt: the story's number and title, the path to its definition file, the ids, titles and statuses of the other tasks in the story, and the first stories.excerptBytes bytes of the definition. The story file is named so the agent can read the rest itself.

This means the shared background you write into a story — why the work exists, decisions already made, evidence — reaches the agent doing the work, not only the humans reading the board. The excerpt is bounded so a long field report does not ship into every turn; raise stories.excerptBytes if your stories carry more reference material than the default.

The block is omitted when a task carries no story tag, or when its tag names a story with no definition file (a tag-only story has nothing to excerpt). The task drawer's transcript records a line saying the story context was included and the excerpt size, so a review can see what the agent was given.

Every registered story gets a number — a zero-padded four-digit value like #0007, the story counterpart to a task's #0042 and an input's #0001. It appears in the same place on both the Stories page and the story panel, and clicking it copies a link straight to that story, the same as clicking a task's or input's number.

The number is stable for the life of the story: it is assigned once, is never changed, and survives the PM agent renaming the story and its file, so links keep working through a rename — which a name-based link would not. Deleting the highest-numbered story does free that number for the next one created.

/stories?story=0007 opens that story's panel. The story's name also works, as does ?story=new to open the New story form.

A story that exists only as a task tag — tagged on a task, with no file under stories/ — has no number and no link. There is no file to hold a stable one, so there is nothing to point a link at. Register the story to get both. Registering one later gives it a number, which also starts a fresh PM conversation for it.

The story PM tab ​

A story panel has a PM tab, right after the story body, exactly like a task panel's. Ask the PM to break the story down into tasks, retag or update one, or explain what is blocking it. It reads and writes the same tasks the story already holds, and the conversation is per story and per user.

The PM tab is the agent's working surface for a story, not a read-only view: it uses the same repoos CLI commands the task panel's PM tab does, so the PM can create and update tasks tagged with this story. It is the same conversation component, so the two panels behave identically.

A story is not an area ​

area describes where work lands — the part of the product or codebase a task touches (web, core, cli, api), and it drives preview-target selection. A story describes what outcome the work serves, and it can span any number of areas. The project-updates email list, for example, can be one story whose tasks are individually tagged neon, landing, and ops areas. Use an area when routing work to the right code; use a story when you want to see whether a whole customer-visible outcome is ready.

With enabled missing, false, or malformed, nothing changes: no nav item, no route entry point, no Story field in the drawer, and no extra API work.

Attachment storage ​

toml
[storage]
provider = "local"
FieldTypeDefaultCommittedEffect
storage.providerlocal | neonlocalyesWhere attachment files (task and input screenshots) are stored. local keeps them in gitignored .attachments/ folders on this machine. neon (Neon Object Storage) is opt-in and needs credentials before it can be used; until it is configured, Settings shows it as unavailable with an explanation and RepoOS falls back to local. Changing it requires a server restart.

The Settings page exposes this key as "Attachment storage".

Neon Object Storage credentials ​

The [storage] section only chooses the provider; it never carries a secret. The Neon credential is supplied through the environment, exactly like the auth and model-provider keys, so no access key ever reaches repoos.toml or git. Set these in .env (gitignored) or a machine secret store:

VariablePurpose
REPOOS_NEON_STORAGE_ENDPOINTThe branch's S3 endpoint URL, e.g. https://br-….storage-c-2.us-east-2.aws.neon.tech.
REPOOS_NEON_STORAGE_REGIONThe branch's S3 region, e.g. us-east-2.
REPOOS_NEON_STORAGE_BUCKETThe bucket objects are stored in.
REPOOS_NEON_STORAGE_ACCESS_KEY_IDThe storage credential's S3 access key id (token_id).
REPOOS_NEON_STORAGE_SECRET_ACCESS_KEYThe storage credential's S3 secret access key.
REPOOS_NEON_STORAGE_PREFIXOptional key prefix isolating this instance's objects.

The endpoint, region and bucket come from the branch's storage state in the Neon Console or GET /projects/{project}/branches/{branch}/storage; the access key and secret come from a storage-scoped credential (neon credentials create --scope storage:read --scope storage:write). Full verified details, limits and pricing: docs/neon-object-storage.md. Until every required variable is set, neon reports itself unavailable and attachments continue to use local storage.

When neon is configured and in effect, attachment bytes are still written to the local gitignored .attachments/ tree first. If Neon is unreachable, reads serve the local original (when it exists), embedded task references keep working, and the UI labels the attachment upload pending until a verified cloud copy exists — never as available on faith. Local originals are not deleted by outage or provider-mode changes; lifecycle rules apply only after verified upload (see the cloud attachment storage story).

Retrieval: viewing or downloading an attachment always requires the RepoOS server that owns the repository (or the local file on disk). Cloud storage is durability for those bytes, not remote access from another machine without that server.

Notifications ​

toml
ntfyEnabled = false
ntfyTopic = ""
ntfyBaseUrl = "https://ntfy.sh"
FieldTypeDefaultCommittedEffect
ntfyEnabledbooleanfalseyesWhether RepoOS publishes task lifecycle events to an ntfy topic.
ntfyTopicstring""yesTopic name. Empty means nothing is published.
ntfyBaseUrlstringhttps://ntfy.shyesBase URL for a self-hosted ntfy server. The NTFY_BASE_URL environment variable overrides it.

Telegram ​

toml
[telegram]
enabled = false
provisioningUrl = ""
FieldTypeDefaultCommittedEffect
telegram.enabledbooleanfalseyesMaster switch for the Telegram integration (connection surfaces, notifications, commands). While off, connection-management API calls are refused and any armed polling loop pauses its Telegram traffic — no Telegram surface is active. The bot itself is connected from the Settings connection panel by an administrator — a project bot token pasted server-side (Bring Your Own Bot Token) or, once deployed, managed provisioning. The token is stored encrypted on this machine; it never appears in this file or in any browser response. See Telegram.
telegram.provisioningUrlstring""yesBase URL of the official managed-provisioning service (#0559). Empty means managed provisioning is "not configured" and is reported as such; Bring Your Own Bot Token works without it. This key is deliberately TOML-only (no Settings control) — see the exception note below.

Deliberate exception: telegram.provisioningUrl is advanced/internal configuration and gets no Settings control. A URL for a service that a repo operator will deploy separately (#0559) sits next to a REPOOS_TELEGRAM_PROVISIONING_KEY environment key, not a toggle, and a misconfigured URL would make a half-configured hosted dependency look like a settled feature. BYO needs nothing here.

Tunnels ​

toml
[tunnel]
enabled = false
provider = "cloudflare"
name = "repoos-local"
domain = ""
tunnel_id = "<your-tunnel-uuid>"

The [tunnel] block is the committed record of what this repo publishes through Cloudflare Tunnel. It is managed by repoos tunnel — use the CLI rather than editing it by hand. See Tunnels.

The Settings page surfaces the master switch as the flat tunnelEnabled field; in repoos.toml it is [tunnel] enabled.

FieldTypeDefaultCommittedEffect
tunnel.enabledbooleanfalseyesUI opt-in only. Disabling never deletes configuration or stops cloudflared.
tunnel.providerstringcloudflareyesTunnel provider. Only Cloudflare is supported.
tunnel.namestringrepoos-localyesMachine-local tunnel name (one tunnel per machine).
tunnel.domainstring""yesBase domain used to infer hostnames.
tunnel.tunnel_idstring""yesThe cloudflared tunnel UUID. Note the snake_case spelling (tunnel_id), which is what repoos tunnel reads and writes.
tunnel.apps.<name>table{}yesOne published app per key: hostname, service, an access email allowlist, and an optional noAccess flag. Written by repoos tunnel create.

The CLOUDFLARE_API_TOKEN credential is environment-only and never belongs in repoos.toml.

Remote validation ​

toml
[remoteValidation]
enabled = false
serverType = "cax31"
location = "hil"
snapshotId = ""
sshKeyName = ""
idleShutdownMinutes = 8
maxServerLifetimeMinutes = 120
fallbackToLocal = false
useForReleases = false

Remote validation is opt-in and off by default. It runs the expensive half of the close-out gate (build + tests) on a disposable Hetzner VM instead of your machine. Enabling it sends repo contents to a third-party host.

The automatic handoff screenshots, UI verification, and repoos shot's web captures use the separate browser capture policy below. repoos verify refreshes through the server, so it uses the same policy; only the interactive Preview stays on this machine. Remote modes require remote validation and a runner image with Bun, Playwright, WebKit, and UI fonts installed. runner-available uses local capture when a capable runner is unavailable; runner-only reports that failure instead of using the laptop. A repoos shot request the remote single-shot path cannot reproduce — an arbitrary absolute URL, or an explicit --viewport, --wait, or --full-page — stays local with a visible note (runner-only reports the structured error instead). The initial default remains local until the runner image and artifact round-trip have passed a real end-to-end proof.

KeyTypeDefaultRestartDescription
remoteValidation.browserCaptureModeselectlocalyesChoose where task screenshots, UI verification, and repoos shot's web captures run: local, runner-available (runner when available, then local with a note), or runner-only. Remote modes require remoteValidation.enabled and a runner that provides Playwright and WebKit. An absolute URL or explicit --viewport/--wait/--full-page stays local.
FieldTypeDefaultCommittedEffect
remoteValidation.enabledbooleanfalseyesMaster switch for the remote runner.
remoteValidation.providerstringhetzneryesRunner backend: hetzner (disposable cloud VM) or tailscale (persistent tailnet machine).
remoteValidation.tailscaleHoststringunsetyesTailscale hostname or 100.x.x.x IP of the runner machine — single-host shorthand for the pool (Tailscale provider only).
remoteValidation.tailscaleHosts.engineersstring (TOML row field)unsetyesPer-host on [[remoteValidation.tailscaleHosts]]: engineers = ["opencode", "codex", "cursor"] — CLIs that must be installed and signed in. Checked with repoos runners check and shown on the Runners tab.
remoteValidation.tailscaleHostsarrayunsetyesTailnet host pool (#0521): jobs dispatch to a host with the fewest active runs, queue only when every eligible host is at its per-host limit, and use this list's top-to-bottom order to break ties. Edit as a comma-separated host list in Settings → Remote validation or reorder plain-list hosts in Checks → Remote runners; saves update the live dispatcher without restarting. Rich [[remoteValidation.tailscaleHosts]] rows (host, plus optional user, os, labels, maxConcurrent, engineers) remain editable in repoos.toml and are read-only in the Remote runners tab.
remoteValidation.tailscaleUserstringrootyesSSH user on the tailscale host (Tailscale provider only).
remoteValidation.containerImagestringrepoos-ciyesDocker image to run the gate in (Tailscale provider only).
remoteValidation.serverTypestringcax31yesHetzner server type. Must match the architecture the snapshot was built on.
remoteValidation.locationstringhilyesHetzner location slug.
remoteValidation.snapshotIdstringunsetyesID or name of the prebuilt Hetzner snapshot the runner boots from.
remoteValidation.sshKeyNamestringunsetyesName of the SSH key registered in the Hetzner project.
remoteValidation.idleShutdownMinutesnumber8yesHow long a warm server stays alive after a job so queued jobs reuse it.
remoteValidation.maxServerLifetimeMinutesnumber120yesHard cost stop-loss: any runner older than this is force-deleted. Minimum 10.
remoteValidation.maxConcurrentselect (1–8)1yesHow many remote validation runs may execute at once per host; extra runs wait in a FIFO queue. Two full test suites on one machine cause load-induced timeouts that show up as a failed gate. Covers handoff, close-out and release in the server; a host-side lock extends the same limit to standalone repoos check runs.
remoteValidation.hangIdleMinutesnumber5yesMinutes a remote run's output may sit unchanged while its host is idle (load per CPU below 0.5) before the run is treated as hung: its container is killed and the run retried once on another host. Raise only for suites that legitimately go quiet longer than that; too high and a real hang wastes the host.
remoteValidation.fallbackToLocalbooleanfalseyesWhen the runner is unreachable, run the full gate locally instead of keeping the task in review for retry.
remoteValidation.useForReleasesbooleanfalseyesAlso validate release cuts on the runner. Off by default because a release is watched live.
remoteValidation.retryOtherHostsbooleantrue when 2+ hosts configured, else falseyesWhen a transient failure (timeout, ssh drop, host overload) occurs on one host with the tailscale provider, retry the run on another healthy, free host before applying fallbackToLocal. A non-transient result (red gate, config error) never retries. Default true when at least two hosts are configured (tailscaleHost + tailscaleHosts or two tailscaleHosts entries); false otherwise. Only applies to the tailscale provider.
remoteValidation.bundleUploadBudgetSecsnumber180yesWall-clock cap for one candidate bundle upload attempt during close-out. A stalled or slow transfer is aborted and retried on another runner (or falls back locally) instead of hanging until SSH fails.
remoteValidation.bundleUploadStallSecsnumber90yesAbort an upload when no bytes have been written for this long. Catches wedged hosts before a multi-minute broken pipe.
remoteValidation.linkThroughputFloorBpsnumber204800yesMinimum measured payload throughput in bytes per second. SSH setup/RTT is measured separately and is not counted against throughput.
remoteValidation.linkProbeBytesnumber1048576yesPayload size in bytes for runner link measurement. Minimum 65536; default is 1 MiB.
remoteValidation.forceLocalOnMeteredLinkbooleanfalseyesSkip uploading the candidate bundle and run the full gate locally when you are on a slow uplink or metered hotspot. Auto-detect also runs when link probing shows a long upload.
remoteValidation.engineerSelfCheckRemotebooleantrue when remote validation is enabledyesManaged engineers run install + build + tests on the runner during repoos check (format/lint stay local). Handoff reuses a green remote pass at the same commit. Turn off to run the full gate on the laptop again.

The HETZNER_API_TOKEN and REPOOS_REMOTE_SSH_KEY credentials are environment-only.

Tailscale host pool ​

With the tailscale provider you can configure a pool of machines. Jobs dispatch to the host with the fewest active runs; they queue only when every eligible host is busy. Equal-load hosts go to the one that has run least recently (LRU), so sequential checks rotate across the pool; configured order is the final tie-break, so a fresh pool starts with the top host. Use the up/down controls in Checks → Remote runners to reorder a plain string pool; the change applies immediately to new runs and does not affect in-flight runs. That page also shows per-host load averages, CPU count, memory use/total, free disk in the remote work area, and when each read-only SSH sample was taken. Samples run while the tab is open, at most every 15 seconds, and time out after five seconds; a failed or unreachable host reports unavailable stats. There are two authoring forms — pick one per config:

Plain list (also editable in Settings → Remote validation → "Host pool"). Saving in Settings updates the running dispatcher immediately without a restart. Each entry is a hostname or a user@host string:

toml
[remoteValidation]
provider = "tailscale"
tailscaleHosts = ["nick@bee", "nick@thinkpad", "peckjachowski@mini"]

Without a user@ prefix the global tailscaleUser (default "root") is used:

toml
[remoteValidation]
provider = "tailscale"
tailscaleHosts = ["bee", "thinkpad", "mini"]
tailscaleUser = "nick"     # default SSH user for all hosts

The single-host tailscaleHost shorthand is included in the pool. When tailscaleHosts is empty, it pins that host to the top; remove the shorthand from [remoteValidation] to control its position using the pool order. When tailscaleHosts is non-empty, the explicit list's order takes precedence and the shorthand does not jump ahead of it.

Rich rows — one [[remoteValidation.tailscaleHosts]] block per host when you need per-host settings (user, os, labels, maxConcurrent, runner, engineers). Use this form when hosts differ (different SSH users, macOS vs Linux, etc.):

toml
[remoteValidation]
provider = "tailscale"
tailscaleUser = "nick"     # fallback user for hosts without an explicit one

[[remoteValidation.tailscaleHosts]]
host = "mini"
# user = "nick"            # inherits tailscaleUser

[[remoteValidation.tailscaleHosts]]
host = "bee"
os = "linux"
maxConcurrent = 2          # this host can run two suites at once

[[remoteValidation.tailscaleHosts]]
host = "thinkpad"
os = "linux"
engineers = ["opencode", "codex", "cursor"]

Per-host fields: host (required), user, os (capability label, e.g. "macos" or "linux"), labels (extra capabilities, e.g. ["apple-silicon"]), maxConcurrent (per-host FIFO limit, default 1), runner ("docker" (default) or "native" — macOS-only), engineers (CLIs that must be installed and signed in on the host: opencode, codex, cursor — checked with repoos runners check).

The per-host attrs (user, os, labels, maxConcurrent, runner, engineers) are TOML-only; the Settings UI only edits the plain host-name list (see remoteValidation.tailscaleHosts.engineers in the table for the documented shape).

The existing single-host shorthand tailscaleHost = "mini" still works and is folded into the pool as a plain entry. See docs/remote-validation.md for full dispatch, routing and cross-process-limit details.

Dev targets ​

Dev targets are the things you run yourself while developing — a local HMR web server, a debug build — as opposed to task previews, which RepoOS manages for a task's worktree. Declare them in repoos.toml with a repeatable [[dev.targets]] table, one row per target:

toml
[[dev.targets]]
name = "web"
kind = "server"
command = "bun run dev"
url = "http://localhost:5173"
label = "Web app"
healthCheck = { url = "http://localhost:5173/health" }

[[dev.targets]]
name = "apk"
kind = "artifact"
command = "bun run build:android"
artifactPath = "dist/app-debug.apk"

[[dev.targets]]
name = "divot"
kind = "process"
command = "swift run --package-path macos divot"
label = "Divot bar"
KeyTypeRequiredDescription
dev.targets[].namestringyesStable identity, unique within the project. The API and CLI address a target by this name.
dev.targets[].kindserver | artifact | processyesserver is a long-lived process with a URL; artifact is a build that produces a file and exits; process is a long-lived foreground process with no URL and no build artifact (a native app, a daemon).
dev.targets[].commandstringyesShell command run in the project's checkout (never a task worktree). May contain {port} and service port tokens.
dev.targets[].cwdstringnoRepo-relative working directory. Service port tokens are substituted before resolving it; a missing directory fails Start clearly.
dev.targets[].envtablenoExtra target environment variables. Service port tokens are substituted in values before launch.
dev.targets[].urlstringfor serverThe server's URL, e.g. http://localhost:5173. May contain {port}. A process never has one.
dev.targets[].portnumbernoThe port used for {port} (#0802). With auto-pick on it is the preferred port — used when free — otherwise a free one is chosen; with auto-pick off it is the fixed port.
dev.targets[].autoPortbooleannoAuto-pick a free port for {port} (#0802). Default true; set false to pin the target to port and let a clash fail with the summary.
dev.targets[].artifactPathstringfor artifactRepo-relative path of the build output.
dev.targets[].labelstringnoHuman label shown in the UI; defaults to name.
dev.targets[].servicesarray of stringsnoNames of [[dev.services]] rows (#0843) booted before this target, in order, each on its own port. The target's command, env, cwd, url and healthCheck.url reference one as {name.port} / {name.url}. Unknown or repeated names are validation errors.
dev.targets[].healthCheck.urlstringnoURL polled until it answers before a server target is reported running. Defaults to url; may contain {port} and {name.port}.
dev.targets[].healthCheck.filestringnoRepo-relative file that must exist before the target is reported running.

{port} in a target's command, url or healthCheck.url is replaced with a port chosen when the target starts. Auto-pick is on by default (#0802): a preferred port is used when it is free, otherwise a free, OS-assigned port is chosen — the same handling [[preview.targets]] uses — so a dev server that would otherwise clash with another app binds cleanly. The substituted port is the same in all three fields, and the Dev page shows the real URL of a running target. Set autoPort = false to pin the target to port instead: a port already in use then fails with the plain-language summary (naming the process holding it) rather than being moved. Picking a port never stops another process. A target with no {port} behaves exactly as before, keeping its fixed port.

A row must have a way to be judged healthy: a server has a url, an artifact has an artifactPath, and a process is healthy while its process is alive (so it carries no url, artifactPath or healthCheck). A server with no url is rejected with a pointer to kind = "process".

Only commands declared here can be run: the API and repoos dev name a target, and the command is always resolved from the committed repoos.toml — no request body ever supplies a command. Starting a target leaves it running independent of the browser; it stops only when you ask, when the server stops, or when it crashes. Status is one of running, stopped, starting or failed, and the last 2000 lines of a target's combined output are kept and shown in a scrollable panel on the Dev page.

Dev target services ​

A full-stack target — a web dev server whose backend proxies to it — does not need either port pinned in config. Declare companion services as a repeatable [[dev.services]] table next to [[dev.targets]] (the dev counterpart of [[preview.services]]), and reference them from a target's services list (#0843):

toml
[[dev.services]]
name = "api"
command = "bun --watch src/server/index.ts"   # reads PORT
healthCheck = { url = "http://localhost:{port}/api/health" }

[[dev.targets]]
name = "app"
kind = "server"
services = ["api"]
command = "bun run dev -- --port {port} --api-port {api.port}"
url = "http://localhost:{port}"

On Start, RepoOS allocates ports for every selected service before starting any process, so forward references are allowed: a target (or a service) may reference a service declared later in repoos.toml, as well as one declared earlier or itself. Each service then boots before the target and is health-gated: a service whose health check (or, with none declared, a short alive-settle) does not pass fails the start with the service's name in the reason, and everything started so far is torn down — the target otherwise starts fine and then proxies to a dead API. Each service's port is substituted into the target's command, env values, cwd, url and healthCheck.url as {<name>.port} / {<name>.url} (name lowercased, non-alphanumerics collapsed to _). A reference is normalized the same way as the name, so {API.port}, {api-v1.port} and {api_v1.port} all reach the same service; validation and runtime expansion share that one rule, and a spelling the validator accepts is always fully resolved. Doubled or backslash-escaped braces, a misspelled kind ({api.portx}) and an unclosed reference are config errors, not literals left in the command. Each substituted port is passed as PORT and REPOOS_DEV_<NAME>_PORT / REPOOS_DEV_<NAME>_URL to the service, and passed as the same REPOOS_DEV_* variables to the main command. Tokens in service command, env values, cwd and healthCheck.url are substituted too. Inside a service's own command and healthCheck.url, {port} (and PORT) is the service's own port. dependsOn starts dependencies before dependents and includes them in the target's service stack; unknown dependencies and actual dependency cycles are configuration errors. A service may reference a sibling declared later in TOML — and a target may reference a service declared later — because all selected ports are assigned first. Service names referenced by one target must map to distinct placeholder tokens: two names that normalize to the same {<token>.port} (for example api-v1 and api_v1) are a config error, not silently disambiguated, so a documented token can never resolve to the wrong service.

KeyTypeRequiredDescription
dev.services[].namestringyesLabel used to reference the service from a target's services list and in diagnostics.
dev.services[].commandstringyesCommand that boots the service. Its own {port}/PORT is the port RepoOS assigns it; any selected service's {name.port} / {name.url} is substituted.
dev.services[].cwdstringrepo rootRepo-relative subdirectory to run the command in.
dev.services[].dependsOnarray of stringsnoServices that start first; their ports are available to this service and they are included in the target's stack. Unknown names and dependency cycles are errors.
dev.services[].portnumbernoThe service's preferred/fixed port, handled exactly like a target's port (#0802).
dev.services[].autoPortbooleannoAuto-pick a free port; default true. With false the port is required and fixed — an occupied one fails with a message naming the service (and autoPort = false with no port is rejected as a config error, not only at Start).
dev.services[].healthCheck.urlstringnoURL polled (with own and companion service ports substituted) until it answers before the service counts as ready.
dev.services[].healthCheck.filestringnoRepo-relative file (resolved against the service's cwd) that must exist AND be newer than when the service started — a stale marker left by a previous run does not count as ready (#0843 review).
dev.services[].envtablenoExtra environment variables for the service's process; values support {name.port} / {name.url}, e.g. env = { LOG_LEVEL = "debug", API_URL = "{api.url}" }.

Stopping or restarting the target stops its services with it, and a start that fails at the target — its command cannot launch, or its readiness check never passes — tears the already-booted services down too. A service that crashes while the target runs (including after a reload handover, where the PID-liveness check catches the death) tears down the whole stack: the target and every sibling service are stopped, their recorded PIDs are cleared, and the target is marked failed with the service's name in the reason. A target crash does the same cleanup and marks the target failed. These states remain visible on the Dev page (each service has its own sub-row there — status, real port, PID, and its own output). Startup or health-check failure also tears down every process that already started. Stop, restart, server shutdown, and a reload that cannot adopt a complete live stack use the same cleanup. A healthy stack adopted during reload keeps running and remains manageable. Every declared service also answers on repoos dev <name> status|logs --service <name>.

For an artifact target, companion services stay available while the build runs and are stopped automatically when it exits successfully. A failed build or failed service also tears down the stack.

[[dev.services]] are deliberately TOML-only, like [[preview.services]]: a service is a command + port + health check + env table no Settings row can express. Edit them in repoos.toml; a target's services name list can also be edited in the Dev targets card (Settings → Advanced) and round-trips through it.

The optional cwd and env fields on [[dev.targets]] are advanced TOML-only fields; the Settings card continues to edit the target's common name, command, URL and service list.

When a target fails, the Dev page shows a plain-language summary above its output — what failed, the most likely cause, and how to fix it. A port already in use is detected from the output and names the port plus the process holding it (its PID and command), with a Stop action when that process is one RepoOS started — including a companion service, in which case the owning target and all its services stop together; any other process is shown for you to stop yourself. An optional AI-written summary can be layered on top:

KeyTypeDefaultRestartDescription
dev.failureSummary.aibooleanfalsenoOffer an AI-written plain-language summary of a failed dev target, in addition to the deterministic diagnosis. Off by default because it spends a model call.

An invalid row — a missing name or command, an unknown kind, a duplicate name, or a conflicting field such as a url on an artifact — is reported as a clear validation error rather than being silently dropped. Edit the list under Settings → Advanced → Dev targets (validation errors appear inline, and the change is written through the config API), or hand-write the [[dev.targets]] tables. See Dev targets for a full walkthrough.

Editor integration ​

Applies to any install where RepoOS launches a local editor: task specs, context docs, and failed-test links.

KeyTypeDefaultRestartDescription
editor.commandstring""noOptional editor launcher used by "Open in editor" links, e.g. zed {file}:{line}. Placeholders {file} and {line}; omit {line} to open without a line number. Always visible in Settings. Overrides precedence: dev.inspector.editorCommand takes precedence when set.

Dev copy inspector (RepoOS self-host only) ​

These settings appear under Settings → Advanced when this checkout has src/ui-app/ and the running build was compiled with the dev UI bundle (devUi: true in dist/.build-info.json — a normal local bun run build, not REPOOS_SHIP=1 / release tarballs). They are inert on release installs and when previewing another project's app.

KeyTypeDefaultRestartDescription
dev.inspector.enabledbooleantruenoHold Alt (Option on macOS) over visible UI text to reveal a File.vue:line pill with the located element outlined; click it or press Enter to see the source file.
dev.inspector.editorCommandstring""noOptional editor launcher, e.g. zed {file}:{line}. Placeholders {file} and {line}; omit {line} to open without a line number. Copy path works with no command configured.

Template copy is attributed at build time with data-repoos-file / data-repoos-line attributes (dev builds only). Line numbers point at the template element; strings composed in <script setup> lead you to the template line that renders them.

See Dev tooling for how to verify the inspector locally.

Summary ​

Commit project behavior in repoos.toml, keep secrets in a gitignored .env or the machine environment, and treat a REPOOS_-prefixed variable as an override only when it is documented as a supported one. For the complete secret contract, read Environment and secrets.

FSL-1.1-MIT — free to use, self-host and modify; converts to MIT two years after release.