Skip to content

Configuration

One file: .multivac/config.yml, in the brain. It is a registry, not a plugin system — it selects entries the tool already ships, by name, and declares where the repos are. It never defines behaviour.

init writes it. Every key below is optional; a file containing nothing but {} loads with every default applied. Every validation error names the key and the fix.

What init writes with no flags:

# multivac configuration — seeded by `multivac init`.
# Edit directly; adopting a new agent later is one line here + `multivac doors`.
doors: [agents]
# repos:
#   backend: ../backend   # bare string = { path }

A filled-in one:

doors:   [agents, claude, cursor]
sdd:     opsx
grapher: graphify
authorities: [published, specified, open]
blocking: [absent, count, each]
staleness: block
strict_pre_push: true
channel: origin/main
mount: .brain
repos:
  api: ../acme-api
  payments:
    url: git@example.com:acme/payments.git
    path: ../payments
    grapher: codegraph
    channel: origin/release

Changing it needs an open change (MV-97)

This file decides which repos exist, which adapters bind and which gates run. Every one of those is as load-bearing as a law row, so a staged modification is refused while no change is open:

config    .multivac/config.yml is modified and no change is open — it decides which repos are verified and which gates run
          open one first (`multivac change new "<title>"`), or drop the edit

Creating one is free — a brain has to start somewhere, and init is the only thing that writes this file, only when it is absent. So the rule reads what the commit does rather than who claims to have done it.

Any open change satisfies it, including one opened for this very edit. The stronger reading — a change that names this file — would need a field the change file does not have. What this buys is that the edit lands on a branch with a merge request describing it.

It reads the index, not the working tree: the index is what is about to be committed.

Top-level keys

doors

typelist of strings — registry target names
default[]
exampledoors: [agents, claude, cursor]

Which harness door targets doors projects, and which ones doctor reports on. Names must exist in the shipped registry; see Agent integrations for the eight entries.

doors and --provider are not the same list, which is why the flag adds to this key rather than being it. --provider answers which coding agents do you useclaude, cursor, copilot. doors records which doors are projected, and the first one always is agents: the canonical AGENTS.md, which is not an agent anyone installs but the format the others project from. Naming this key providers would put a non-provider at the head of every list.

Without it: doors still writes the canonical AGENTS.md into the brain and every present repo, because that write is unconditional — but no symlink, no stub, no skill, no harness hook is installed for any vendor. doctor says so:

doors      none declared — add doors: [agents] to .multivac/config.yml

An unknown name is a notice from doors and a line from doctor, never a crash:

doors      nope: unknown target — known: agents, claude, cursor, opencode, codex, windsurf, gemini, copilot; fix doors: in .multivac/config.yml

sdd

typestring — one of opsx, speckit
defaultunset
examplesdd: opsx

Selects the spec-driven-development adapter whose propose / apply / archive steps run inside the change lifecycle. See Graphers and SDD.

Without it: silence. No SDD step runs, doctor prints no sdd line at all. Not declaring is different from declaring something absent — the first is “we do not use one”, the second is “we use one, it is not on this machine”.

sdd_auto

typeboolean
defaulttrue
examplesdd_auto: false

Whether the declared sdd adapter runs automatically at change new, change apply and change close.

Without it: the workflow is automatic. Set it to false to keep the adapter declared — doctor still reports it — while running its steps by hand:

sdd        opsx: artifact ok · binary ok · sdd_auto: false — the lifecycle prints nothing and gates nothing; run the steps yourself

--no-sdd on a single change invocation does the same thing once, without editing the config.

grapher

typestring — any tool name
defaultunset
examplegrapher: graphify

The code-graph tool for the brain, and the fallback for every repo that does not override it. The name must be one multivac speaksgraphify or codegraph — or one you declare yourself under graphers. multivac never derives an artifact path or a refresh command from a name, because inventing either is inventing a fact.

Two, deliberately. Speaking a grapher means knowing what it can answer, so the brain door can tell an agent which verb to reach for; that is earned one tool at a time, not scaled by adding rows. Any other tool still works through graphers with no merge request against multivac — it simply gets no query lines in the door, because multivac does not know its verbs.

Without it: no grapher lines in doctor, no refresh hint at the end of change close. A newborn brain is two content files; graphing that is noise, which is why init declares no grapher unless it detects one.

tracker

Which issue tracker the roadmap projects to: gitlab, github, or absent.

tracker: gitlab

Root level only. Unlike sdd: and grapher:, which act on each repo’s files, the tracker projects the change — and changes live only in the brain, so a per-repo override would answer a question nobody can ask.

Projection is one way and runs only from multivac roadmap sync. It reaches the network, so it never runs from verify, doctor or doors.

grapher_auto

Default true. When false, the graph gate never refuses — the declared tool still runs, the graph is still built and refreshed, and change close simply stops requiring the artifact.

grapher_auto: false

Named after sdd_auto and parsed the same way, because two adapters with two vocabularies for one idea is a tax on every reader.

It answers a different question from grapher: none on a repo. grapher: none says do not graph this repo; grapher_auto: false says graph it, but do not refuse my close over it. Neither substitutes for the other, and an operator forced to un-declare their tool to get a change closed will un-declare it permanently.

See MV-90 and --no-grapher in commands for the per-run form.

graphers

typemapping of name -> { artifact, refresh, create?, binary?, install? }
default{}
examplesee below

Contracts for graphers the shipped registry has not verified. This is what makes an unverified tool usable without a merge request against multivac:

grapher: mytool
graphers:
  mytool:
    artifact: .mytool/index.db   # repo-relative path the tool writes, file or directory
    refresh: mytool index        # the one command safe to re-run
    create: mytool init          # optional, when the build differs from the refresh
    binary: mytool               # optional, defaults to the first word of refresh
    install: pipx install mytool # optional, printed when the binary is missing

artifact and refresh are required. A declaration also overrides a shipped registry entry — you know your own install better than the table does.

Without it: a grapher: naming an unverified tool is reported as unverified, with these exact fields to fill in, and nothing is run.

repos.<key>.role

Optional. One line saying what a repo is for, rendered in the ecosystem list every door carries.

repos:
  api:
    path: ../acme-api
    role: the contract every surface consumes
  web: ../acme-web

Declared or omitted, never derived — what a repo is for is not in its path, and api — api is worse than silence. A repo with no role has its entry stop at the path.

A role written across several lines is reduced to one, because the list is a list.

authorities

typelist of strings
default[]
exampleauthorities: [published, specified, open]

The vocabulary your law table’s authority column draws from — how hard a claim binds, from “published to customers” down to “still an open question”.

Read but not yet enforced. The loader parses and validates this key, and nothing in the current build consumes it: no command rejects a row whose authority is outside the list. Declare it as documentation for your team and your agent; do not expect it to gate anything today.

blocking

typelist of anchor modes — from present, absent, unique, count, each
default[absent, count, each]
exampleblocking: [absent, count, each, unique]

Which anchor modes make a broken leg exit 1 under the default policy. Everything else is reported and exits 0 unless you pass --strict.

Without it: tombstones (absent), counted claims (count) and universals (each/each!) gate; presence and uniqueness report. That asymmetry is the point — a rename should not kill your commit, but calling a dead endpoint should.

You may widen the set. You may not narrow it below the tombstone:

$ mvac verify
.multivac/config.yml: "blocking" must include "absent" — the tombstone always blocks; add it back

An unknown mode is refused with the allowed list:

.multivac/config.yml: "blocking" has unknown mode "sometimes" — allowed: present, absent, unique, count, each

staleness

typereport or block
defaultreport
examplestaleness: block

What happens when a consumer repo’s pinned brain mount is behind the declared channel. report prints the line and exits 0; block makes it a verify failure.

Without it: stale pins are reported, never gating. Under block, a resolvable stale pin exits 1 with the sync command in the line — but a channel ref that does not resolve locally still only reports, because offline never guesses and never gates:

  stale?    api: channel origin/main unknown locally — reported only, cannot gate offline; `multivac repos sync` fetches it

A pin ahead of the channel is not stale and never gates.

.multivac/config.yml: "staleness" must be "report" or "block" — block makes a stale pin exit 1

strict_pre_push

typeboolean
defaultfalse
examplestrict_pre_push: true

Whether doors writes the pre-push shim as mvac verify --strict instead of mvac verify.

Without it: both hooks run the default policy. Turning it on keeps commits permissive and makes a push gate on the presence and uniqueness legs as well — the last hop out of the machine held to a harder bar than a commit anyone can still amend. It only takes effect the next time doors (or init) rewrites the shims. See Hooks.

channel

typestring — a git ref
defaultunset for pin staleness; origin/main for what a brain-scoped verify reads
examplechannel: origin/main

The ecosystem as published. Per-repo repos.<key>.channel overrides it. The key answers two questions:

  1. Which bytes a brain-scoped verify judges (MV-53). Every declared repo is read at its channel ref — resolved in that repo — not at its working tree, so a sibling parked on a WIP branch never reddens the brain’s law. The brain’s own repo is the exception: always its working tree, because that is the commit the run gates. Undeclared, this defaults to origin/main; a ref that does not resolve there falls back to the working tree and says so on that repo’s read line. --worktree forces the working-tree read across the whole ecosystem. The ref is a local remote-tracking snapshot — verify never fetches — so the read line also names how old it is; mvac repos sync is what refreshes it.
  2. What each consumer’s brain-mount pin is compared against, resolved in the brain checkout. This one has no default: undeclared, verify skips the staleness check for that repo entirely — there is nothing to compare to. doctor falls back to the brain’s remote-tracking branch if there is one, and otherwise says what to add:
pins       api: pin 8f2a1cc — no channel ref to compare; set channel: in .multivac/config.yml

doctor also names the branch each repo is parked on and whether it is that repo’s channel — the line that explains a verify result at a glance:

branches   api: on wip/refactor @ 4d5e6f7 — OFF channel origin/main @ 1a2b3c4; verify reads the channel, not this tree

mount

typestring — a repo-relative directory
default.brain
examplemount: docs/brain

Where each consumer repo mounts the brain, as a git submodule. Both the staleness check and doctor’s pins line read the gitlink at this path.

Without it: .brain, which is also the name verify prefers when it runs from a consumer repo and has to find the brain. If a repo has no gitlink there:

pins       api: no brain mount at .brain — add the brain as a gitlink (git submodule add <brain-url> .brain)

requires

The minimum multivac this team will trust. Hand-authored — the tool never writes this field, because a floor is a decision and multivac does not answer for a human’s decisions.

requires: ">=X.Y.Z"

Grammar is >=X.Y.Z and nothing else. A floor gets a floor’s grammar: ^0.3 or >=0.3 <1 needs a semver range parser, which would be a third runtime dependency, and the law pins the count (MV-02). A malformed value is refused by name, not ignored — silently dropping it would leave you believing a gate is declared that is not.

A binary below the floor gets the loudest notice on every run and is not refused. Nothing here changes an exit code: enforcement degrades, it never locks you out (MV-86).

repos

typemapping of key → path string, or key → { path, url, grapher, channel }
default{}
examplesee below

The ecosystem. Every anchor’s <repo> prefix is one of these keys — plus two built-ins you do not declare: brain (the brain itself) and * (every declared repo).

repos:
  api: ../acme-api                     # bare string = { path: ../acme-api }
  payments:
    url: git@example.com:acme/payments.git
    path: ../payments                  # optional; defaults to ../<key>
    grapher: codegraph                 # overrides the global grapher
    sdd: opsx                          # overrides the global sdd; `none` = no SDD here
    channel: origin/release            # overrides the global channel

sdd: per repo. Declared adapters reach every declared, present repo: the change lifecycle runs the tool’s own init in each one that lacks it, and doctor reports each one by name. A repo that should have no spec-driven flow says so in its own entry:

repos:
  landing:
    path: ../acme-landing
    sdd: none

none is out of scope, not a gap: that repo is never scaffolded, never gated on the SDD’s project-level document, and never reported as lacking anything. An absent sdd: inherits the ecosystem’s — it does not mean none.

sdd        speckit @ brain: artifact ok · binary ok · sdd_auto on
sdd        speckit @ api: artifact missing (looked for .specify) — declared but never run here; `change new` runs the tool's own `specify init …`, doctor never does (it reaches the network)
sdd        none @ landing: no sdd declared for this repo — out of scope, not a gap

Paths are resolved relative to the brain directory. An entry with only a url is legal — the repo is declared before it is cloned, and its anchors report unevaluated rather than red:

  unevaluated INV-04 [present] .multivac/invariants.md:12 · repo not on disk — run `multivac repos sync` to clone it

Without it: nothing to verify against except the brain handle itself. doctor says so:

repos      none declared — add repos: to .multivac/config.yml

seed writes the same finding into its report — No repos declared — add them under repos: in .multivac/config.yml — and doors just writes the brain’s own door and stops.

* is reserved outright — it already means “every repo” in an anchor leg:

.multivac/config.yml: repos."*" is a reserved key — "*" means every repo in anchor legs; rename the repo

brain has exactly one legal meaning: brain: ., the brain==code declaration init writes when the brain is its own code repo. Pointed anywhere else it would let a consumer-scoped verify evaluate the brain’s own anchors against a consumer checkout, so it is refused:

.multivac/config.yml: repos.brain must be the brain itself (path .) — it is "../elsewhere"; rename the repo

An entry with neither path nor url cannot be located:

.multivac/config.yml: repos.api needs "path" or "url" — add path: ../api

Errors are exit 2

A config that does not load is an environment error, not a failed check. Every command that reads it exits 2 and prints one line naming the key and the repair. doors and doctor are the two exceptions and exit 1: for those an unloadable config is the diagnosis they were asked for, not an environment they failed to read.

$ mvac verify
.multivac/config.yml: top level must be a mapping of keys, not a list or scalar
$ mvac verify
no .multivac/config.yml in /private/tmp — run `multivac init .` to create it

.multivac/projected.yml — not config

A second file lives beside the config, and it is not yours to edit:

# Written by multivac, never by hand.
version: X.Y.Z

It records the version this brain was deliberately brought to — not whatever binary last touched it. init writes it; mvac doors --adopt moves it; nothing else does. Bare mvac doors re-projects and leaves it alone on purpose, so the notice survives a run you made for an unrelated reason.

Upgrading the binary does not upgrade a brain: npm i -g multivac@latest replaces the projector, not the projections it already wrote. The record is what lets every command tell you the two have drifted, and name the command that closes it. It is provenance, not integrity — it says which version wrote these files, never that they still are what was written (MV-86).

Layout

The config lives at .multivac/config.yml, and everything else multivac creates lives beside it: the law, the changes, the ritual, the hooks, the gitignored cache and worktrees. AGENTS.md at the repo root is the one exception, because that is where harnesses look. Your own content is never under .multivac/ — the line is the user’s files versus multivac’s artifacts.

AGENTS.md                  the door
.multivac/config.yml       this file
.multivac/invariants.md    the law table
.multivac/changes/         one file per ecosystem change
.multivac/ritual.md        the closing ceremony
.multivac/hooks/           pre-commit and pre-push shims
.multivac/cache/           gitignored
.multivac/worktrees/       one checkout per open change, gitignored

A brain that still keeps invariants.md or changes/ at its root is the pre-.multivac/ layout: every command refuses it and names multivac init ., which migrates with git mv so history follows. It never moves a file multivac did not write.