Every path of loop.config.yaml, its type, its default and what it decides. Generated from the schema and the source.
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.
ak-harness loop validate prints the effective result, which is the only one that decides anything.
211 of 366 paths carry no description yet.
Type: number · Default: 1
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
| 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.<key>.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. |
| Path | Type | Default | Description |
|---|
orca.bin | string | "orca" | |
orca.repoSelector | string | | Orca repo selector for new worktrees: id:<repoId>, name:<name> or path:<abs>; default path:<project.root>. |
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 | |
| 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<string> | {} | 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" | |
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. |
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. |
| 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<number> | {} | 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<object> | 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" | |
| 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 | |
| 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 < med < high < 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. |
| 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. |
| 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. |
| 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/<id>/. 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. |
| 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 | |
| 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[] | [] | |
| 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. |
| Path | Type | Default | Description |
|---|
github.intakeLabel | string | null | "loop:review" |
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. |
| 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. |
| 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. |
| 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:<kind>]; warn leaves the text as-is but logs a security.pii-detected event; block fails the contract instead of sending the text anywhere. |
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. |
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<string> | {} | Severity (as the source reports it, lowercased) → flow:<name> label, so a P0 can start the incident flow. |
intake.maxPerRun | integer | 5 | |
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. |
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. |
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 | |
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. |
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. |
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" | |
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. |
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 | | |
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<object> | {} | |
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<object> | {} | 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<boolean> | {} | 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.<id>.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[] | [] | |
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<string> | {} | 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 | |
| Path | Type | Default | Description |
|---|
schedule.tick | string | "*/5 * * * *" | |
schedule.deliver | string | "*/10 * * * *" | |
schedule.retro | string | | When set with retroIssue, install also creates <prefix>-retro. |
schedule.retroIssue | string | | Linear issue that receives the weekly retro digest comment. |
schedule.observe | string | | When set, install also manages <prefix>-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 <config> 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 (<prefix>-tick, <prefix>-deliver, <prefix>-retro, <prefix>-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 | | |