docs(skills): clarify that keyed-merge requires a single identifier key per array

Review feedback (PR #2284) flagged that the merge-rules wording was
ambiguous: "every item has a `code` or `id` field" could reasonably
be read as "each item individually has at least one of the two",
allowing arrays to mix `code` and `id` across items.

The resolver has always required all items share the *same* identifier
key (all `code`, or all `id`). Mixed arrays fall through to append —
intentional, because mixing identifier keys within one array is a
schema smell and any guess about which key should merge creates a
worse trap than the append-fallback.

Clarified in three places:
- Merge-rules table in customize-bmad.md: "every item shares the
  **same** identifier field"
- `code`/`id` convention paragraph: "pick **one** convention ... and
  stick with it across the whole array"
- Resolver docstring and `_detect_keyed_merge_field` docstring:
  explicit note that mixed arrays fall through with rationale

No behavior change.
This commit is contained in:
Brian Madison 2026-04-19 14:36:30 -05:00
parent f51159bf83
commit 80ba89eb52
2 changed files with 15 additions and 6 deletions

View File

@ -44,14 +44,14 @@ The resolver applies four structural rules. Field names are never special-cased
|---|---|
| Scalar (string, int, bool, float) | Override wins |
| Table | Deep merge (recursively apply these rules) |
| Array of tables where every item has a `code` or `id` field | Merge by that key — matching keys **replace in place**, new keys **append** |
| Any other array (scalars, mixed, tables without `code`/`id`) | **Append** — base items first, then team items, then user items |
| Array of tables where every item shares the **same** identifier field (every item has `code`, or every item has `id`) | Merge by that key — matching keys **replace in place**, new keys **append** |
| Any other array (scalars; tables with no identifier; arrays that mix `code` and `id` across items) | **Append** — base items first, then team items, then user items |
**No removal mechanism.** Overrides cannot delete base items. If you need to suppress a default menu item, override it by `code` with a no-op description or prompt. If you need to restructure an array more deeply, fork the skill.
#### The `code` / `id` convention
BMad uses `code` (short identifier like `"BP"` or `"R1"`) and `id` (longer stable identifier) as merge keys on arrays of tables. If you author a custom array-of-tables that should be replaceable-by-key rather than append-only, give every item a `code` or `id` field.
BMad uses `code` (short identifier like `"BP"` or `"R1"`) and `id` (longer stable identifier) as merge keys on arrays of tables. If you author a custom array-of-tables that should be replaceable-by-key rather than append-only, pick **one** convention (either `code` on every item, or `id` on every item) and stick with it across the whole array. Mixing `code` on some items and `id` on others falls back to append — the resolver won't guess which key to merge on.
### Some agent fields are read-only

View File

@ -21,9 +21,11 @@ no virtualenv — plain `python3` is sufficient.
Merge rules (purely structural no field-name special-casing):
- Scalars (string, int, bool, float): override wins
- Tables: deep merge (recursively apply these rules)
- Arrays of tables where every item carries a `code` or `id` field:
- Arrays of tables where every item shares the *same* identifier
field (every item has `code`, or every item has `id`):
merge by that key (matching keys replace, new keys append)
- All other arrays (scalars, mixed, or tables without code/id):
- All other arrays including arrays where only some items have
`code` or `id`, or where items mix the two keys:
append (base items followed by override items)
No removal mechanism overrides cannot delete base items. To suppress
@ -92,7 +94,14 @@ def load_toml(file_path: Path, required: bool = False) -> dict:
def _detect_keyed_merge_field(items):
"""Return 'code' or 'id' if every table item carries that field, else None."""
"""Return 'code' or 'id' if every table item carries that *same* field.
All items must share the same identifier (all `code`, or all `id`).
Mixed arrays where some items use `code` and others use `id`
return None and fall through to append semantics. This is intentional:
mixing identifier keys within one array is a schema smell, and
append-fallback is safer than guessing which key should merge.
"""
if not items or not all(isinstance(item, dict) for item in items):
return None
for candidate in _KEYED_MERGE_FIELDS: