Config pipeline
User-facing merge order is in configuration.md. This page is how the loader is implemented and which file to change for a new field.
Pipeline (adapters/config/load.ts)
discover(startDir, --config)
→ loadPath(repoRoot, configPath)
decodeFile(main YAML) // decode.ts + merge.applyRoot
loadModular(.devctl/{services,profiles,http,proxy/routes.yaml})
applyLocalOverlays // ~/.devctl/config.local.yaml then .devctl/config.local.yaml
applySessionOverlay // .devctl/overlays/<name>.yaml when load({ overlay }) / state.json config_overlay
migrate()
applyTemplates()
mergeServiceProxyRoutes()
applyProxyCredentials()
provenance = presence.provenance
validate() // throw KindConfiguration if any issueload uses discover then loadPath. loadOrEmpty catches only KindConfigurationMissing.
validateConfigText(repoRoot, configPath, text) substitutes candidateText at the main-file read so the TUI buffer (v / /buffer) runs the real pipeline, including modular files and overlays.
Discovery (discover.ts)
Walk from cwd (or --config) upward:
<dir>/.devctl/config.yaml<dir>/devctl.yaml
--config pointing at a directory named .devctl → repo root is its parent. Pointing at a file → repo root is that file’s directory, or parent if the directory is .devctl.
Decode vs schema
| Mechanism | File | Catches |
|---|---|---|
| JSON Schema | schema/devctl.config.schema.json | Types, required version: 1, documented fields |
| Unknown keys | strict.ts + known.ts | Extra YAML keys (additionalProperties analogue) |
| Semantic rules | validate.ts | Cycles, duplicate ports, bad refs, plugin types, … |
| Authoring rules not in the schema | skills/devctl-onboard/references/authoring.md | Loader-only constraints agents miss if they only read the schema |
schema-parity.test.ts fails if schema properties drift from known.ts / decode. When you add a field:
domain/config/types.ts(+empty*/ defaults)decode.ts/merge.ts(presence-aware so overlays do not clobber with empty)known.ts+schema/devctl.config.schema.jsonvalidate.tsif there is an invariantmigrate.tsif old files need a rewrite- Tests in
validate.test.ts/load.test.ts/schema-parity.test.ts
Do not author YAML from the schema alone.
Modular files
Under .devctl/:
services/*.yaml— keyed by filename stem;mergeServiceif the main file already defined the nameprofiles/*.yamlhttp/*.yaml— recipesproxy/routes.yaml— may setproxy:scalars and appendroutes
Unknown fields are checked with a prefix (services.api.…) so errors name the service.
Templates
templates: in YAML merge into services that extends: them (applyTemplates). Template fields are defaults; the service wins.
Provenance
merge.ts records { source, layer } per path. Layers include main, modular_service, home_local, repo_local, session_overlay, …. configDiff / MCP get_config_sources expose this. config.local.yaml overlays are gitignored; named .devctl/overlays/<name>.yaml files may be committed. They still show up in provenance.
load / loadPath / loadOrEmpty take opts.overlay (the stem). Reload reads the recorded name from ReloadHost.configOverlay (persisted as state.json config_overlay, sticky like profile). Missing file: overlay "X" not found: .devctl/overlays/X.yaml.
Environment references
refs.ts resolveEnvMap expands ${services.*.ports.*}, ${identity.user}, ${http.*.*}, and ${env.NAME} / ${NAME} from process env plus .devctl/secrets.env. Service env still rejects ${token}. Recipe URL/headers/body, proxy route strings (including .devctl/proxy/routes.yaml), and IAP client_secret / credentials / audience expand ${env.NAME} at fetch/request/mint time. config validate accepts those templates without requiring the variable to be set.
TUI preferences (separate pipeline)
Not YAML. tui-preferences.ts:
- Hardcoded defaults, then
config.yamlui.keymap DEVCTL_TUI_CONFIG/OPENCODE_TUI_CONFIG→ that file only (writes stay in-session)- Else merge, later wins: team
./tui.jsonor.devctl/tui.json→~/.devctl/tui.json→~/.devctl/state/<repoID>/tui.json
Settings default to the repo overlay. MCP listen always writes the repo file. dismissed_notifications stay in the user file. Allowlisted stack overlay writes (web.enabled, web.listen.port) go to .devctl/config.local.yaml then reload + syncWebListener.
Reload
adapters/daemon/reload.ts re-runs load with the recorded session overlay, diffs with domain/config/snapshot.ts, records restartRequired, reapplies plugin registry, rebinds health/LLM factories. Live processes are not killed until the user restarts (or watch/reload policy says so). Failed reload publishes ConfigurationReloadFailed and keeps the last-known-good in-memory config. ProxyCoordinator.applyConfig hot-swaps routes when listen is unchanged; a bind change recreates that listener; proxy.enabled: false stops a running proxy; proxy.enabled: true starts a stopped proxy unless the user has suppressed it.
Demo fixture
demo.test.ts / examples/demo-platform/.devctl/ is the CI-validated real config. Prefer adding cases there over inventing YAML in unit tests when testing the full loader.