docs: cleaned up docs
This commit is contained in:
@@ -1,510 +0,0 @@
|
|||||||
# 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 <url> --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<MacroVariable{name, rest, default}>, steps:
|
|
||||||
Vec<String> }`, YAML at `macros_dir()/<name>.yaml` (global only; no
|
|
||||||
workspace dir, unlike skills).
|
|
||||||
- `.macro <name> [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 <name>`, 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<MacroMode>` (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<MacroMode>` 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 <name> <rest>` 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 <name>`; 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 `.<TAB>`, 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<RwLock<RequestContext>>`
|
|
||||||
(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 `.<name>`
|
|
||||||
(built-in always wins) nor listed in `.<TAB>` completions — it surfaces
|
|
||||||
only in `.list macros` as `shadowed (built-in)` and stays invokable via
|
|
||||||
`.macro <name>`.
|
|
||||||
- **`.macro <TAB>` argument completion (RULED)**: upgraded to match `.<TAB>`
|
|
||||||
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<String>)`; `.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
|
|
||||||
`.<TAB>`: shadowed macros ARE listed here (`.macro` is their escape
|
|
||||||
hatch), and the `enable`/`disable` subcommands appear alongside macro
|
|
||||||
names. Second-arg completion: `.macro enable <TAB>` / `.macro disable
|
|
||||||
<TAB>` 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, modeled on
|
|
||||||
`--no-workspace-mcp` (cli/mod.rs:96-98 → main.rs:222-223).
|
|
||||||
IMPLEMENTATION-VERIFIED CORRECTION (T6): `no_workspace_mcp` is
|
|
||||||
CLI-flag-only — AppConfig field exists but there is NO Config-struct key
|
|
||||||
and NO env arm, so a config.yaml entry is non-functional. The exact-mirror
|
|
||||||
ruling therefore makes `no_workspace_macros` CLI-flag-only too.
|
|
||||||
(Pre-existing bug, out of scope: config.example.yaml:140 documents
|
|
||||||
`no_workspace_mcp:` as a yaml key even though it does nothing — follow-up.)
|
|
||||||
- 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 <new-name>`) 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 <name>` / `.macro disable <name>`** (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 <csv|null>`** — 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 <name>` |
|
|
||||||
| `.macro enable X` | locked (more-specific allowlist active) | error: "restricted by <config>; 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. Do NOT add a `no_workspace_macros`
|
|
||||||
yaml entry — the flag is CLI-only (see §5 correction); the existing
|
|
||||||
`no_workspace_mcp` yaml line documents a dead key (pre-existing, follow-up).
|
|
||||||
- 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 <TAB>` 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.
|
|
||||||
Reference in New Issue
Block a user