---
title: Configuration
description: Every path of loop.config.yaml, its type, its default and what it decides. Generated from the schema and the source.
---

{/* Generated by `pnpm docs:generate`. Do not edit by hand: edit `src/loop/config.ts` and regenerate. */}

The four layers that produce this configuration — the machine's global file, the project's, the team's and this
machine's overlay — are described in [ADR-0033](https://github.com/AgentsKit-io/harness/blob/main/docs/ADR-0033-four-config-layers-and-presets.md).
`ak-harness loop validate` prints the effective result, which is the only one that decides anything.

211 of 366 paths carry no description yet.

## `schemaVersion`

Type: number · Default: `1`

## `extends`

Defaults for this kind of project (`web-app`, `library`, `monorepo`, `data-pipeline`, `mobile`), merged **below** every other layer. Anything this file states wins over the preset; the preset only fills silence.

Type: string

## `project`

| Path | Type | Default | Description |
|---|---|---|---|
| `project.name` | string | **required** |  |
| `project.repo` | string | **required** |  |
| `project.baseBranch` | string | `"main"` |  |
| `project.root` | string | `"."` |  |
| `project.stateDir` | string | `".ak-loop"` | Where the loop keeps everything that is NOT the project's configuration: dispatch ledger, delivery state, contracts, events, agent memory, plans. Meant to be gitignored — the configuration lives at the repo root as `loop.config.yaml`, versioned; this directory is runtime state, per machine. |
| `project.team` | string |  | Selects the `loop.config.team.&lt;key&gt;.yaml` layer. `$AK_LOOP_TEAM` overrides it; a declared team whose file is missing fails loudly. |
| `project.setup` | object | `{}` |  |
| `project.setup.command` | string[] |  | Argv (no shell — one element per arg, e.g. `[pnpm, install, --frozen-lockfile]`) run once in a freshly created worktree before the worker terminal opens. Unset/empty = skip. |
| `project.setup.timeoutSec` | integer | `600` |  |
| `project.setup.required` | boolean | `true` | When true, a failing/timing-out setup removes the worktree and counts as a dispatch failure instead of handing the worker a broken environment. |

## `orca`

| Path | Type | Default | Description |
|---|---|---|---|
| `orca.bin` | string | `"orca"` |  |
| `orca.repoSelector` | string |  | Orca repo selector for new worktrees: `id:&lt;repoId&gt;`, `name:&lt;name&gt;` or `path:&lt;abs&gt;`; default `path:&lt;project.root&gt;`. |
| `orca.workspaceSelector` | string |  | Existing worktree the automations run in; default: the enclosing worktree resolved by Orca. |
| `orca.host` | string |  |  |
| `orca.minVersion` | string | `"1.4.200"` |  |
| `orca.timeoutMs` | integer | `20000` |  |

## `linear`

| Path | Type | Default | Description |
|---|---|---|---|
| `linear.workspaceId` | string | **required** |  |
| `linear.teamKey` | string | **required** |  |
| `linear.person` | string | **required** | Linear display name of the person whose queue this machine drains. |
| `linear.people` | record&lt;string&gt; | `{}` | Display name → Linear user id, for `assignee set` and audit; the queue itself filters by display name. |
| `linear.rotation` | object | `{}` | Optional ordered handoff between owners after the current dispatchable queue drains. |
| `linear.rotation.enabled` | boolean | `false` |  |
| `linear.rotation.owners` | string[] | `[]` |  |
| `linear.rotation.advanceWhenEmpty` | boolean | `true` |  |
| `linear.queueOwnership` | `person` · `unassigned` | `"person"` | Whose queue this machine drains. `person` (default) keeps the historical behaviour: the issues assigned to `linear.person`. `unassigned` drains the issues with NO assignee and turns the assignee into a transient claim — written on dispatch, cleared when the item returns — so several machines can share one priority-ordered queue without colliding. Note when switching to `unassigned`: clearing the assignees is then REQUIRED, not cosmetic. With `person` and an emptied backlog the queue comes back empty and the loop looks healthy while doing nothing. |
| `linear.states` | string[] | `["Todo","Ready"]` |  |
| `linear.excludeLabels` | string[] | `["blocked","needs-info"]` |  |
| `linear.requireLabels` | string[] | `[]` | ALL of these must be on the issue (AND). |
| `linear.anyLabels` | string[] | `[]` | At least ONE of these must be on the issue (OR) — how a machine declares the slices of the board it drains, e.g. `[layer:L2, layer:L3]`. `requireLabels` cannot say this: it demands every label on the same issue, so two layers there match nothing and the queue comes back silently empty. |
| `linear.projects` | string[] | `[]` |  |
| `linear.order` | `priority` · `updatedAt` · `createdAt`[] | `["priority","updatedAt"]` |  |
| `linear.maxQueue` | integer | `50` |  |
| `linear.inProgressState` | string | `"In Progress"` |  |
| `linear.reviewState` | string | `"In Review"` |  |
| `linear.doneState` | string | `"Done"` |  |
| `linear.blockedLabel` | string | `"blocked"` |  |
| `linear.needsInfoLabel` | string | `"needs-info"` |  |

## `knownFailures`

Suites already red on the base branch, declared so a worker is not asked to pass a verification that nobody can pass. The harness does NOT run `delivery.verifyCommand` — the worker does, in its own worktree, before opening the PR. So tolerating known breakage cannot be done by parsing output the harness never sees: it has to be *told* to the worker, which is what this list does. Every entry carries the tracking issue on purpose. A quarantine without an owner becomes permanent, and the worker needs to know the failure is someone else's to avoid "fixing" it inside an unrelated task.

| Path | Type | Default | Description |
|---|---|---|---|
| `knownFailures.path` | string | **required** | Path or suite name as the runner prints it. |
| `knownFailures.issue` | string | **required** | Tracking issue — no anonymous quarantine. |
| `knownFailures.reason` | string | **required** | Why it is red, in one line. |

## `reviewOverrides`

Stricter review for the slices of the board that deserve it, keyed by label. The review IS the gate when there is no CI, and not every change carries the same risk: a contract that freezes evidence and a copy tweak should not be judged with the same budget. First matching entry wins, and it only overrides the fields it names — everything else falls back to `delivery.review`.

| Path | Type | Default | Description |
|---|---|---|---|
| `reviewOverrides.anyLabels` | string[] | **required** | Matches when the issue carries at least ONE of these labels. |
| `reviewOverrides.votes` | integer |  |  |
| `reviewOverrides.minSeverity` | `nit` · `med` · `high` · `blocker` |  |  |
| `reviewOverrides.profile` | `fast` · `full` |  | Mesmo enum de `delivery.review.profile` — um perfil inventado aqui só falharia no CLI. |
| `reviewOverrides.reason` | string |  | Why this slice is stricter — read by whoever wonders about the cost. |

## `models`

| Path | Type | Default | Description |
|---|---|---|---|
| `models.orchestrator` | string[][] | **required** |  |
| `models.reviewer` | string[][] | **required** |  |
| `models.builder` | string[][] | **required** |  |
| `models.watcher` | string[][] | **required** |  |
| `models.routing` | object | `{}` | How candidates are ordered. `tiers` = YAML order (0.6 behaviour). `hybrid` = keep tiers, rank by remaining usage inside each. `dynamic` = flatten + usage. `catalog` = discover models via CLI/AA/builtin + usage. |
| `models.routing.mode` | `tiers` · `hybrid` · `dynamic` · `catalog` | `"tiers"` |  |
| `models.routing.usageMetric` | `max` · `session` · `weekly` · `monthly` | `"max"` | Which usage window drives remaining%. `max` = most constrained window. |
| `models.routing.preferKnownUsage` | boolean | `true` | Prefer providers with live usage % over those with unknown usage (e.g. grok often has no %). |
| `models.routing.excludeProviders` | string[] | `[]` |  |
| `models.routing.includeProviders` | string[] | `[]` | If non-empty, only these providers may be selected (still must be declared under providers). |
| `models.routing.pin` | object | `{}` | Hard pin per role (`provider/model`). If pinned provider is unavailable, fall through unless pinStrict. |
| `models.routing.pin.orchestrator` | string |  |  |
| `models.routing.pin.reviewer` | string |  |  |
| `models.routing.pin.builder` | string |  |  |
| `models.routing.pin.watcher` | string |  |  |
| `models.routing.pinStrict` | boolean | `false` |  |
| `models.routing.policy` | `quality-first` · `usage-balanced` · `cost-first` | `"quality-first"` | How to choose among the candidates a role's tiers allow. `quality-first` (default, today's behaviour): the best available, with failover. `usage-balanced`: spread across providers by remaining window. `cost-first`: the cheapest that the role can still use — from `models.cost` when declared, otherwise the last tier, which is where a config already puts its cheap last resort. Policy never widens the candidate set; it only orders it. |
| `models.cost` | record&lt;number&gt; | `{}` | Relative cost per `provider/model`, any unit you like — only the order matters. Used by `policy: cost-first`. |
| `models.roles` | object | `{}` | Quality band when `routing.mode: catalog` (and as soft bias in hybrid). |
| `models.roles.orchestrator` | object | `{}` |  |
| `models.roles.orchestrator.quality` | `frontier` · `balanced` · `fast` | `"frontier"` |  |
| `models.roles.orchestrator.preferCreators` | string[] | `[]` |  |
| `models.roles.reviewer` | object | `{}` |  |
| `models.roles.reviewer.quality` | `frontier` · `balanced` · `fast` | `"frontier"` |  |
| `models.roles.reviewer.preferCreators` | string[] | `[]` |  |
| `models.roles.builder` | object | `{}` |  |
| `models.roles.builder.quality` | `frontier` · `balanced` · `fast` | `"balanced"` |  |
| `models.roles.builder.preferCreators` | string[] | `[]` |  |
| `models.roles.watcher` | object | `{}` |  |
| `models.roles.watcher.quality` | `frontier` · `balanced` · `fast` | `"fast"` |  |
| `models.roles.watcher.preferCreators` | string[] | `[]` |  |
| `models.catalog` | object | `{}` |  |
| `models.catalog.sources` | `cli` · `artificial-analysis` · `builtin`[] | `["cli","builtin"]` |  |
| `models.catalog.cliCacheHours` | number | `6` | How long a provider's CLI-discovered model list (e.g. `grok models`) is trusted before spawning the CLI again — it rarely changes between releases. |
| `models.catalog.artificialAnalysis` | object | `{}` |  |
| `models.catalog.artificialAnalysis.enabled` | boolean | `false` |  |
| `models.catalog.artificialAnalysis.apiKeyEnv` | string | `"ARTIFICIAL_ANALYSIS_API_KEY"` |  |
| `models.catalog.artificialAnalysis.cacheHours` | number | `24` |  |
| `models.catalog.artificialAnalysis.endpoint` | string | `"https://artificialanalysis.ai/api/v2/data/llms/models"` |  |
| `models.cooldown` | object | `{}` |  |
| `models.cooldown.initialMin` | integer | `30` |  |
| `models.cooldown.maxMin` | integer | `240` |  |
| `models.cooldown.probeBeforeReenable` | boolean | `true` |  |
| `models.cooldown.exhaustedPercent` | number | `100` | A usage window at or above this percent counts as exhausted. |
| `models.providers` | record&lt;object&gt; | **required** |  |
| `models.providers.bin` | string | **required** |  |
| `models.providers.auth` | `subscription` · `api-key` · `none` | `"none"` |  |
| `models.providers.envKeys` | string[] | `[]` |  |
| `models.providers.orcaAgent` | string |  |  |
| `models.providers.orcaUsageKey` | string |  |  |
| `models.providers.tui` | string | **required** |  |
| `models.providers.probe` | string[] |  |  |
| `models.providers.headless` | string[] |  |  |
| `models.providers.reviewProvider` | string |  |  |
| `models.providers.effortFlag` | string |  |  |
| `models.providers.subagents` | boolean | `false` |  |
| `models.effort` | object | `{}` | Reasoning effort requested per role; only applied for providers whose `effortFlag` is set. |
| `models.effort.orchestrator` | `low` · `medium` · `high` · `xhigh` | `"high"` |  |
| `models.effort.reviewer` | `low` · `medium` · `high` · `xhigh` | `"high"` |  |
| `models.effort.builder` | `low` · `medium` · `high` · `xhigh` | `"medium"` |  |
| `models.effort.watcher` | `low` · `medium` · `high` · `xhigh` | `"low"` |  |

## `machine`

| Path | Type | Default | Description |
|---|---|---|---|
| `machine.floor` | integer | `1` |  |
| `machine.ceiling` | integer |  |  |
| `machine.minFreeRamGb` | number | `4` |  |
| `machine.warningPercent` | number | `75` |  |
| `machine.criticalPercent` | number | `90` |  |
| `machine.agentRssMb` | number | `1400` | Per-agent RSS budget when live measurement is unavailable. |
| `machine.wslCap` | integer | `1` |  |

## `delivery`

| Path | Type | Default | Description |
|---|---|---|---|
| `delivery.verifyCommand` | string | **required** |  |
| `delivery.review` | object | `{}` |  |
| `delivery.review.cli` | string | `"agentskit-review"` |  |
| `delivery.review.mode` | `trusted-local` · `isolated` | `"trusted-local"` | agentskit-review execution mode. `trusted-local` reuses this user's environment (and CLI logins); the isolated default runs claude/codex with a temporary HOME and no credentials. |
| `delivery.review.transport` | `acp` · `headless` · `auto` |  | agentskit-review transport. `headless` is required for current grok-cli (ACP fails on submit_batched_findings); omit to use the CLI default. |
| `delivery.review.profile` | `fast` · `full` | `"fast"` | `fast` = one bounded pass over the required lenses (fits a 600 s Orca stage); `full` = every lens, needs a long deadline or batching. |
| `delivery.review.votes` | integer | `1` |  |
| `delivery.review.concurrency` | integer | `4` |  |
| `delivery.review.minSeverity` | `nit` · `med` · `high` · `blocker` | `"med"` | agentskit-review severity floor that blocks auto-merge: nit &lt; med &lt; high &lt; blocker. |
| `delivery.review.deadlineMs` | integer | `600000` |  |
| `delivery.review.maxCalls` | integer | `400` |  |
| `delivery.review.post` | boolean | `true` | Post the review to the PR (inline + summary). |
| `delivery.review.doctorProbe` | `help` · `none` | `"help"` | Doctor probe depth for the review CLI (`help` runs `--help`; `none` only checks PATH). |
| `delivery.review.smallChangeLines` | integer | `0` | Cost lever: a change at or below this many lines — or touching only documentation — is reviewed by the cheapest available candidate instead of the strongest. 0 disables it and every review uses the strongest. |
| `delivery.review.criticalPaths` | string[] | `[]` | Path prefixes that always get the strongest reviewer, whatever the size (contracts, security, migrations). |
| `delivery.merge` | object | `{}` |  |
| `delivery.merge.auto` | boolean | `true` |  |
| `delivery.merge.method` | `squash` · `merge` · `rebase` | `"squash"` |  |
| `delivery.merge.requireChecks` | boolean | `true` |  |
| `delivery.merge.requireHumanApproval` | boolean | `false` | Extra synchronous gate on top of a clean review + green checks: a real human must approve the PR on GitHub (`reviewDecision: 'APPROVED'`, already fetched with every PR snapshot) before the loop merges it. False by default so existing configs keep auto-merging on a clean review, matching ADR-0027 §6. |
| `delivery.smoke` | object | `{}` | Optional bounded smoke gate before auto-merge (argv via CommandRunner; default off). |
| `delivery.smoke.enabled` | boolean | `false` |  |
| `delivery.smoke.kind` | `none` · `verify-argv` | `"none"` |  |
| `delivery.smoke.argv` | string[] | `[]` |  |
| `delivery.smoke.timeoutMs` | integer | `120000` |  |
| `delivery.verify` | object | `{}` | Harness-side verify runtime for smoke/doctor only; workers still see `verifyCommand` as a string. |
| `delivery.verify.runtime` | `process` · `docker` | `"process"` |  |
| `delivery.verify.argv` | string[] | `[]` |  |
| `delivery.verify.docker` | object | `{}` |  |
| `delivery.verify.docker.image` | string | `""` |  |
| `delivery.verify.docker.cwd` | string | `"/work"` |  |
| `delivery.maxFixRounds` | integer | `2` |  |
| `delivery.workerIdleTimeoutMin` | integer | `45` |  |
| `delivery.maxDispatchMinutes` | integer |  | Hard wall-clock ceiling on one dispatch, independent of idle detection: `workerIdleTimeoutMin` only catches a worker that stopped producing output, not one that is still active but has been running far longer than any real task on this project should. Unset (default) = disabled. |
| `delivery.handoff` | object | `{}` | When a worker goes idle / dies and its provider is out of usage (or otherwise unavailable), relaunch another builder on the **same** Orca worktree + branch with a continuation brief. |
| `delivery.handoff.enabled` | boolean | `true` |  |
| `delivery.handoff.maxHandoffs` | integer | `2` |  |
| `delivery.handoff.onlyWhenProviderUnavailable` | boolean | `true` | Only hand off when the current provider is unavailable (exhausted/cooldown/missing). |
| `delivery.selfEditPaths` | string[] | `["loop.config.yaml",".github/**"]` |  |
| `delivery.secretFilePatterns` | string[] | `["**/.env","**/.env.*","**/*.pem","**/*.key","**/id_rsa","**/id_rsa.*","**/credentials.json","**/*.p12","**/*.pfx"]` | Glob patterns (same matcher as `selfEditPaths`) for filenames that should never enter a PR the loop reviews or merges, regardless of the diff content — the loop cannot fetch a PR's actual diff content today, so this is a filename-shaped guardrail, not a secret-content scan. A PR touching one of these is held exactly like `selfEditPaths`, with a distinct reason. Defaults cover the most common accidentally-committed secret files. |
| `delivery.ignoreChecks` | string[] | `[]` | Check names ignored when deciding CI is green (e.g. advisory bots). |
| `delivery.requiredChecks` | string[] | `[]` | Check names that must be observed and green; empty = every reported check must pass. |
| `delivery.cleanupWorktree` | boolean | `true` | Remove the Orca worktree after a successful merge. |
| `delivery.returnState` | string | `"Todo"` | Linear state an abandoned (stuck/blocked) issue returns to. |

## `contract`

| Path | Type | Default | Description |
|---|---|---|---|
| `contract.maxIssueChars` | integer | `12000` | Max characters of issue description + comments rendered into the orchestrator prompt. |
| `contract.timeoutMs` | integer | `300000` |  |
| `contract.maxContextReferences` | integer | `6` | Doc Bridge references appended to the orchestrator prompt when `.doc-bridge/index.json` exists. |
| `contract.reuseHours` | number | `72` | Re-generate a cached contract older than this many hours (0 = always reuse). |
| `contract.docBridgeMaxAgeHours` | number | `168` | Warn (or fail when requireDocBridge) when the Doc Bridge index mtime is older than this many hours. |
| `contract.requireDocBridge` | boolean | `false` | When true, doctor fails if `.doc-bridge/index.json` is missing or unreadable. |
| `contract.briefScopes` | string[] | `["playbook","for-agents"]` | Doc Bridge scopes resolved into the worker brief (titles/paths only). |
| `contract.maxBriefReferences` | integer | `4` |  |
| `contract.contextProviders` | `doc-bridge` · `rag`[] | `["doc-bridge"]` | Context providers consulted when freezing a contract. |

## `memory`

| Path | Type | Default | Description |
|---|---|---|---|
| `memory.enabled` | boolean | `false` | Master switch. When false the loop never recalls or writes memory. |
| `memory.backend` | `file` · `none` | `"file"` |  |
| `memory.storePath` | string | `"memory"` | Directory under stateDir for the file KV store. |
| `memory.maxRecall` | integer | `5` |  |
| `memory.maxSummaryChars` | integer | `240` |  |
| `memory.maxBlockChars` | integer | `1200` |  |
| `memory.preferOverDocBridge` | boolean | `true` | Drop Doc Bridge refs covered by memory so the context budget shrinks. |
| `memory.minDocBridgeWhenMemory` | integer | `2` |  |
| `memory.scopes` | `issue` · `project` · `global`[] | `["project","global"]` |  |
| `memory.includeStale` | boolean | `false` |  |
| `memory.writeOnPromote` | boolean | `true` |  |
| `memory.categories` | `worked` · `problem` · `adjustment` · `other`[] | `["adjustment"]` |  |
| `memory.shrinkIssueCharsWhenMemory` | boolean | `true` |  |
| `memory.issueCharsWithMemory` | integer | `4000` |  |
| `memory.recurrence` | object | `{}` | When a lesson stops being an anecdote and starts being a pattern. A learning proposed `minSightings` times is surfaced by `loop retro` as ready to promote, with the exact command — so the human act is one keystroke instead of an analysis, and at most `maxPerRun` are offered at a time. With `autoPromote.enabled` the retro promotes them itself as `loop-auto` — under the same three bounds, and never as `human` (ADR-0019, amendment of 2026-09-19). Off by default: turning it on is the project's decision, and every automatic promotion stays listable and revocable. |
| `memory.recurrence.minSightings` | integer | `2` | How many sightings make a lesson a pattern. Below 2 is "it happened once". |
| `memory.recurrence.maxPerRun` | integer | `3` |  |
| `memory.autoPromote` | object | `{}` |  |
| `memory.autoPromote.enabled` | boolean | `false` | When true, `loop retro` promotes the recurring lessons itself, attributed to `loop-auto`. |

## `agents`

| Path | Type | Default | Description |
|---|---|---|---|
| `agents.registryPath` | string | `"agents.registry.yaml"` |  |
| `agents.requireRegistry` | boolean | `false` | When true, missing registry or role entry fails doctor/routing closed. |
| `agents.autoImprove` | boolean | `false` | Let the retro propose improvements to the instructions of the agents this project installed under `agents/&lt;id&gt;/`. Off by default; `architect` and `reviewer` are never auto-changed whatever this says, and publishing anything back to the registry is always a human's gesture. |
| `agents.evalCommand` | string[] | `[]` | Argv that runs the agent eval. Without it nothing is adopted: a change that cannot be measured is a guess. |
| `agents.evalTimeoutSec` | integer | `900` |  |
| `agents.maxAutoLines` | integer | `5` | More lines than this in one proposal and it waits for a human. |
| `agents.minRatio` | number | `0.5` | Bad outcomes per run above which a role is worth improving at all. |

## `rag`

| Path | Type | Default | Description |
|---|---|---|---|
| `rag.enabled` | boolean | `false` |  |
| `rag.queryArgv` | string[] | `[]` | Argv that prints a ContextSnapshot (or `{ references, sourceHash }`) JSON on stdout. |
| `rag.timeoutMs` | integer | `30000` |  |
| `rag.maxReferences` | integer | `4` |  |

## `mcp`

| Path | Type | Default | Description |
|---|---|---|---|
| `mcp.enabled` | boolean | `false` | Public API / future CLI only in 0.6.0 — not wired into tick/deliver. |
| `mcp.allowTools` | string[] | `[]` |  |

## `plugins`

| Path | Type | Default | Description |
|---|---|---|---|
| `plugins.modules` | string[] | `[]` | Local `.mjs` files (relative to `project.root`) loaded once at the start of `tick`/`deliver`; each exports `{ id, apply(bus) }` and gets the loop's in-process event bus to subscribe to (`src/loop/event-bus.ts`) — events (`contract.failed`, `worker.dispatched`, …) and lifecycle hooks (`beforeDispatch`, `beforeMerge`, … a `before*` hook can block the action). Same trust level as `agents.registry.yaml`: files already in this repo, never fetched over the network. |

## `github`

| Path | Type | Default | Description |
|---|---|---|---|
| `github.intakeLabel` | string | null | `"loop:review"` | A PR labeled with this on GitHub is picked up by deliver even though the loop never dispatched it. Set null to disable intake entirely. |
| `github.reviewOnly` | boolean | `true` | Intake PRs are always review + comment only; this loop never merges a PR it did not dispatch, regardless of a clean review. |

## `resilience`

| Path | Type | Default | Description |
|---|---|---|---|
| `resilience.maxConsecutiveFailures` | integer | `3` | Consecutive failures on the same issue — contract generation failing on every candidate, or a worker/worktree dispatch failing — before the loop stops retrying it and escalates instead of spinning every tick. (Pilot 2026-09-11: one unclassified quota error produced 19 silent retries across 4 issues over 7h with no cap.) `contract.escalated` (a genuine "needs more information" decision) does not count; a successful dispatch, a clean/findings review, or a merge clears the counter. |
| `resilience.pausedLabel` | string | `"loop:paused"` | Label applied (and checked for removal, to auto-resume) when an issue is paused after `maxConsecutiveFailures`. |
| `resilience.stagePauseAfterRuns` | integer | `3` | Consecutive *thrown* `loop stage` runs (config/adapter crash, not a normal idle/ok/blocked report) before that stage pauses itself. |
| `resilience.maxUsageDeltaPercent` | number |  | Cost circuit breaker: the loop cannot count a worker CLI's internal model/tool calls (it is an opaque process), so instead it watches the builder provider's remaining Orca usage from dispatch time. If that provider's remaining usage drops by at least this many percentage points *while this one issue is in flight*, deliver stops nudging/reviewing/merging it and escalates like a stuck worker. Unset (default) = disabled — a config typo elsewhere must not silently start blocking normal-cost dispatches. |

## `brief`

| Path | Type | Default | Description |
|---|---|---|---|
| `brief.skills` | string[] | `[]` | Markdown files (paths relative to `project.root`) pinned verbatim into every worker brief, sha256-digested for traceability. Missing file = dispatch fails closed. |
| `brief.maxSkillChars` | integer | `6000` | Per-file cap; a file over this length is truncated with a visible note rather than blowing the brief budget. |

## `security`

| Path | Type | Default | Description |
|---|---|---|---|
| `security.pii` | object | `{}` |  |
| `security.pii.enabled` | boolean | `false` | Off by default: scanning issue text/PR findings for PII-shaped patterns before they enter a prompt or a public comment. |
| `security.pii.action` | `redact` · `warn` · `block` | `"redact"` | `redact` replaces a match with `[REDACTED:&lt;kind&gt;]`; `warn` leaves the text as-is but logs a `security.pii-detected` event; `block` fails the contract instead of sending the text anywhere. |

## `budget`

Ceilings on what the loop may spend. A budget that is reached **escalates; it never insists** — the loop calling the same model again with less headroom is how a bad hour becomes a bad week.

| Path | Type | Default | Description |
|---|---|---|---|
| `budget.perProvider` | number | `100` | Percentage of a provider's window the loop may consume, leaving the rest for the human sharing the plan. 100 (default) changes nothing; 80 means the loop stops using a provider once 80% of the window is gone. |
| `budget.perIssueTokens` | integer | `0` | Tokens one issue may consume across every model call the loop makes for it. 0 = no ceiling. |

## `intake`

Where work comes from when nobody typed it: an alert, a log error, a piece of user feedback. `intake` reads the declared sources, deduplicates against what it already filed, and creates the issue with its evidence. It is what closes the cycle — and the only way the `incident` flow starts without a human at a keyboard.

| Path | Type | Default | Description |
|---|---|---|---|
| `intake.enabled` | boolean | `false` |  |
| `intake.sources` | object[] | `[]` | Each source is argv printing a JSON array of alerts: `{ id?, title, body?, severity?, url? }`. |
| `intake.sources.id` | string | **required** |  |
| `intake.sources.command` | string[] | **required** |  |
| `intake.sources.timeoutSec` | integer | `120` |  |
| `intake.sources.labels` | string[] | `[]` | Labels every issue from this source carries, on top of `intake.labels`. |
| `intake.labels` | string[] | `[]` |  |
| `intake.dedupeWindowHours` | number | `168` | An alert with the same fingerprint inside this window is not filed again. |
| `intake.flowBySeverity` | record&lt;string&gt; | `{}` | Severity (as the source reports it, lowercased) → `flow:&lt;name&gt;` label, so a P0 can start the incident flow. |
| `intake.maxPerRun` | integer | `5` |  |

## `maintain`

Dependencies, security and licences, on a schedule — what a bot does from outside, inside the loop and under the same Definition of Done. A check that has nothing to decide files nothing.

| Path | Type | Default | Description |
|---|---|---|---|
| `maintain.enabled` | boolean | `false` |  |
| `maintain.checks` | object[] | `[]` |  |
| `maintain.checks.id` | string | **required** |  |
| `maintain.checks.command` | string[] | **required** |  |
| `maintain.checks.timeoutSec` | integer | `600` |  |
| `maintain.checks.fileWhen` | `output` · `exit-code` | `"exit-code"` | `output` files an issue when the command prints anything; `exit-code` when it exits non-zero. |
| `maintain.checks.title` | string | **required** |  |
| `maintain.checks.labels` | string[] | `[]` |  |
| `maintain.dedupeWindowHours` | number | `168` | How often the same unresolved finding may be filed again. |

## `connectors`

Which implementation of each connector this project uses. The engine speaks only to the interfaces (`TrackerConnector`, `ScmConnector`, `RunnerConnector`); adding Jira, GitLab or a cloud sandbox is a new implementation and a new value here, never a change in tick, deliver or release.

| Path | Type | Default | Description |
|---|---|---|---|
| `connectors.tracker` | `linear` | `"linear"` |  |
| `connectors.scm` | `github` | `"github"` |  |
| `connectors.runner` | `orca` · `local` | `"orca"` | `orca` drives Orca's worktrees and terminals; `local` is git worktree + tmux + the system crontab. |
| `connectors.local` | object | `{}` |  |
| `connectors.local.worktreeRoot` | string | `"../.ak-worktrees"` | Where `local` puts its worktrees. Relative paths resolve against `project.root`. |
| `connectors.local.tmuxBin` | string | `"tmux"` |  |
| `connectors.local.cronMarker` | string | `"# ak-harness"` | Marker comment the harness owns in the crontab; every line it manages carries it. |

## `release`

Promotion and deploy. The loop closes an issue when it merges into `project.baseBranch` — the integration branch; `release` moves that batch to `releaseBranch` and runs the project's deploy, and it **never** starts without a human approving the batch. Everything that acts on the world keeps a human in front of it.

| Path | Type | Default | Description |
|---|---|---|---|
| `release.enabled` | boolean | `false` |  |
| `release.branch` | string | `"production"` | Where the approved batch is promoted to. Must differ from `project.baseBranch`. |
| `release.deploy` | string[] |  | Deploy argv (no shell), run in `project.root` after the promotion push succeeds. Unset = promotion only. |
| `release.deployTimeoutSec` | integer | `1800` |  |
| `release.smoke` | string[] |  | Post-deploy smoke argv. A non-zero exit runs `rollback` (when declared) and escalates. |
| `release.smokeTimeoutSec` | integer | `300` |  |
| `release.notesFile` | string |  | When set, release notes for the batch are written here (newest first) and committed before the promotion. |
| `release.rollback` | string[] |  | Rollback argv, declared by the project because only the project knows what undoing its deploy means. |
| `release.rollbackTimeoutSec` | integer | `900` |  |

## `worker`

The roles that run per issue, and the plan the worker starts from. The planner and the vote run **in the harness, headless, before dispatch** — the same shape as freezing the contract. The model writes the plan and writes the votes; the machine counts them and decides. The worker is only launched once a plan has consensus, so it starts from an approved plan instead of inventing one.

| Path | Type | Default | Description |
|---|---|---|---|
| `worker.plan` | object | `{}` |  |
| `worker.plan.enabled` | boolean | `false` |  |
| `worker.plan.votes` | integer | `3` | How many agents vote on the plan. |
| `worker.plan.approvals` | integer | `2` | How many of them must approve. Default 2 of 3. |
| `worker.plan.maxCycles` | integer | `3` | Planner → vote → replan cycles before the item becomes a human's problem. Three models disagreeing three times is an ambiguous requirement. |
| `worker.plan.timeoutMs` | integer | `300000` |  |
| `worker.roles` | `planner` · `vote` · `builder` · `verify` · `review` · `dod`[] |  | The phases that run for one issue, in order. Unset (the default) means every phase answers for itself, from its own block — `worker.plan.enabled`, `delivery.verify.argv`, `delivery.review`, `dod.items` — which in practice is `['builder', 'review']` and is exactly what a project that never opted in already has. **Declaring the list makes it the answer**: a phase not named here does not run, however well configured its own block is. That is the point of declaring it. |

## `layers`

The slices of the codebase, each with the one thing that decides it: a label the tracker carries, a file boundary, and the test that closes it. This is the source; a layer's description in the tracker is a reflection of it, never the other way round. The decomposer reads these to place an issue, the brief tells the worker which test closes its layer, and the cheap verifier runs that test instead of the whole suite when the issue belongs to one.

| Path | Type | Default | Description |
|---|---|---|---|
| `layers.id` | string | **required** |  |
| `layers.label` | string | **required** | The tracker label that puts an issue in this layer, e.g. `layer:L2`. |
| `layers.description` | string | `""` |  |
| `layers.paths` | string[] | `[]` | Globs the layer owns. A PR for this layer touching anything else is reported, and held when `enforce`. |
| `layers.verify` | string | `""` | The command that closes this layer. Used by the brief and by the pre-review verifier. |
| `layers.enforce` | boolean | `false` | Off by default: a boundary that blocks before a team has drawn it properly costs more than it protects. |

## `documents`

Where the PRD, the technical design and the decisions live once a human approves them. `file` writes them into the repository, which is what makes them reviewable, diffable and greppable by the workers that come later. `none` keeps them only in the loop's state. A tracker-document backend is the seam this leaves open; Orca's CLI has no document command today, so there is nothing honest to implement against.

| Path | Type | Default | Description |
|---|---|---|---|
| `documents.backend` | `file` · `none` | `"file"` |  |
| `documents.prdPath` | string | `"docs/prd"` |  |
| `documents.designPath` | string | `"docs/design"` |  |

## `dod`

The project's half of the Definition of Done: the same list for every issue, and every item provable. The issue's half is the frozen contract's `outcomes`. A PR merges only when both lists are proven, and the proof — a command's output, a changed file, an absent pattern — is written on the PR. There is deliberately no `manual` kind: what cannot be proven is not a DoD item, it is a wish.

| Path | Type | Default | Description |
|---|---|---|---|
| `dod.items` | object[] | `[]` | With items declared, the project list is enforced at merge; with none, only the contract outcomes are. |
| `dod.items.id` | string | **required** |  |
| `dod.items.description` | string | **required** |  |
| `dod.items.kind` | `command` · `file-changed` · `pattern-absent` | **required** | `command` — argv the worker runs, exit 0 is the proof (the harness never runs it; the worker does, in its own worktree). `file-changed` — the PR must touch a path matching `glob`. `pattern-absent` — no changed file may contain `pattern`. |
| `dod.items.command` | string[] |  |  |
| `dod.items.glob` | string |  |  |
| `dod.items.pattern` | string |  |  |
| `dod.items.paths` | string[] | `[]` | Restrict `pattern-absent` / `file-changed` to these path globs; empty = every changed file. |
| `dod.evidenceFile` | string | `".ak-loop/dod.json"` | Where the worker writes its proofs, relative to the worktree root. |

## `tuning`

Knobs the retro is allowed to move by itself, each inside a declared range and justified by a declared metric. A knob with no metric is not auto-adjustable: the metric is what proves the change helped, and it is the same number that reverts it when the next cycle is worse. Never auto-adjustable, whatever this block says: models, providers, gates and branches — the things that decide who pays and what reaches production.

| Path | Type | Default | Description |
|---|---|---|---|
| `tuning.enabled` | boolean | `false` |  |
| `tuning.maxChangesPerRetro` | integer | `1` | At most this many knobs move in one retro, so a bad cycle changes one thing and stays explainable. |
| `tuning.commit` | boolean | `false` | Commit the edited `loop.config.yaml` with the reason and the evidence. Off by default: committing is the project's call. |
| `tuning.knobs` | object[] | `[]` |  |
| `tuning.knobs.path` | string | **required** | Dotted path into this config, e.g. `delivery.review.minSeverity`. Must resolve to a declared field. |
| `tuning.knobs.metric` | `review-findings-ratio` · `stuck-count` · `fix-rounds-per-merge` · `escalation-count` | **required** | The metric that justifies moving it, and that reverts it when the next cycle is worse. |
| `tuning.knobs.values` | string,number[] |  | Ordered ladder of allowed values, cheapest first. Use this for enums. |
| `tuning.knobs.min` | number |  | Numeric range. `step` is how far one retro may move it. |
| `tuning.knobs.max` | number |  |  |
| `tuning.knobs.step` | number |  |  |

## `flows`

Named flow profiles — one motor, several kinds of demand. A profile switches on and off what the loop spends: review strictness and votes, CI babysitting, the human gates, and (as the stages land) the worker's own roles. A selection rule picks one per issue; unmatched issues get `flows.default`.

| Path | Type | Default | Description |
|---|---|---|---|
| `flows.default` | string |  | Profile used when no rule matches. Must name a key of `profiles` (or `null` for "change nothing"). |
| `flows.profiles` | record&lt;object&gt; | `{}` |  |
| `flows.profiles.review` | object |  | Replaces the named `delivery.review` fields for issues on this flow. Other fields keep the project value. |
| `flows.profiles.review.votes` | integer |  |  |
| `flows.profiles.review.minSeverity` | `nit` · `med` · `high` · `blocker` |  |  |
| `flows.profiles.review.profile` | `fast` · `full` |  |  |
| `flows.profiles.review.deadlineMs` | integer |  |  |
| `flows.profiles.merge` | object |  |  |
| `flows.profiles.merge.auto` | boolean |  |  |
| `flows.profiles.merge.requireChecks` | boolean |  | CI babysitting: with checks required, a red check becomes a fix round; without, the review is the gate. |
| `flows.profiles.merge.requireHumanApproval` | boolean |  |  |
| `flows.profiles.maxFixRounds` | integer |  |  |
| `flows.profiles.roles` | record&lt;object&gt; | `{}` | Who runs a role on this flow, and how hard it thinks. Precedence is narrow beats broad: the role inside the profile, then the project's config, then the global one. A `provider`/`model` here **narrows** the role's candidate list to that pin; it never widens it, so a pin nobody can serve right now falls through to the role's ordinary candidates instead of dispatching something nobody asked for. |
| `flows.profiles.roles.provider` | string |  |  |
| `flows.profiles.roles.model` | string |  |  |
| `flows.profiles.roles.effort` | `low` · `medium` · `high` · `xhigh` |  |  |
| `flows.profiles.roles.timeoutMs` | integer |  | Ceiling for one call of this role on this flow. Unset = the role's own default. |
| `flows.profiles.stages` | record&lt;boolean&gt; | `{}` | Per-issue phases this flow switches off (or explicitly back on), overriding `worker.roles`. These are the phases of one issue — not the scheduled automations, which are `schedule.*`. `builder` is the work itself: listing it as `false` is accepted and ignored, because a flow that builds nothing is not a flow. |
| `flows.profiles.lead` | boolean |  | The builder leads instead of typing: it delegates one plan item at a time and integrates the results. Only worth asking for where the provider has subagents (`models.providers.&lt;id&gt;.subagents`). Where it does not, the brief says so plainly and the dispatch record keeps that fact — silently dropping the request would leave a human reading "lead" in the config and a worker that never led anything. |
| `flows.profiles.reason` | string |  | Free-form note shown wherever the flow is reported, so a costlier gate can explain itself. |
| `flows.select` | object[] | `[]` | Rules are evaluated by kind, never by position: **label, then project, then priority**. A label is an explicit intention and outranks a signal; within one kind the first matching rule wins. |
| `flows.select.flow` | string | **required** |  |
| `flows.select.anyLabels` | string[] | `[]` |  |
| `flows.select.projects` | string[] | `[]` |  |
| `flows.select.priorities` | string[] | `[]` |  |

## `notifications`

Where the loop calls a human. The tracker comment always happens — it is the record; this is the channel on top of it. Two generic shapes only: a webhook (Slack, Discord, Telegram bots, n8n) and a local command (system notification, mail CLI). Zero vendor code, so a new destination is configuration, not a release.

| Path | Type | Default | Description |
|---|---|---|---|
| `notifications.events` | string[] | `["contract.escalated","contract.failed","issue.paused","stage.paused","pr.merge-refused","release.waiting"]` | Loop event types that reach the channel. `onEscalate` always does, whatever this list says. |
| `notifications.webhook` | object |  |  |
| `notifications.webhook.url` | string |  | Literal URL. Only for the user's global file, which lives outside every repository; in a versioned config use `urlEnv`. |
| `notifications.webhook.urlEnv` | string |  | Name of the environment variable holding the URL — the shape a versioned config uses, since this file never holds secrets. |
| `notifications.webhook.method` | `POST` · `PUT` | `"POST"` |  |
| `notifications.webhook.headers` | record&lt;string&gt; | `{}` | Extra headers. Values are literal; put a token in `urlEnv` or a proxy instead of writing it here. |
| `notifications.webhook.timeoutMs` | integer | `10000` |  |
| `notifications.command` | string[] |  | Argv (no shell). `{summary}`, `{event}`, `{issue}` and `{json}` are substituted per element. |
| `notifications.commandTimeoutMs` | integer | `10000` |  |

## `schedule`

| Path | Type | Default | Description |
|---|---|---|---|
| `schedule.tick` | string | `"*/5 * * * *"` |  |
| `schedule.deliver` | string | `"*/10 * * * *"` |  |
| `schedule.retro` | string |  | When set with `retroIssue`, install also creates `&lt;prefix&gt;-retro`. |
| `schedule.retroIssue` | string |  | Linear issue that receives the weekly retro digest comment. |
| `schedule.observe` | string |  | When set, install also manages `&lt;prefix&gt;-observe`: the health scan whose precheck exits 0 only when a human-facing anomaly is new or overdue for a reminder. |
| `schedule.observer` | object | `{}` |  |
| `schedule.observer.since` | string | `"24h"` | Event window the scan reads, as accepted by `loop observe --since`. |
| `schedule.observer.reminderHours` | number | `2` | An unresolved problem set already notified is repeated at most this often. |
| `schedule.observer.schedulerStallMin` | integer | `20` | No automation run in this long means the scheduler itself stopped, not that the loop is idle. |
| `schedule.observer.staleLockMin` | integer | `30` | A stage lock older than this is presumed abandoned rather than a long run. |
| `schedule.precheckTimeoutSec` | integer | `120` |  |
| `schedule.harnessCommand` | string | `"ak-harness"` | How the Orca automation invokes the harness inside the workspace; `-f &lt;config&gt;` is appended. |
| `schedule.provider` | string |  | Orca agent id that runs the automation prompt; default: the watcher role's first available provider, else claude. |
| `schedule.namePrefix` | string | `"loop"` | Prefix for automation names (`&lt;prefix&gt;-tick`, `&lt;prefix&gt;-deliver`, `&lt;prefix&gt;-retro`, `&lt;prefix&gt;-observe`). |
| `schedule.runner` | `precheck` · `agent` | `"precheck"` | `precheck` (default): the stage runs inside Orca's `--precheck` command and always exits 1, so Orca records the run (`skipped_precheck`, stdout captured) without ever launching an agent. `agent`: legacy — the precheck only tests for work and an Orca-launched agent runs the harness (needs a provider that runs non-interactively). |
| `schedule.stageTimeoutSec` | integer | `600` | Time budget for one stage when `runner: precheck`. Orca caps prechecks at 600 s; the stage itself must fit. |
| `schedule.timezone` | string |  |  |
