From 9863c7a5f3355c04a3447232f56098c382855d83 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 14:34:26 -0600 Subject: [PATCH] docs(plans): add macros-as-custom-commands design (gate-approved) Design doc for macros as first-class custom commands: top-level .name invocation, description/isolated fields, enabled_macros scoping, workspace-local macros, completion integration. Gatekeeper: sealed (3 findings fixed). Oracle plan-review: approved. --- plans/custom-commands-design.md | 505 ++++++++++++++++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 plans/custom-commands-design.md diff --git a/plans/custom-commands-design.md b/plans/custom-commands-design.md new file mode 100644 index 0000000..c5a8054 --- /dev/null +++ b/plans/custom-commands-design.md @@ -0,0 +1,505 @@ +# Design: Macros as First-Class Custom Commands + +Status: DRAFT v2 — grounded in code (src/config/macros.rs, src/repl/mod.rs, +src/config/install_remote.rs, src/config/paths.rs). Supersedes v1, which +proposed a new markdown "commands" artifact before discovering macros already +cover ~80% of the feature. + +## 0. Decision record + +- **Naming: keep "macros"** (user decision). No rename, no new artifact type, + no new install filter — `--install-from --filter macros` already + exists in both the CLI enum (`InstallFilter`, config/mod.rs:403) and the + REPL parser (`install_remote_from_repl_args`). (Note: the flag may be + renamed to `--install` per plans/bundle-manifest-design.md §5; the filter + is unaffected.) Docs will state plainly: *macros are + coyote's custom commands* (README + config examples + `.help`). + "Macro" is also the more accurate term: these are replayable scripts of + REPL commands with variables, not just prompt templates. +- **Keep the `.macro` subcommand** alongside top-level invocation (user + decision): it hosts the interactive creator (which top-level must never + trigger), it is the escape hatch for macros shadowed by built-ins (incl. + future built-ins landing on existing macro names), and removing it breaks + muscle memory and macros whose steps invoke `.macro`. Cost of keeping: ~0. +- **Rejected**: separate markdown command files (redundant — a single-step + macro with one `rest` variable IS a prompt command, and macros additionally + do multi-step, role switching, and `` .file `cmd` `` shell embedding); + rename to "commands" (churn, dir migration, and the less accurate word); + an `alias` field (complexity without payoff — the file name is the command + name, matching the existing naming structure); command-side agent/role + binding (inverts contexts-curate-artifacts; lets installed bundles mutate + existing contexts). +- **Bundle manifest / provenance tracking is NOT in this plan.** It is an + orthogonal, separate design (see §10); this feature neither depends on nor + blocks it. + +## 1. What exists today (verified) + +- `Macro { variables: Vec, steps: + Vec }`, YAML at `macros_dir()/.yaml` (global only; no + workspace dir, unlike skills). +- `.macro [args]` executes; nonexistent name + no args opens the + interactive macro creator (`ctx.new_macro`). +- `macro_execute` forks a fresh `RequestContext` from the current role + (inherits role's model/temperature/enabled_tools/enabled_mcp_servers), + copies `last_message` in (discontinuous), sets `macro_flag`, runs each + `{{var}}`-interpolated step via `run_repl_command`. **No state flows back**: + the macro's exchanges are not recorded in the active session and do not + update the caller's `last_message`. +- Auto-generated usage strings; positional vars with defaults + rest-capture. +- `.list macros`, `.delete macro`, built-ins embedded from `assets/macros/`. +- Unknown dot-command → `unknown_command()` at repl/mod.rs:1521: + `Error: Unknown command. Type ".help" for additional help.` + +## 2. Gaps this design closes + +1. Top-level invocation: `.review-work args`, not just `.macro review-work args`. +2. Discoverability: `description` field → listings, completion, `.help`. +3. Context scoping: `enabled_macros` on role / agent (non-graph) / session / + global config, mirroring `enabled_skills`. +4. Runtime toggles: `.macro enable|disable `, shorthand for + `.set enabled_macros` (see §6). +5. Conversation-integrated macros: `isolated: false`. +6. Workspace-local macros: `.coyote/macros/` shadowing global, mirroring + workspace skills/MCP conventions (see §5). + +## 3. New Macro fields + +```yaml +description: Review WIP against a base branch # optional; shown in listings/completion/.help +isolated: false # optional; default TRUE (current behavior) +variables: + - name: base + default: main + - name: instructions + rest: true + default: "" +steps: + - "Review the diff against {{base}}. {{instructions}}" +``` + +Both new fields are `#[serde(default)]`-style optional → every existing +macro file remains valid; default `isolated: true` preserves current behavior +exactly. + +### `isolated` semantics + +- `true` (default, today's behavior): forked context as described in §1. + Right for utility macros (e.g. generate-commit-message) whose chatter + should not pollute the session. +- `false`: steps run via `run_repl_command(ctx, ...)` against the **live** + context — exactly as if the user had typed each step at the prompt + themselves. Prompts continue the actual conversation: recorded in the + active session, `last_message` updated, agent context preserved. + Consequence to document loudly: mutating steps (`.role x`, `.model y`) + **persist after the macro ends** — that is the meaning of non-isolation, + not a bug. +- **VERIFIED: the session-recording chain has no macro_flag check anywhere** + (`after_chat_completion` request_context.rs:1528-1544 → `save_message` + :1464-1474 → `Session::add_message` session.rs:724-763; disk save deferred + to `Session::exit` :626-631). Today's "macros aren't recorded" behavior + comes ENTIRELY from the fork (`session: None` in the fresh context), not + from the flag — so non-isolated execution records normally with zero + changes to the recording path. +- Footnotes to today's isolation (documented, not changed): (a) the forked + ctx still appends exchanges to the flat `messages.md` file via the + `save_message` fallthrough (request_context.rs:1518-1526) — only *session* + recording is skipped; (b) Arc-backed state copied into the fork + (supervisor, inbox, escalation_queue) is shared, so mutations through those + handles already reach the parent. +- Both modes: `macro_flag` is set for the duration (temporarily on the live + ctx for non-isolated, restored on exit **including the error path** — RAII + guard) so the existing forbidden-op guards (repl/mod.rs:890, 979, 1310) + apply, and nested `.macro`/top-level macro invocation inside a macro is + rejected in non-isolated mode (isolated mode already recurses safely via + `#[async_recursion]`; keep as-is). + Precise predicate (oracle note): reject when `macro_flag` is set AND the + CURRENT execution mode is non-isolated. An **isolated** macro's step + invoking a **non-isolated** macro runs it inline on the FORKED ctx + (harmless — the fork has no session); pin this behavior in the step-5 + test matrix. + Implementation footguns (oracle): (a) the RAII guard cannot hold + `&mut ctx.macro_flag` while `&mut ctx` is passed to `run_repl_command` — + wrap the whole `&mut RequestContext` (Drop restores prior mode) or use + save/restore around a closure; (b) prefer a COMPANION FIELD for the mode + over changing `macro_flag` to `Option` (the enum touches all 13 + sites incl. both fork-propagation sites; the companion field is the + smaller diff); (c) a separate `macro_execute_inline` fn creates a new + type-level recursion cycle with `run_repl_command` and needs its own + `#[async_recursion]`/boxing — a branch inside the already-boxed + `macro_execute` is free. + Semantics caveat to document (§8): steps are FAIL-FAST — a mid-macro error + aborts remaining steps while completed steps' mutations persist (slightly + stronger than "as if typed", where a human would continue past errors); + and a `.exit` step's exit signal is swallowed inside macros today (bool + discarded at macros.rs:67) — unchanged, but say so. +- **VERIFIED guard inventory** (13 `macro_flag` sites: 4 decl/init, 2 + propagation — `fork_for_branch` request_context.rs:276, `new_for_child` + :315 — and 7 behavioral). Under non-isolated execution: + - Keep as-is (desirable in a macro even on the live ctx): `.update` bail + (repl/mod.rs:890), `.edit` bail (:979 — no `$EDITOR` mid-macro), + blank-line suppression (:1310, cosmetic), `new_role` prompt→bail + (request_context.rs:2259), `new_macro` prompt→bail (:2360). + - Non-issue: `apply_prelude` skip (:3966) — on a live REPL ctx the prelude + already ran and `state()` is non-empty. + - **RULED (user, 2026-08-20): condition on isolation.** `use_agent` + (:3743-3749) suppresses the agent's default `agent_session` when + `macro_flag` is set; under `isolated: false` the suppression is LIFTED — + a non-isolated `.agent foo` step inherits foo's default session exactly + as if typed. Mechanism: the RAII guard records the mode (companion field + or `Option` replacing the bare bool) so use_agent can + distinguish isolated from non-isolated; isolated mode keeps today's + suppression verbatim. + - Nested-macro *execution* is currently unguarded (`new_macro` only blocks + the interactive creator) — the non-isolated nesting rejection is NEW + code, not a reuse of an existing check. + +## 4. Top-level invocation + +- Dispatch order in the REPL command match: **built-ins first**, then — where + `unknown_command()` fires today — look up visible macros by name (file + stem). Hit → execute exactly as `.macro ` would (honoring + `isolated`). Miss → existing `unknown_command()` error, verbatim, unchanged. + **VERIFIED insertion point**: the top-level catch-all `_ => + unknown_command()?` at repl/mod.rs:1297. The other `unknown_command()` + call sites (:630, :740, :763, :1236, :1259) are sub-argument mismatches + inside known commands and must NOT dispatch macros. +- `.macro` subcommand is kept verbatim for back-compat, including the + interactive-creation flow (creation stays ONLY under `.macro`; a top-level + typo like `.hi` must error, never open the creator). +- Both entry points (`.name` and `.macro name`) go through the same §5 + visibility check — otherwise `.macro` trivially bypasses `enabled_macros`. +- Collision rules: + - Macro name colliding with a **built-in** command: built-in always + wins; macro still invokable via `.macro `; flagged + `shadowed (built-in)` in `.list macros`. Built-in list sourced from the + existing `ReplCommand` registry, not a hardcoded copy. **VERIFIED shape**: + `static REPL_COMMANDS: LazyLock<[ReplCommand; 60]>` (repl/mod.rs:56-330), + `ReplCommand { name: &'static str, description, state: AssertState }` + (:554-571), with a test hardcoding the count (:1732). Macros must NOT be + added to this array (static, `&'static str`, fixed count). +- Tab completion: on `.`, visible macros appear alongside the usual + built-ins, with their `description` shown when available — joining the + built-in completer — as a SEPARATE dynamic source queried at + completion time: the completer clones REPL_COMMANDS at construction + (completer.rs:93-98) but already holds `Arc>` + (completer.rs:84), so it can query visible macros live. + **Shadowed macros are excluded from completion entirely** (RULED): a macro + whose name collides with a built-in is neither dispatchable via `.` + (built-in always wins) nor listed in `.` completions — it surfaces + only in `.list macros` as `shadowed (built-in)` and stays invokable via + `.macro `. +- **`.macro ` argument completion (RULED)**: upgraded to match `.` + presentation — macro names WITH descriptions when available. VERIFIED + today: request_context.rs:3004 completes `.macro` args via + `map_completion_values(paths::list_macros())` (names only, no + descriptions), while the plumbing already supports described suggestions + (`repl_complete` returns `(String, Option)`; `.model`/`.agent` + arms use it, rendered at completer.rs:58-60). Change the `.macro` arm to + the same resolver source as top-level completion. Differences from + `.`: shadowed macros ARE listed here (`.macro` is their escape + hatch), and the `enable`/`disable` subcommands appear alongside macro + names. Second-arg completion: `.macro enable ` / `.macro disable + ` complete toggle-eligible macro names. +- Completion entries for macros carry the same `AssertState` stance as the + `.macro` built-in (the completer filters on `cmd.is_valid(state)`, + completer.rs:46). +- `enable`/`disable` are sub-args of the existing `.macro` entry, NOT new + `REPL_COMMANDS` array entries (the count-asserting test at repl/mod.rs:1732 + stays untouched). + `.help` gains a "custom commands (macros)" section and + a line stating macros = custom commands. + +## 5. Scoping: `enabled_macros` + +New optional field, mirroring `enabled_skills` semantics **verbatim** — same +parser (`parse_string_or_array`: YAML list or comma-separated string), same +null/absent/empty behavior. **VERIFIED semantics** (resolver: +`SkillPolicy::effective_with`, skill_policy.rs:40-132; precedence :78-82; +regression test :388-404 pinning CHANGELOG:320): + +**Scope of "mirror" (explicit):** the mirroring covers ONLY the allowlist +resolution semantics (None/empty/populated meanings, first-`Some`-wins +precedence) and the config plumbing (where the field lives, how each level +parses it). It does NOT copy any LLM-facing skills machinery: skills feed +instruction injection and tool-scope refresh into the model payload — +macros have no analog of any of that. The LLM never learns macros exist or +ran (an isolated macro's exchanges arrive as ordinary messages on a fork; a +non-isolated macro's steps are indistinguishable from typed input). +`enabled_macros` gets its own small resolver over the discovered-files set — +it is NOT wired into `SkillPolicy`, prompt building, or context startup +(see "Lazy resolution" below). + +- `None`/absent = "no opinion" → fall through to the next level; all-`None` + → everything visible. +- `Some([])` (empty list, incl. empty string via the parsers) = **explicit + ZERO** — nothing enabled. Empty ≠ all; that was the regression. +- Populated = exactly those names. **Deliberate DIVERGENCE from skills on + unknown names**: skills hard-bail the whole resolution + (skill_policy.rs:94-103 — there is no warn path). For macros, hard + validation happens ONLY at `.set enabled_macros` time (the + request_context.rs:2709-2727 pattern: bail on a name that exists in + neither workspace nor global macros dir); CONFIG-FILE lists are validated + gracefully at resolution time — warn + `missing` row in `.list macros`, + never a bail (a stale name in a role file must not brick that role). + There is no `visible_macros` concept in v1. +- Precedence: `.or_else()` chain — session → agent → role → global, **first + `Some` wins outright**, no merging. Matches the ruling below. +- Plumbing gotcha: the global level is TWO structs (`Config` mod.rs:222 AND + `AppConfig` app_config.rs:44/:212) plus an env-override arm + (app_config.rs:532); role parses via frontmatter `parse_string_or_array` + (role.rs:131), session via plain serde — three parse paths to mirror. + +- Global config (`config.example.yaml`, alongside `enabled_skills`): default + when no role/agent/session is active. Absent/null = all macros visible. +- Role (`config.role.example.md` frontmatter), agent config + (`config.agent.example.yaml`), session config: allowlist for that context. +- **Graph-based agents — CORRECTED BY VERIFICATION, then RULED (user, + 2026-08-20): silently ignored, option (a).** + The prior ruling ("graph configs reject the field") is not implementable + as specified: coyote uses `deny_unknown_fields` nowhere except + mcp/mod.rs:79, so unknown fields in graph.yaml are silently ignored — and + `enabled_skills` is in fact SUPPORTED at graph level (`AgentConfig:: + from_graph` copies it at agent.rs:811; graph/llm.rs:195-226 swaps per-node + values with save/restore; validator enforces node⊆graph at + graph/validator.rs:1156,:1235). Ruling: `enabled_macros` is simply omitted + from the `Graph` struct — graph.yaml ignores it like any other unknown + field (zero code, consistent behavior); the omission is documented in the + wiki/agent docs. Full skills-style graph support was rejected as + meaningless (graph nodes never dispatch REPL commands); a bespoke + validator warning was considered and declined. +- Precedence: most-specific active context that defines the field wins — + session > agent > role > global. No merging/intersection. +- Allowlist entries are macro **names** (the file stem — the same identifier + used for invocation). + +### Workspace-local macros (RULED: in scope) + +Workspace macro definitions are allowed, mirroring the existing +workspace-artifact conventions (VERIFIED mechanics): + +- Discovery: `.coyote/macros/*.yaml` under `workspace_config_dir()` + (paths.rs:198-207 — CWD only, no ancestor walk-up, dir name overridable + via `COYOTE_WORKSPACE_CONFIG_DIR`), exactly like workspace skills + (`workspace_skills_dir()`, paths.rs:209-215). +- Collision rule: **workspace shadows global by name** — same as workspace + skills (`list_skills` iterates [workspace, global] with shadowing, + paths.rs:478-501; `has_skill` checks workspace first, :503-505) and + workspace MCP (HashMap insert, workspace wins, mcp/mod.rs:239). +- Opt-out mirrors MCP: new `--no-workspace-macros` CLI flag + + `no_workspace_macros` config key (default false), modeled on + `--no-workspace-mcp` (cli/mod.rs:96-98 → main.rs:222-223, + app_config.rs:98). +- Trust model: no confirmation prompt — consistent with workspace MCP and + skills, which load with no gate (mcp/mod.rs:225-268 merely eprintlns); + macros are strictly lower-risk since they run only on explicit user + invocation, never automatically. `.list macros` gains a source column + (`workspace` | `global`) so provenance is always visible. +- `enabled_macros` existence validation consults workspace-then-global (the + `has_skill` pattern). +- The interactive creator (`.macro `) continues to write to the + GLOBAL macros dir; workspace macros are authored by hand / committed to + the repo. Remote installs (`--filter macros`) also target global only — + bundles never write into a workspace. +- Agent plumbing (VERIFIED): `AgentConfig` agent.rs:694-764 (getter/setter + pattern :399-412), reached lazily from `RequestContext.agent` + (request_context.rs:147, set in `use_agent` :3670+). Policy is resolved + lazily at enforcement sites from `ctx.role/agent/session` — no + activation-time snapshot exists, which confirms the lazy-resolution ruling + fits the existing architecture exactly. +- Session-level note: `enabled_skills` has NO REPL setter today (session file + serde only). Runtime toggles do NOT touch session state — `.macro + enable|disable` edits the global-level in-memory list via + `update_app_config` (see §6), reusing the `.set` machinery. +- Unknown names in a config-file list: warning + `missing` row in `.list + macros` (per the divergence ruling above — resolution never bails). + +### Lazy resolution + +`enabled_macros` never touches the LLM payload (macros are invisible to the +model), so the visible set is computed on demand — at top-level dispatch +fallback, completion, `.list macros`, `.macro enable|disable` — from: +discovered files x active context's allowlist x runtime toggles. Zero +context-startup work; no new startup-ordering surface. Rescan both macro +dirs (workspace + global) on each resolution — two `read_dir`s; matches +`paths::list_macros()` cost today. + +## 6. REPL management surface + +- `.list macros` (enriched): name, description, isolated?, state: + `enabled` | `disabled (runtime)` | `locked` | `missing` | `shadowed + (built-in)` | `invalid`. `locked` names the restricting config (e.g. + `agent:oracle enabled_macros`). Plus a source column + (`workspace` | `global`). +- **`.macro enable ` / `.macro disable `** (RULED — replaces the + earlier `.command` proposal; user prefers no separate command at the cost + of reserving two names): shorthand for editing `.set enabled_macros`. + Consequently **`enable` and `disable` are reserved macro names**: the + creator rejects them, discovery marks such a file `invalid` (with a + warning), and remote installs warn. +- **`.set enabled_macros `** — new key in the `.set` match + (`RequestContext::update`, request_context.rs:2644-2929), mirroring + `enabled_skills` (:2706-2730) verbatim: `csv_to_vec` parsing (space-free, + comma-separated), `null` clears back to None, per-name existence + validation (workspace-then-global), then + `update_app_config(|app| app.enabled_macros = ...)`. +- **Mechanism makes the no-override rule structural** (VERIFIED): `.set` + writes the in-memory GLOBAL AppConfig level — the LOWEST-precedence rung + of the session > agent > role > global chain — in-memory only, never + persisted (`update_app_config` clone-and-swaps the Arc, + request_context.rs:347-354). So `.macro enable|disable` physically cannot + override a role/agent/session `enabled_macros` allowlist. When such an + allowlist is active, the toggle ERRORS (naming the owning config) instead + of silently writing a shadowed value. +- Toggle semantics over the global-level list: `disable X` with list=None + (all visible) materializes the list as all-discovered-minus-X; `enable X` + appends if absent (None → already visible → no-op notice); `disable X` + removes (absent → no-op notice). Lifetime: process runtime, like every + other `.set` key. +- Drive-by fix bundled here: `enabled_skills` is missing from the `.set` + key completion list (request_context.rs:3018-3047) although its setter + works — add both `enabled_skills` and `enabled_macros` to completion. + +States per macro per context: +1. permitted + enabled (default within allowlist) +2. permitted + disabled (runtime toggle) +3. locked (outside a ROLE/AGENT/SESSION allowlist) — not enableable from the + REPL; the error says to edit `enabled_macros` in the owning config. No + REPL override, ever: those configs stay the single source of truth. + +Boundary rule: `locked` applies ONLY to role/agent/session allowlists. A +global-level exclusion (whether from config.yaml or a prior toggle — they +occupy the same in-memory list and are indistinguishable) is always +toggleable via `.macro enable` and displays as `disabled (runtime)`. + +### Error handling matrix + +| Action | Condition | Behavior | +|---|---|---| +| `.name` | no built-in, no macro | existing error verbatim: `Error: Unknown command. Type ".help" for additional help.` | +| `.name` / `.macro name` | locked | error naming the restricting context/config | +| `.name` / `.macro name` | runtime-disabled | error: re-enable with `.macro enable ` | +| `.macro enable X` | locked (more-specific allowlist active) | error: "restricted by ; edit `enabled_macros` there" | +| `.macro enable X` | unknown | existing unknown-style error | +| `.macro enable X` | already enabled | no-op notice | +| `.macro create/creator` | name is `enable` or `disable` | error: reserved name | +| discovery | file named `enable`/`disable`.yaml | `invalid` in `.list macros` + warning | +| config `enabled_macros` | unknown name | warning + `missing` in `.list macros` | +| discovery | name shadows built-in | works via `.macro` only; `shadowed` in `.list macros` | +| discovery | workspace + global same name | workspace shadows global; both visible in `.list macros` source column | +| non-isolated macro | step invokes another macro | error: nested macros not allowed in non-isolated mode | + +## 7. Install & distribution + +Unchanged: `--install-from --filter macros` (CLI + REPL; flag rename to +`--install` tracked in plans/bundle-manifest-design.md §5). Installing macros +never modifies any `enabled_macros` list — a context with an allowlist is +unaffected by new installs until the user edits it (the security property +motivating context-side scoping). Existing overwrite/skip behavior applies; +install-time warning if an installed macro's name shadows a built-in. + +## 8. Docs + +- README + `.help`: "macros are coyote's custom commands" framing; top-level + invocation; `isolated` semantics with the role-switch-persists warning. +- `config.example.yaml`, `config.role.example.md`, + `config.agent.example.yaml`: `enabled_macros` entries mirroring the + existing `enabled_skills` doc comments; `no_workspace_macros` entry + alongside the existing `no_workspace_mcp` one (config.example.yaml:140-145). +- New `macro.example.yaml` (or extend existing docs) showing all fields incl. + description/isolated. +- Workspace macros: document `.coyote/macros/` alongside the existing + workspace skills/MCP conventions. + +## 9. Implementation sketch + +1. **Macro struct**: add `description`, `isolated` (serde defaults); + deser tests for back-compat with field-less YAML. +2. **`enabled_macros` field**: global AppConfig + role + agent (non-graph) + + session structs (both `Config` AND `AppConfig` at the global level + env + arm), via `parse_string_or_array` like role.rs:131; graph.yaml silently + ignores it (field omitted from the Graph struct, per §5 ruling); + precedence resolver + tests (incl. the empty-list-means-zero regression + case). +3. **Resolver**: discovered x allowlist x runtime toggles (global-level + in-memory) → + visible set + per-macro state; table-driven tests over the §6 matrix. + OWNS the two-dir discovery (`workspace_macros_dir()` + shadowing, the + has_skill/list_skills pattern) so steps 4/5/6 are genuinely independent. +4. **REPL dispatch**: macro fallback immediately before `unknown_command()`; + `.macro enable|disable` + `.set enabled_macros` key (+ completion + drive-by); enriched `.list macros`; dynamic macro completion with + shadowed-name exclusion; `.macro ` arg completion upgraded to + descriptions + subcommands (request_context.rs:3004); `.help`. Enforce + visibility on the `.macro` path too. +5. **Non-isolated execution**: `macro_execute_inline(ctx, ...)` variant (or + branch) running steps on the live ctx with RAII macro_flag guard + + nested-macro rejection; tests for flag restore on error, plus two + mock-free session tests (oracle note — no mock-client harness needed): + (i) the non-isolated path passes the LIVE ctx (session `Some`) into + `run_repl_command`, not a fork; (ii) a mutating step (`.model x`) + persists on the live ctx after the macro returns. Also pin + isolated→non-isolated nesting (§3). +6. **Workspace macros (flag + docs only; discovery lives in step 3)**: + `--no-workspace-macros` flag + `no_workspace_macros` config key, source + column in `.list macros`. +7. **Docs** (§8). + +Order: 1 → 2 → 3 → {4, 5, 6 in parallel} → 7. + +## 10. Follow-ups (out of scope) + +- Persisted toggles (surviving restart) — `.set` state is process-lifetime + by design; persistence would be new machinery for all `.set` keys, not + just macros. +- Bundle manifest & provenance layer — **separate design doc**, now written: + `plans/bundle-manifest-design.md` (per-file provenance recording at install + time, `--list-bundles` / `--update-bundle` / `--uninstall`, optional + author-shipped `coyote-bundle.yaml` manifest). Once it exists, `.list + macros` gains a "source bundle" column for free. This plan neither depends + on nor blocks it. +- "Did you mean" suggestions on unknown command (only if built-ins get it too). + +## 11. VERIFY before task materialization + +All items resolved 2026-08-20 (findings folded into §3/§4/§5 above): + +- [x] `enabled_skills` semantics: None=fall-through, empty=ZERO, populated= + exact+hard-bail validation; first-`Some`-wins precedence + (`SkillPolicy::effective_with`). See §5. +- [x] Agent config plumbing: lazy resolution from `ctx.agent`, no snapshot; + new field = AgentConfig field + accessor + resolver read. See §5. +- [x] `ReplCommand` registry: static fixed array, count-asserting test; + macros go in a separate dynamic completer source. See §4. +- [x] `macro_flag` guards: 13 sites inventoried; none suppress session + recording; one OPEN DECISION (`use_agent` agent_session suppression) + + nested-rejection is new code. See §3. +- [x] Session recording: `after_chat_completion` → `save_message` → + `Session::add_message`, no macro conditions; isolation lives in the + fork's `session: None`. See §3. + +Both open decisions ruled by the user 2026-08-20: §3 `use_agent` suppression +is conditioned on isolation (lifted for isolated:false); §5 graph.yaml +silently ignores `enabled_macros`. No open questions remain. + +Tooling warning for implementers: `fs_grep`/plain grep tools silently skip +src/config/request_context.rs (file-size exclusion) — audit that file with +ast_grep or targeted reads only. + +### Second verification round (2026-08-20, after user review) + +- [x] `.set` mechanics: fixed hand-written key match in + `RequestContext::update` (request_context.rs:2644-2929); + `enabled_skills` IS settable (:2706-2730) and always writes the + in-memory global AppConfig via `update_app_config` (:347-354) — + lowest precedence, never persisted, no role/agent/session setter. + Values: `csv_to_vec` comma lists (space-free; whitespace rejected for + all but two keys, :2654-2662), `null` clears. `enabled_skills` missing + from `.set` completion (:3018-3047) — oversight, fixed as drive-by. +- [x] Workspace discovery precedents: MCP = CWD-only probe + `.coyote/mcp.json` → `.coyote/.mcp.json` → `.mcp.json` + (paths.rs:217-230), workspace wins collisions via HashMap insert + (mcp/mod.rs:239), gated by `no_workspace_mcp` only, no trust prompt; + skills = `.coyote/skills/` shadowing global by name + (paths.rs:478-505); memory walks ancestors but MCP/skills do NOT. + Workspace macros copy the skills model + an MCP-style opt-out flag.