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/releaseChanging 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 editCreating 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
| type | list of strings — registry target names |
| default | [] |
| example | doors: [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 use — claude, 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.ymlAn 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.ymlsdd
| type | string — one of opsx, speckit |
| default | unset |
| example | sdd: 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
| type | boolean |
| default | true |
| example | sdd_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
| type | string — any tool name |
| default | unset |
| example | grapher: 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 speaks — graphify 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: gitlabRoot 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: falseNamed 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
| type | mapping of name -> { artifact, refresh, create?, binary?, install? } |
| default | {} |
| example | see 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 missingartifact 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-webDeclared 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
| type | list of strings |
| default | [] |
| example | authorities: [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”.
blocking
| type | list of anchor modes — from present, absent, unique, count, each |
| default | [absent, count, each] |
| example | blocking: [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 backAn unknown mode is refused with the allowed list:
.multivac/config.yml: "blocking" has unknown mode "sometimes" — allowed: present, absent, unique, count, eachstaleness
| type | report or block |
| default | report |
| example | staleness: 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 itA 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 1strict_pre_push
| type | boolean |
| default | false |
| example | strict_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
| type | string — a git ref |
| default | unset for pin staleness; origin/main for what a brain-scoped verify reads |
| example | channel: origin/main |
The ecosystem as published. Per-repo repos.<key>.channel overrides it.
The key answers two questions:
- Which bytes a brain-scoped
verifyjudges (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 toorigin/main; a ref that does not resolve there falls back to the working tree and says so on that repo’sreadline.--worktreeforces the working-tree read across the whole ecosystem. The ref is a local remote-tracking snapshot —verifynever fetches — so thereadline also names how old it is;mvac repos syncis what refreshes it. - What each consumer’s brain-mount pin is compared against, resolved
in the brain checkout. This one has no default: undeclared,
verifyskips the staleness check for that repo entirely — there is nothing to compare to.doctorfalls 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.ymldoctor 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 treemount
| type | string — a repo-relative directory |
| default | .brain |
| example | mount: 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
| type | mapping of key → path string, or key → { path, url, grapher, channel } |
| default | {} |
| example | see 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 channelsdd: 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: nonenone 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 gapPaths 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 itWithout it: nothing to verify against except the brain handle itself.
doctor says so:
repos none declared — add repos: to .multivac/config.ymlseed 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 repobrain 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 repoAn entry with neither path nor url cannot be located:
.multivac/config.yml: repos.api needs "path" or "url" — add path: ../apiErrors 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.ZIt 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, gitignoredA 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.