45. Local Project-Level Config Overrides (.milk/)¶
- Status: accepted
- Date: 2026-09-15
- Updated: 2026-09-25 — aligned with implementation (agents merge-by-name, init --local full wizard)
Context¶
milk currently stores its entire configuration in a single global file
(~/.milk/config.json). Every milk config CLI subcommand, every /config
TUI command, and the auto-reload watcher all read from and write to that one
path. This works for single-project users but creates friction in several
situations:
-
Per-project agent tuning. A user working on a large monorepo may want higher
local_context_budget_charsor a differentcontext_window_tokensthan when working on a small script. Today they must edit the global config and remember to revert it. -
Per-project MCP servers. A project may have its own MCP server (e.g. a project-specific documentation indexer or a database schema tool) that should not pollute the global config seen by other projects.
-
Per-project escalation policy. Some projects may warrant always escalating (complex architecture), while quick utility repos should stay local. The routing rules and
default_routeare global today. -
Team-shared defaults. A team may want to commit a baseline milk config to the repo (e.g. recommended agent settings, MCP servers for the project's infra) alongside
.editorconfigor.claude/settings.json. The global~/.milk/config.jsonis inherently per-user and cannot be committed.
How other harnesses handle this¶
| Tool | Local config | Merge strategy | Prompt for scope |
|---|---|---|---|
| Claude Code | .claude/settings.json (project) vs ~/.claude/settings.json (user) |
Project overrides user, field by field | No — settings commands target the active scope based on CWD |
| Cursor | .cursor/settings.json in project root |
Merges with global, project wins | No — implicit from file location |
| Aider | .aider.conf.yml in project root + ~/.aider.conf.yml |
Project overrides global, same keys | No — precedence is implicit |
| GitHub Copilot | .github/copilot-instructions.md |
Appended to system prompt | N/A (instructions, not config) |
| Windsurf / Codeium | .windsurfrules / .codeiumignore in project root |
Project appended to global | No — implicit |
The universal pattern is: a dotfile/dot-directory in the project root overrides the global equivalent, field by field, with no interactive prompt about scope. The user controls scope by choosing where to put the file.
milk should follow this convention but go one step further: since milk's
config commands write to config programmatically (unlike tools that expect
the user to hand-edit), it should ask when a local config exists, so the
user doesn't accidentally write a project-specific change to their global
config or vice versa.
Decision¶
1. Local config path: .milk/config.json¶
When milk starts (or any config load occurs), it checks for
<cwd>/.milk/config.json in addition to ~/.milk/config.json. Both are
optional; if neither exists, defaults are used (existing behaviour). If only
one exists, it is loaded as today. If both exist, they are deep-merged:
- Local (
<cwd>/.milk/config.json) takes priority over global for every field that is explicitly set (non-zero, non-nil). - Fields absent from local fall through to global.
- Arrays (
agents,mcp_servers,agent_tools) use merge-by-name: if the local config defines anagentsentry whosenamematches a global entry, the local entry replaces it; new local names are appended. Same formcp_servers. This lets projects override specific agents or add project-specific ones without losing the user's global definitions. - Scalar fields, nested objects (
rules,otel,loop_detection), and pointer fields (show_reasoning,sticky_escalation) are deep-merged: local overrides only the fields it explicitly sets.
The merge function lives in internal/config as MergeConfigs(global, local Config) Config.
2. New LoadWithLocal() and SaveWithScope() functions¶
// LoadWithLocal loads and merges global + local config.
// localPath is "" when no .milk/config.json exists in cwd.
func LoadWithLocal(cwd string) (Config, string /*localPath*/, error)
// SaveWithScope writes cfg to the appropriate target.
// scope == "local" → <cwd>/.milk/config.json
// scope == "global" → ~/.milk/config.json
func SaveWithScope(cfg Config, scope string) error
The existing Load() and Save() remain unchanged for backward
compatibility. Load() is modified internally to call LoadWithLocal with the
current working directory so that the running config always reflects the
merge — no call-site changes needed for readers.
3. Dual-file watcher¶
The existing config.Watcher is extended to accept a second, optional path
(the local config). When either file changes, onChange is called with the
re-merged config:
func NewDualWatcher(globalPath, localPath string, onChange func(Config, error)) (*Watcher, error)
localPath may be empty (no .milk/config.json). The existing
NewWatcher(path, onChange) remains for callers that only care about the
global file (e.g. tests). The TUI's repl.go switches to NewDualWatcher.
4. milk config commands: scope prompt¶
When a .milk/config.json exists in cwd, commands that modify config (milk
config mcp add, milk config agent add, milk config open, and the TUI's
/config commands) print:
Apply to local project (.milk/config.json)? [Y/n]
Default is Yes (local) when a local config already exists, No (global)
when no local config exists yet. If the user answers "n" (or there is no local
config), the change goes to ~/.milk/config.json as today. If "Y" (or
Enter), the change goes to <cwd>/.milk/config.json, creating the .milk/
directory if needed.
A --local / --global flag on the CLI subcommands bypasses the prompt for
scripting:
milk config mcp add name=foo url=http://... --local
milk config mcp add name=foo url=http://... --global
4a. Creating a local config file¶
When no .milk/config.json exists yet, the user needs a way to bootstrap one.
Several entry points:
-
milk config init --localcreates a minimal.milk/config.jsoncontaining only{}(empty object — all fields inherit from global). The user can then run furthermilk configcommands to populate it, or open it in their editor. The.milk/directory is created if needed. -
Any
milk configcommand with the scope prompt (Decision #4): when the user answers "Y" (local) but no local config exists yet, milk creates.milk/config.jsonwith the single changed field — not a copy of the entire global config. This keeps the local file minimal and intentional. -
Manual creation: the user creates
.milk/config.jsonby hand (an empty{}is valid). milk detects it on next load/merge. This is the expected path for teams committing a project config to the repo.
milk config init (no flag) continues to target the global config. If a local
config already exists, it asks whether to reinitialise local or global (existing
Decision #5 behaviour).
5. milk config init respects local¶
If .milk/config.json already exists when milk config init runs, the wizard
asks whether to overwrite the local config or create/update the global one.
6. .milk/ in .gitignore guidance¶
The docs recommend adding .milk/config.json to .gitignore when it
contains secrets (API keys) and committing it when it contains only
team-shared settings (agent URLs, MCP servers, routing rules). The default
scaffolding does not auto-add .milk/ to .gitignore — that is the
team's decision, same as .claude/settings.json.
7. MILK_CONFIG environment variable¶
When MILK_CONFIG is set (existing behaviour), it overrides the global path
but does not disable local config — local is always <cwd>/.milk/config.json.
This matches the mental model: MILK_CONFIG replaces where the global file
is, not whether local overrides exist.
8. config open command¶
milk config open opens the local config file directly in the editor when
one exists. When no local config exists, it opens the global file (current
behaviour). When a local config is present it prints a note:
Editing local config (.milk/config.json).
Fields not set here are inherited from global (~/.milk/config.json).
Use --global to edit the global file instead.
Why not a temporary merged file?
The previous proposal opened a temp file containing the merged JSON and wrote the result back to local on save. This was rejected for several reasons:
-
VS Code
--waitsemantics.code --waitblocks until the tab is closed, not when the file is saved. Intermediate saves go to the temp file and are invisible to milk's watcher. Only on tab close does the editor-exit callback fire. Users expect saves to take effect immediately. -
Stale snapshot. The merged content is a point-in-time snapshot. If the global config is edited externally while the temp file is open, stale global values get "frozen" into the local config on save.
-
Back-write pollution. Saving the merged content to local writes every field — including those inherited from global. The local file bloats to a full copy of the config, and subsequent merges become meaningless (local has everything, global is ignored).
-
Temp file lifecycle. The temp file needs cleanup (crash-safe), collision handling (two concurrent
config opencalls), and a location — none of which are specified. The existing watcher infrastructure watches real files, not temp files.
How the direct-edit approach works:
- The editor opens
.milk/config.json(or~/.milk/config.jsonwith--global). - The watcher (Decision #3) detects any save to the opened file within ≤200ms, re-merges, and hot-reloads. Saves take effect while the file is still open.
- The user edits only the fields they want to override — the file stays minimal. Unset fields continue to inherit from global.
- This matches the editing model of Claude Code (
.claude/settings.jsonis edited directly, not as a merged view). - The editor-exit callback (
tea.ExecProcess) still fires on editor close and triggers a final re-merge, as a safety net.
Consequences¶
Positive:
- Per-project configuration is a common expectation (Claude Code, Cursor,
Aider all support it). Closing this gap removes friction for multi-project
users.
- Deep-merge with local priority is the standard pattern — no novel semantics.
- The dual-watcher extends the existing polling infrastructure rather than
replacing it.
- The scope prompt prevents accidental cross-project config pollution, which
is the one improvement over tools that use implicit precedence.
- config open edits the local file directly, keeping save semantics identical
to the current implementation — saves are detected by the watcher within
200ms, no temp-file lifecycle management needed.
- Multiple bootstrap paths for local config (init --local, scope prompt
answer, manual creation) cover all user workflows.
Negative:
- Deep merge adds complexity to the config loading path. The merge function
must handle every field type correctly (pointers, slices, nested structs).
Comprehensive unit tests are essential.
- Two config files to reason about in bug reports ("which config am I
actually using?"). A milk config show --sources command that annotates
each field with its origin (global / local / default) mitigates this.
- Merge-by-name semantics for agents may surprise users who expect full
replacement. The documentation must be explicit about this.
- config open shows only the local file, not the merged result. Users must
understand inheritance ("fields not set here are inherited from global").
milk config show with the merged view mitigates this.
Neutral:
- ~/.milk/config.json is unchanged for users who don't create a local
config. Zero migration, zero breakage.
- The MILK_CONFIG env var continues to work as before.