Adds src/config/macro_policy.rs: MacroPolicy::effective computes the
visible macro set on demand from the discovered definition files, the
four-level enabled_macros allowlists, and the built-in command names.
- Discovery scans workspace (.coyote/macros/) then global macros dirs on
every resolution; workspace shadows global by name, and the shadowed
global entry is retained and flagged so both stay listable (plan
custom-commands-design.md §5). Workspace scanning is gated on a bool
parameter so the future --no-workspace-macros flag wires in one line.
- Allowlist precedence is session > agent > role > global, first Some
wins, no merging; None falls through, an empty list is an explicit
zero, all-None enables everything (mirrors SkillPolicy).
- Per-macro states per plan §6: enabled, disabled (runtime, global-level
exclusions only), locked (role/agent/session exclusions, recording the
owning level), missing (unknown allowlist names warn instead of
bailing — deliberate divergence from skills), shadowed (built-in name
collisions), and invalid (parse failures and the reserved names
enable/disable). Invalid beats allowlist exclusion beats shadowing.
- Adds enabled_macros() accessors on Role, Session, and Agent alongside
their enabled_skills() counterparts, plus paths::workspace_macros_dir.
- 37 tests: state matrix, pairwise precedence, explicit-zero pinned at
every level, workspace shadowing, reserved names, builtin collisions,
missing rows, invalid YAML, and env-gated discovery (#[serial]).
Mirrors the enabled_skills plumbing per plans/custom-commands-design.md §5:
- global: Config + AppConfig structs, from_config copy, and the
COYOTE_ENABLED_MACROS env-override arm (csv_to_vec parsing)
- role: frontmatter via parse_string_or_array (list or csv string),
plus the export() mirror so Role::save round-trips the field
- agent (non-graph): plain serde on AgentConfig; graph.yaml silently
ignores the key (pinned by test, no field on Graph by design)
- session: plain serde with csv-or-vec deserializer
Empty list/string deserializes to Some([]) (explicit zero), distinct
from absent/null (None) — pinned by tests at every level, including
the env arm (serial-fenced against the from_config tests, which read
the process env via load_envs).