Skip to content

The repository environment

the repository environment: one typed snapshot of what a checkout is — project, layer, version control, toolchains, workflows, provider projections, local services — with a provenance entry for every value; the full and the fast resolution, the cache under `.ai/local/`, and the direnv adapter that renders it without reading the repository itself

Rendered from docs/ENVIRONMENT.md — the same Markdown GitHub shows.

What this checkout is right now — the project and its version, the repository and its layer, version control, the toolchains the repository declares, what the layer holds, the workflows a person can run here, the provider projections and the local services — is one typed value, environment::RepositoryEnvironment, built by one resolver under apps/majordomus-cli/src/environment/. Every surface that says any of those things renders that value: majordomus env on the command line, the HTTP route /api/v1/environment, the MCP resource majordomus://environment, and the banner direnv draws on entering the directory. None of them discovers anything of its own. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit.

The commands and their executable examples are in the generated reference (generated/cli.md, under majordomus env); the capabilities, their routes and their benchmark cases in generated/capabilities.md, module environment. Neither is restated here.

Two resolutions, one model

Counting what the layer holds means building the index, which costs seconds; the banner runs on every cd. So a snapshot is resolved in one of two modes, and both produce the same type:

  • full reads everything, the index included, and writes the cache. env status and env explain resolve in full, because a person is waiting for them.
  • fast reads only what is cheap — one git status, one just --dump, a few file reads — and takes the rest from the cache the last full resolution wrote. env banner, env export and env enter resolve fast, because direnv runs the last of them on every entry.

What no cache can supply is reported as unknown, never guessed and never defaulted: every tier of the snapshot carries its state (resolved, cached or unavailable), and an unavailable value is absent rather than zero. The two modes differ in what they may read, never in what they mean; the crate's tests hold them to that.

Provenance

Every field carries where it came from: the file, command or compile-time constant that decided it, the resolver that read it, and whether it was read now, taken from the cache or not resolved at all. env explain prints it for one field, a prefix or every field; the capability environment.explain answers the same over HTTP and MCP.

The cache

The cache lives under .ai/local/state/environment/, beside the shared server's lease and for the same reason: it belongs to this checkout, is never tracked, and a fresh clone starts without it. It holds counts, recipe names and their descriptions, and toolchain version strings — nothing read from the process environment, no file contents, no path outside the repository. Each tier carries its own fingerprint over its own inputs, so editing the justfile expires the workflows and leaves the counts alone. A cache that cannot be read is a miss, never an error, and writes are atomic. A served request — HTTP or MCP — never writes it: the cache is the command line's, and a GET with a side effect on the repository would be a defect.

The shell entry point

.envrc is an adapter and nothing else. It puts bin/ on the path, watches the lease so that the environment and the banner are re-evaluated when the shared server comes up or goes away, and evaluates one call: bin/majordomus-env enter --shell direnv. That one process writes the assignments — MAJORDOMUS_ROOT, MAJORDOMUS_SHARE, and MAJORDOMUS_URL when a server is running — on standard output, where direnv reads the environment it applies, and the banner on standard error; it refreshes the workflow bridge when a declaration behind it moved; and it makes sure this repository's shared server is serving this checkout. bin/majordomus-env finds the executable through lib/rust_bin.sh and never builds it: a missing executable is one line on standard error naming just build, and exit 0, because a non-zero exit on a cd makes direnv report that the whole environment failed. An executable older than its sources gets that same line and no runtime — the adapter sets MAJORDOMUS_RUNTIME=off — because a server started from stale code answers with a tree that is no longer there.

The server entry starts binds loopback, unless this machine's environment names another interface in MAJORDOMUS_HTTP_HOST. Entry inherits that variable and passes it on; it does not read it, and .envrc does not set it. Because .envrc.local is sourced after the one call above, a value written there arrives too late for the server that call starts: it belongs in the shell's own startup file (MCP.md).

The rules that hold this shut are project.envrc-is-an-adapter (.ai/repo/rules/project/envrc-is-an-adapter.v2.md) — a file a shell evaluates on entering the repository resolves the tool and makes exactly one call to it, the bootstrap command, and evaluates what that call exports; it reads nothing about the repository, builds nothing, reaches no network and decides nothing — and project.entry-converges, which says what that one call must and must not do. ADR 0043 is the decision, and docs/ENTRY.md the operator's page.

MAJORDOMUS_BANNER chooses auto, full, compact or off; MAJORDOMUS_RUNTIME=off enters without bringing the runtime up; NO_COLOR makes the output plain, and under CI there is no banner at all. The env group of the justfile (.just/env.just) carries the recipes a person runs.

Under the banner, entry draws the episode and compact preflight checks without repeating the repository heading: what is proven about this checkout, as opposed to what it is. The server and its surfaces, the briefing, the rules, the recorded test runs and the deployment each get a verdict with its evidence. It is a separate value, environment::preflight::Preflight, and PREFLIGHT.md describes it. The banner takes one thing from it: a service is drawn as answering only when the preflight verified the server behind it, never on the strength of a connection alone. The server publishes its URL only after its request workers start, so the lease-triggered reload can reach it. While it prepares its routes, its lease remains in the starting state.

What is canonical, and what is derived

FactCanonical source
project name, version, licence, summarythe crate manifest, at compile time
repository root, layer sections.ai/manifest.yaml, through the repository model
version controlone git status --porcelain=v2 --branch
toolchainsthe manifest that declares each one
what the layer holdsthe index, through the capability registry
workflowsjust --dump --dump-format json
provider projectionsthe policy's projections[]
servicesthe executable's own route constants and the shared server's lease

Nothing in the module writes outside .ai/local/, and nothing in it opens a socket to anything but the loopback address the lease names — and only when asked to probe.

Session telemetry

An agent session can export its own telemetry to the same OpenTelemetry endpoint that CI's validate traces reach (OTEL_EXPORTER_OTLP_ENDPOINT, the repository secret CI exports to). For a session the endpoint is the person's: it belongs to this machine's environment and is never written into the repository, not into .claude/settings.json, not into a template and not into a projection. The repository carries names and the switch, no value.

The one place that is the person's already is .envrc.local (ignored, sourced last by .envrc). A person who wants their sessions measured writes there, once, with their own values:

export OTEL_EXPORTER_OTLP_ENDPOINT=...   # the collector, yours
export OTEL_EXPORTER_OTLP_HEADERS=...    # only if the collector wants them
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_LOGS_EXPORTER=otlp

Claude Code reads these from the environment of the shell that starts it, which direnv supplies on entering the repository. With nothing set nothing is exported, and no tracked file differs. A person who prefers .claude/settings.local.json (also ignored) may put the same names in its env block; the tracked .claude/settings.json stays free of them.

Other providers name their switches differently (Gemini CLI reads GEMINI_TELEMETRY_*, Codex an [otel] table in its own configuration); the endpoint and headers stay the shared OTEL_EXPORTER_OTLP_* pair, and each provider's switch belongs in the same ignored, per-person place. majordomus env enter does not export any of these: it reads nothing about a person's endpoint, and a switch decided there would be a second place to hold it.

Searches the titles and descriptions of every page this site publishes.