Config Composition — per-stack .coding-os/ merge at cos init
P: SSOT for how
cos initcomposes the consumer's.coding-os/cognitive configs (rag-config.yaml,scrumban-config.yaml,domain-config.json) from the base defaults + every installed stack's overlay, data-driven and multi-stack-correct. R: Editingsrc/cli/config_composer.py, the init scaffold flow, or any stack's.coding-os/*config; debugging why a stack's board/RAG settings are missing. S: Authoring CODE-lane boundaries — that's scaffold-boundary-contract.md. N: scaffold-boundary-contract.md, meta-project.md, docs-system.md
Nav: Parent Index
Why this exists — the bug it replaces
_overlay_scaffold copies _base/scaffold/ first, then each stack's
scaffold/, and is first-writer-wins (if dest.exists(): continue, for
idempotency vs the user's own files). For files that exist at the SAME path in
both base and a stack — the three .coding-os/* configs — base always won and
the stack's version was silently dropped. A cos init --template nextjs
project shipped the BASE Scrumban board (backend swimlanes), not the nextjs one;
react-native's rag-config.yaml override never applied. Multi-stack projects
(e.g. nextjs + go-fiber) could never get both stacks' swimlanes.
The fix: the three configs are EXCLUDED from the overlay and instead composed
by src/cli/config_composer.py — base defaults deep-merged with every installed
stack's overlay, in install order, with per-file merge semantics.
Composition contract
- Source of truth:
_base/scaffold/.coding-os/<file>provides stack-agnostic defaults; each<stack>/scaffold/.coding-os/<file>ships only its DELTA. - Data-driven: the composer iterates the installed
templateslist (Rule 11 — no hardcoded stack literals) and reads each stack's overlay fromsrc/templates/<stack>/scaffold/.coding-os/. - Idempotent: the composed file is written only when the target does not
already exist — a re-run of
init/update, or a user-edited config, is never clobbered (same contract as_overlay_scaffold). - Order: base first, then stacks in the order they appear in
templates; on a key collision the LATER source wins (stack overrides base; a later stack overrides an earlier one).
Per-file merge semantics
The merge is spec-driven — one strategy table per file in config_composer.py.
| File | Key | Strategy |
|---|---|---|
rag-config.yaml |
sources |
union by path (later wins on collision → tune priority/chunk_size; new paths appended) |
exclude |
list union (order-preserving, deduped) | |
graph.enforce_context_on |
list union | |
scrumban-config.yaml |
swimlanes |
union by id (stack lane overrides a base lane of the same id; new lanes appended) |
wip_limits |
dict override (stack keys override base keys) | |
workflow_policy |
dict override | |
label_families |
union by name |
|
domain-config.json |
refs_by_tag |
per-tag list union |
domain_map, playbook_map |
dict merge (stack adds/overrides keys) | |
default_refs, default_domain, default_playbook |
scalar/list override |
Strategy vocabulary (spec values): union_by:<key> · union_list · dict_merge
(recursive, scalar-override) · dict_union_lists (per-key list union) ·
override (later wins wholesale).
Consumers (verify field names against these — Rule: producer is SSOT)
| File | Consumer | Reads |
|---|---|---|
scrumban-config.yaml |
src/core/board_os/config.py::parse_config |
swimlanes[].{id,label,color,accent,description}, wip_limits, workflow_policy, label_families[].{name,color,emoji} |
rag-config.yaml |
src/core/thinking_os/doc_indexer.py::load_rag_config / walk_sources |
sources[].{path,type,chunk_size,chunk_overlap,priority,exclude}, exclude |
domain-config.json |
Bash scripts + skills (config chain, Rule 4) | refs_by_tag, domain_map, playbook_map, default_* |
Presets — named stack compositions (TASK-356)
A preset is a named, validated stack list the user can pick instead of composing stacks by hand. Data model:
- One file per preset:
src/templates/_presets/<id>.yaml— the_presetsdir has nostack.yaml, so the stack loader ignores it. Schema:src/core/schemas/preset.schema.json(required:version: 1,id= filename stem,label,stacks[]minItems 1; optionaldescription,skills[]extra skills,modulessubsystem toggles,notes). - Loader:
src/cli/preset_registry.py::load_preset_registry(templates_dir)→{presets, warnings}. A preset referencing an unknown stack id is skipped with a WARN (fail-soft, same posture as the stack loader).skills/modulesare stored into the project's.coding-os.yamlverbatim — linking extra skills is TASK-370, subsystem toggle behavior is TASK-349. Modules can also be toggled per-create independent of a preset viacos init --profile <id>,--disable-module <id>, and--enable-module <id>(all repeatable where sensible; the Composer's module chips feed the same path) — merged intoconfig["modules"]before the scaffold copy. - CLI:
cos init --preset <id>(mutually exclusive with--template) expands to the preset's stack list and then follows the normal init flow — relocation, composition, and every derived artifact behave exactly as if the stacks were passed by hand. Discovery:cos list-stacksprints a Presets section (and apresetskey in--format json); the hub exposesGET /api/hub/presets— each entry carriesprovenance: core|user(user presets come from~/.coding-os/presets/and are badged "yours" in the composer so they never masquerade as shipped compositions).
Merge preview + conflict surfacing (TASK-356)
cos init --dry-configcomputes the composed.coding-os/*configs for the requested stacks/preset, prints the merged summary (swimlane union + per-file conflict list) and exits without writing anything — the wizard's preview source.- Conflicts are reported, never silent.
compose()records every same-key/different-value collision (union_byrow replacement and scalar override alike) as<file>: <key>[<id>]: <old> → <new> (winner: <source>).cos initechoes them as WARN lines;--dry-configlists them in the preview. Later-wins stays the resolution rule — the report makes the resolution visible, it does not change it.
Anti-patterns
- Re-adding a
.coding-os/*config to the overlay copy path — it will silently shadow the composed output (first-writer-wins). - A stack shipping a FULL duplicate of base instead of a delta — it drifts (the
pre-fix react-native
rag-config.yamldid exactly this). Ship only additions. - Hardcoding the stack list in the composer — iterate
templates(Rule 11). - Merging a board (
swimlanes) as wholesale override — a multi-stack project then loses one stack's lanes. Union byid.