Skip to content

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) ​

text
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 issue

load 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:

  1. <dir>/.devctl/config.yaml
  2. <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 ​

MechanismFileCatches
JSON Schemaschema/devctl.config.schema.jsonTypes, required version: 1, documented fields
Unknown keysstrict.ts + known.tsExtra YAML keys (additionalProperties analogue)
Semantic rulesvalidate.tsCycles, duplicate ports, bad refs, plugin types, …
Authoring rules not in the schemaskills/devctl-onboard/references/authoring.mdLoader-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:

  1. domain/config/types.ts (+ empty* / defaults)
  2. decode.ts / merge.ts (presence-aware so overlays do not clobber with empty)
  3. known.ts + schema/devctl.config.schema.json
  4. validate.ts if there is an invariant
  5. migrate.ts if old files need a rewrite
  6. 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; mergeService if the main file already defined the name
  • profiles/*.yaml
  • http/*.yaml — recipes
  • proxy/routes.yaml — may set proxy: scalars and append routes

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:

  1. Hardcoded defaults, then config.yaml ui.keymap
  2. DEVCTL_TUI_CONFIG / OPENCODE_TUI_CONFIG → that file only (writes stay in-session)
  3. Else merge, later wins: team ./tui.json or .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.

Released under the MIT License. Use at your own risk.