From 9c974e4aa439f1e8660dfebf7fa9df730b4b515c Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Tue, 7 Jul 2026 14:43:49 +0000 Subject: [PATCH 1/5] Add plugin model sub-RFD Defines what a plugin is from scratch: every directory is a valid plugin, Symposium.toml structure, defaults (skills/ and .agents/skills/ discovery), predicates (workspace(), used(), depends-on(), shell(), etc.), the [depends-on] shorthand, chained plugins, and installed vs. active states. Co-authored-by: Claude --- md/SUMMARY.md | 1 + md/rfds/registry-centric-plugins/README.md | 2 +- .../plugin-model/README.md | 240 ++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 md/rfds/registry-centric-plugins/plugin-model/README.md diff --git a/md/SUMMARY.md b/md/SUMMARY.md index f0fbbaeb..990e8dec 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -90,6 +90,7 @@ - [Accepted](./rfds/accepted.md) - [MCP meta-server](./rfds/mcp-meta-server/README.md) - [Registry-centric plugin distribution](./rfds/registry-centric-plugins/README.md) + - [Plugin model](./rfds/registry-centric-plugins/plugin-model/README.md) - [Predicate caching](./rfds/predicate-caching/README.md) - [Completed](./rfds/completed.md) - [Configuration parsing and normalization](./rfds/config-normalization/README.md) diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index e6b1c7f9..6c6918d5 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -297,7 +297,7 @@ The tradeoff is that some plugins don't have a natural "home" in a language-spec We plan follow-up RFDs with more details on each component: -- **Plugin model** — what a plugin is, `Symposium.toml` structure, defaults (skill discovery, implicit installations), predicates, chained plugins, installed vs. active. +- **[Plugin model](./plugin-model/README.md)** — what a plugin is, `Symposium.toml` structure, defaults (skill discovery, implicit installations), predicates, chained plugins, installed vs. active. - **PM interface + Cargo PM** — the JSON-RPC protocol for PM binaries, error semantics, caching contract. The cargo PM specifically: `resolve` schema, `fetch` via cargo toolchain, `list-deps` from `Cargo.lock`. - **Discovery & sync** — the two-phase discovery algorithm (`list-deps` on all PMs, then `search` on all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration. - **User-managed plugins** — `symposium use`/`remove`/`status` commands, config file format, version requirement syntax, global vs. workspace-local scoping. diff --git a/md/rfds/registry-centric-plugins/plugin-model/README.md b/md/rfds/registry-centric-plugins/plugin-model/README.md new file mode 100644 index 00000000..76dfc9fc --- /dev/null +++ b/md/rfds/registry-centric-plugins/plugin-model/README.md @@ -0,0 +1,240 @@ +# Plugin model + +## TL;DR + +- A plugin is a directory. Every directory is a valid plugin — no manifest required. +- An optional `Symposium.toml` provides explicit configuration. If absent, an empty one is synthesized. +- Defaults apply to every plugin: `skills/` and `.agents/skills/` are discovered as skill directories. +- Plugins can declare chained plugins (additional plugins to load when activated). +- Predicates gate activation, not installation. + +## Motivation + +The old plugin model was built around explicit manifests in "plugin source" directories. This made it hard for crate authors to ship skills without learning a new configuration system. The new model inverts the default: everything is a plugin, configuration is optional, and conventions do the heavy lifting. + +## Change in a nutshell + +A plugin directory with nothing but a `skills/` subdirectory: + +``` +my-plugin/ +└── skills/ + └── usage-guide/ + └── SKILL.md +``` + +This is a valid, complete plugin. No `Symposium.toml` needed. Symposium synthesizes an empty manifest and applies defaults, which discovers the skill. + +Adding a `Symposium.toml` lets you control behavior — add predicates, declare hooks, reference binaries, suppress defaults, or chain other plugins: + +```toml +# Symposium.toml +[depends-on] +cargo = { tokio = "1" } + +[[hooks]] +event = "PreToolUse" +command = "my-linter" + +[[plugins]] +source.cargo = { tokio-extras = "*" } +``` + +## Detailed plans + +### What is a plugin? + +A plugin is a directory. That's it. The directory may contain: + +- `Symposium.toml` — optional manifest +- `skills/` — conventional skill directory (exposed to workspace and dependency consumers) +- `.agents/skills/` — conventional skill directory (workspace-only) +- Any other files (scripts, assets, etc. referenced by hooks or MCP servers) + +### Synthesized manifest + +When a directory has no `Symposium.toml`, Symposium behaves as if an empty one exists. This empty manifest still triggers default behavior (see below). + +### `Symposium.toml` structure + +```toml +# Predicates gating activation +predicates = ["workspace()", "file-exists(build.rs)"] + +# Shorthand: depends-on reuses the PM's resolve format +[depends-on] +cargo = { tokio = "1", serde = "1" } + +# Suppress defaults +[defaults] +skills = false + +# Skills (beyond those discovered by convention) +[[skills]] +source.path = "extra-skills/advanced" +predicates = ["env(ADVANCED_MODE=1)"] + +# Hooks +[[hooks]] +event = "PreToolUse" +command = "my-linter" +args = ["--strict"] + +[[hooks]] +event = "SessionStart" +command = "my-greeter" + +# MCP servers +[[mcp]] +name = "my-server" +command = "my-mcp-binary" +args = ["serve"] + +# Chained plugins — loaded when this plugin activates +[[plugins]] +source.cargo = { tokio-extras = "*" } + +[[plugins]] +source.git = { url = "github.com/org/helpers", branch = "main" } + +# Installable content (binaries referenced by hooks/MCP servers) +[[installable]] +name = "my-linter" +source.cargo = { my-linter-crate = "1.0" } +``` + +### Agentic extensions + +`Symposium.toml` files contain the following kinds of content: + +* `[[plugins]]` defines a set of additional *chained plugins*. If a plugin X defines a chained plugin Y, then whenever X is loaded, Y will be loaded. +* `[[skills]]` identifies directories where we should search for skills. Any skills found there will be installed into the user's workspace in the appropriate place(s) for the agent(s) they've selected. +* `[[mcp]]` identifies MCP servers. +* `[[hooks]]` identifies hooks. Symposium allows you to define vendor-neutral hooks that work for any vendor or vendor-specific hooks that target a particular agent (e.g., Claude Code or Codex). +* `[[installable]]` identifies installable content, which can be referenced by MCP servers or hooks (which need an executable). An easy option is to package your content as a cargo package that will be cargo-install'd and managed by Symposium, but there are other options. + +### Default content + +Plugins have default content added automatically unless disabled via `[defaults]`. Currently we have one default, `defaults.skills = (true|false)`. Assuming the default is not set to false, the following is added to the plugin: + +```toml +[[skills]] +source.path = "skills" + +[[skills]] +predicates = ["workspace()"] +source.path = ".agents/skills" +``` + +These defaults establish the skills conventions: +- `skills/` is exposed to anyone who depends on the crate (no predicate gate). +- `.agents/skills/` is only exposed when working directly in the workspace (gated by `workspace()`). + +### Predicates + +The plugin itself and each of its subsections can be gated with a `predicates = [...]` field. When a plugin is installed, the content is only *activated* if the predicate matches. + +Common predicates: + +* `workspace()` — true if this plugin is part of the active workspace +* `used()` — true if this plugin was explicitly used by the user +* `workspace-dependency()` — true if plugin is a dependency of some project in the current workspace +* `depends-on(pm, name, version)` — true if the workspace depends on this package +* `env(FOO=BAR)` — true if the environment variable is set to the given value +* `file-exists(path)` — true if the given file exists relative to workspace root +* `shell(command)` — true if the command exits with code 0 +* `workspace-directory(path)` — true if the workspace is a subdirectory of the given path +* `not(p)`, `any(p, ...)`, `all(p, ...)` — combinators + +The `[depends-on]` shorthand reuses the PM's `resolve` format: + +```toml +[depends-on] +cargo = { tokio = "1", serde = "1" } +``` + +This is equivalent to `predicates = ["depends-on(cargo, tokio, 1)", "depends-on(cargo, serde, 1)"]`. + +Predicates can appear at any level (plugin, skill, hook, MCP server). A predicate on a plugin gates all its direct contents. Chained plugins have their own predicates and are evaluated independently. + +### Chained plugins + +A plugin can declare additional plugins to be loaded when it activates: + +```toml +[[plugins]] +source.cargo = { serde-extras = "*" } + +[[plugins]] +source.path = { path = "./sub-plugin" } +``` + +Chaining is an *activation-time* relationship: when this plugin becomes active, also load these. Chained plugins: +- Are fetched and cached transitively (installing A also fetches A's chained plugins) +- Have their own predicates (they may not activate even if the parent does) +- Are independent after loading + +Use chaining when a library crate wants agent support but ships it in a separate package for release-cycle independence. + +### Installed vs. active + +| State | Meaning | Where | +|-------|---------|-------| +| Installed | Content is in cache, ready to activate | `~/.symposium/cache/` | +| Active | Predicates pass, content wired into agent dirs | `.claude/skills/`, etc. | +| Inactive | Installed but predicates don't pass | Cache only | + +A plugin transitions between active and inactive as workspace state changes (e.g., adding a dependency). No re-fetch needed. + +## Frequently asked questions + +### Why is every directory a plugin? + +It makes the cargo PM simple: every crate is a plugin, no detection heuristic needed. Most crates won't have any plugin content (no `skills/`, no `Symposium.toml`), so they result in empty plugins that are effectively no-ops. + +### What happened to "plugin sources"? + +Gone. In the old model, `[[plugin-source]]` pointed at directories that *contained* plugins. Now there's just plugins — and plugins can chain other plugins. + +### Can a plugin contain sub-directories that are also plugins? + +Only via explicit `[[plugins]]` with `source.path`. We don't recursively scan for nested `Symposium.toml` files. + +### What if `skills/` exists but I don't want it discovered? + +```toml +[defaults] +skills = false +``` + +## Implementation plan and status + +### Step 1: Plugin struct and manifest parsing + +Define the `Plugin` struct, parse `Symposium.toml`, synthesize empty manifests for directories without one. + +- [ ] PR: plugin struct + TOML parsing + +### Step 2: Default application + +Implement skill discovery from `skills/` and `.agents/skills/`. Suppression via `[defaults]`. + +- [ ] PR: plugin defaults + +### Step 3: Predicates on plugins + +Evaluate predicates at the plugin level and per-construct level. Gate activation. Implement the `[depends-on]` shorthand. + +- [ ] PR: predicate evaluation + +### Step 4: Chained plugins + +Parse `[[plugins]]` entries, resolve via PMs, fetch transitively, evaluate independently. + +- [ ] PR: chained plugin loading + +### Step 5: Integration with sync + +Wire the new plugin model into the sync pipeline: iterate installed plugins, evaluate predicates, sync active content to agent directories. + +- [ ] PR: sync integration From 91cdb6a99787f6d9713a44fe53c8ccd8714613bb Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Tue, 7 Jul 2026 15:26:34 +0000 Subject: [PATCH 2/5] Add PM interface, cargo PM, and discovery & sync sub-RFDs PM interface: JSON-RPC over stdio protocol, package-id tuples, four operations (resolve/search/fetch/list-deps), error handling, cache layout. Only path PM is built-in; cargo/git/recommendations are separate binaries. Cargo PM: resolve schema using cargo's dependency format, fetch via cargo toolchain, list-deps from Cargo.lock, plugin detection in crates. Discovery & sync: the two-phase algorithm (list-deps on all PMs, then search on all PMs for each dep), prompt UX, auto-install config, hook-triggered notification, debouncing. Co-authored-by: Claude --- md/SUMMARY.md | 3 + md/rfds/registry-centric-plugins/README.md | 4 +- .../cargo-pm/README.md | 162 ++++++++++++ .../discovery-sync/README.md | 207 +++++++++++++++ .../pm-interface/README.md | 239 ++++++++++++++++++ 5 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 md/rfds/registry-centric-plugins/cargo-pm/README.md create mode 100644 md/rfds/registry-centric-plugins/discovery-sync/README.md create mode 100644 md/rfds/registry-centric-plugins/pm-interface/README.md diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 990e8dec..7a84e650 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -91,6 +91,9 @@ - [MCP meta-server](./rfds/mcp-meta-server/README.md) - [Registry-centric plugin distribution](./rfds/registry-centric-plugins/README.md) - [Plugin model](./rfds/registry-centric-plugins/plugin-model/README.md) + - [PM interface](./rfds/registry-centric-plugins/pm-interface/README.md) + - [Cargo PM](./rfds/registry-centric-plugins/cargo-pm/README.md) + - [Discovery & sync](./rfds/registry-centric-plugins/discovery-sync/README.md) - [Predicate caching](./rfds/predicate-caching/README.md) - [Completed](./rfds/completed.md) - [Configuration parsing and normalization](./rfds/config-normalization/README.md) diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index 6c6918d5..b7dae4c5 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -298,8 +298,8 @@ The tradeoff is that some plugins don't have a natural "home" in a language-spec We plan follow-up RFDs with more details on each component: - **[Plugin model](./plugin-model/README.md)** — what a plugin is, `Symposium.toml` structure, defaults (skill discovery, implicit installations), predicates, chained plugins, installed vs. active. -- **PM interface + Cargo PM** — the JSON-RPC protocol for PM binaries, error semantics, caching contract. The cargo PM specifically: `resolve` schema, `fetch` via cargo toolchain, `list-deps` from `Cargo.lock`. -- **Discovery & sync** — the two-phase discovery algorithm (`list-deps` on all PMs, then `search` on all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration. +- **[PM interface](./pm-interface/README.md) + [Cargo PM](./cargo-pm/README.md)** — the JSON-RPC protocol for PM binaries, error semantics, caching contract. The cargo PM specifically: `resolve` schema, `fetch` via cargo toolchain, `list-deps` from `Cargo.lock`. +- **[Discovery & sync](./discovery-sync/README.md)** — the two-phase discovery algorithm (`list-deps` on all PMs, then `search` on all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration. - **User-managed plugins** — `symposium use`/`remove`/`status` commands, config file format, version requirement syntax, global vs. workspace-local scoping. ### Future work diff --git a/md/rfds/registry-centric-plugins/cargo-pm/README.md b/md/rfds/registry-centric-plugins/cargo-pm/README.md new file mode 100644 index 00000000..9f40cc9f --- /dev/null +++ b/md/rfds/registry-centric-plugins/cargo-pm/README.md @@ -0,0 +1,162 @@ +# The `cargo` PM + +## TL;DR + +- The `cargo` PM bridges crates.io (and alternative Rust registries) to Symposium's plugin system. +- It is a separate binary (`symposium-pm-cargo`) communicating with Symposium via JSON-RPC over stdio. +- `resolve` takes an opaque TOML value using cargo's dependency format. +- `fetch` leverages the existing cargo toolchain to obtain crate sources. +- `list-deps` reads `Cargo.toml`/`Cargo.lock` to report direct workspace dependencies. +- Every crate is implicitly a plugin — no opt-in required. + +## Motivation + +Most Symposium users today are Rust developers. Their project dependencies live on crates.io. The cargo PM makes these dependencies discoverable as plugin sources — if `serde` ships skills, or if a recommendations entry references `serde`, the cargo PM is what connects the dots. + +## Change in a nutshell + +In the cargo PM, **every crate is a plugin**. No opt-in is required. A crate can optionally include a `Symposium.toml` at its root directory for explicit configuration — but if absent, an empty one is synthesized and [plugin defaults](../plugin-model/README.md) apply (which discovers `skills/` and `.agents/skills/` directories). + +This means a crate author can ship skills by simply adding a `skills/` directory: + +``` +my-crate/ +├── Cargo.toml +├── src/ +│ └── lib.rs +└── skills/ + └── my-crate-usage/ + └── SKILL.md +``` + +No `Symposium.toml` needed. When a user depends on `my-crate`, the cargo PM's `list-deps` reports it, discovery finds the plugin content (via defaults), and the skills are offered for installation. + +## Detailed plans + +### Package-ids + +The cargo PM defines package-ids as `(cargo, $crate-name, $version)`. For example: `(cargo, serde, 1.0.210)`, `(cargo, tokio, 1.38.0)`. + +### `resolve` schema + +Symposium passes the TOML value from `source.cargo = { ... }` to the cargo PM uninterpreted. The cargo PM accepts the same format cargo uses for dependency specifications — crate names as keys, version requirements as values: + +```toml +[[plugins]] +source.cargo = { serde-skills = "1" } + +[[plugins]] +source.cargo = { foo = "1.*", bar = "2.0" } +``` + +`resolve` queries the registry index and returns one package-id per resolved crate: + +``` +source.cargo = { serde-skills = "1.*" } +→ resolve → [(cargo, serde-skills, 1.2.3)] +``` + +### `search` behavior + +`search` receives a package-id tuple (from another PM's `list-deps` result, passed during discovery). If the tuple's `pm` field is `cargo`, it searches the cargo registry for matching crates with Symposium plugin content. + +**How we detect plugin content in a crate:** + +1. **`Symposium.toml` at crate root** — explicit opt-in. +2. **Presence of `skills/` directory** — implicit. Convention-based discovery. +3. **Keyword convention** — crate authors add a `symposium-plugin` keyword. Search filters on this. + +If the tuple's `pm` field is not `cargo`, return empty. + +### `fetch` behavior + +Given a package-id like `(cargo, serde-skills, 1.2.3)`: + +1. Use the existing cargo toolchain to obtain crate sources — leveraging `~/.cargo/registry/src/` (the unpacked source cache) or triggering `cargo fetch` if needed. +2. Locate the unpacked crate source in cargo's cache. +3. The crate root directory is the plugin directory (defaults apply to discover skills, etc.). +4. Copy (or symlink) the plugin root into the destination path provided by Symposium. + +This approach ensures compatibility with users who have custom registry configurations, alternative registries, or corporate mirrors — we go through cargo rather than around it. + +### `list-deps` behavior + +Reads the workspace to report direct Rust dependencies. + +**Input:** workspace root directory (where `Cargo.toml` lives). + +**Strategy:** + +1. If `Cargo.lock` exists, read it — it has exact versions for all resolved dependencies. Return direct dependencies (those listed in workspace members' `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`). +2. If no lockfile, fall back to reading `Cargo.toml` manifests for dependency names (without exact versions). + +**Output:** set of package-id tuples, e.g., `[(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]`. + +**Workspace handling:** +- For a workspace with multiple members, union all members' direct dependencies. +- Path dependencies within the workspace are excluded (those are the user's own crates, not external deps). +- Dev-dependencies are included (they're still dependencies the user works with). + +**Performance:** +- Parse `Cargo.lock` directly (it's a TOML file). No `cargo metadata` invocation. +- Cache results keyed on `Cargo.lock` mtime. +- If `Cargo.lock` hasn't changed, return cached results immediately. + +### Chained plugins for independent release + +If a crate author wants to release plugin content on a separate schedule from their library, they add a `Symposium.toml` to their crate with a chained plugin: + +```toml +# In widget-lib's Symposium.toml +[[plugins]] +source.cargo = { widget-symposium = "1" } +``` + +This tells Symposium: "when this plugin is loaded, also load `widget-symposium`." The chained plugin can be published and updated independently. + +### Alternative registries + +The cargo PM defaults to crates.io but can be configured to use alternative registries. Configuration mechanism TBD — likely via cargo's own registry configuration in `~/.cargo/config.toml`, which the cargo PM inherits naturally since it uses the cargo toolchain. + +## Frequently asked questions + +### Why keys in `source.cargo` rather than `name`/`version` fields? + +The key-value style (`{ foo = "1.0", bar = "2.0" }`) mirrors how `[dependencies]` works in `Cargo.toml`, which is familiar to Rust users. It also naturally supports multiple crates per entry. + +### How does `search` know which crates have plugin content without downloading them all? + +Three approaches, in order of preference: +1. **Keyword convention** — crate authors add a `symposium-plugin` keyword. Search filters on this. +2. **Registry metadata** — if crates.io exposes enough metadata to detect `Symposium.toml` or `skills/` presence. +3. **Recommendations fallback** — for crates found via recommendations, we already know they have content. + +### Why not use `[package.metadata.symposium]` in Cargo.toml? + +We use `Symposium.toml` as the single configuration mechanism across all ecosystems. This avoids splitting plugin configuration between ecosystem-specific manifest files and keeps things consistent — whether your plugin comes from cargo, npm, or git, the configuration lives in `Symposium.toml`. + +## Implementation plan and status + +### Step 1: `list-deps` from Cargo.lock + +Parse `Cargo.lock` directly for dependency names and versions. Handle workspace members, exclude path deps. Mtime-based caching. + +- [ ] PR: cargo PM `list-deps` + +### Step 2: `resolve` with registry index + +Query the crates.io index (or alternative registry) to resolve version requirements to exact versions. + +- [ ] PR: cargo PM `resolve` + +### Step 3: `fetch` via cargo toolchain + +Leverage cargo's registry cache to locate crate sources. Copy to dest. + +- [ ] PR: cargo PM `fetch` + +### Step 4: `search` with plugin detection + +Search the registry, filter for plugin content (via keyword or metadata), rank results. + +- [ ] PR: cargo PM `search` diff --git a/md/rfds/registry-centric-plugins/discovery-sync/README.md b/md/rfds/registry-centric-plugins/discovery-sync/README.md new file mode 100644 index 00000000..b74e8f6b --- /dev/null +++ b/md/rfds/registry-centric-plugins/discovery-sync/README.md @@ -0,0 +1,207 @@ +# Discovery & sync + +## TL;DR + +- `symposium sync` resolves installed plugins, discovers new ones from workspace dependencies, prompts the user, fetches, evaluates predicates, and wires active content into agent directories. +- Discovery calls `list-deps` on all PMs, then passes each result as a query to `search` on all PMs. +- A session-start hook notifies users of available extensions without auto-installing. + +## Motivation + +Users shouldn't have to manually find and install plugins for every crate they depend on. Discovery bridges the gap: when you add `serde` to your `Cargo.toml`, Symposium notices and offers relevant extensions. The sync pipeline ensures everything stays consistent. + +## Change in a nutshell + +User adds `axum` to their project. On next agent session start, they see: + +``` +New extensions available for 1 dependency. Run `symposium sync` to review. +``` + +They run `symposium sync`: + +``` +New extensions available: + + [1] (cargo, axum-agents, 0.5.1) — Route documentation and testing skills + (because you depend on axum) + +Install? [1,all,none]: 1 +✓ Installed (cargo, axum-agents, 0.5.1) +✓ Synced 2 skills: axum-routing, axum-testing +``` + +## Detailed plans + +### The discovery algorithm + +The core discovery loop: + +1. **Call `list-deps` on all PMs.** Each PM reports the workspace's dependencies in its ecosystem. For example, the cargo PM returns `[(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]`. PMs that don't have a concept of workspace deps (git, path, recommendations) return empty. + +2. **For each dependency, call `search` on all PMs.** The full package-id tuple is passed as the query. Each PM decides how to match: + - The cargo PM: if `pm = cargo`, search the registry for matching plugin crates. Otherwise, return empty. + - The recommendations PM: match on `(pm, name)`, ignoring version. If it has a `cargo/serde/` directory, it returns that as a match. + - The git PM: returns empty (not searchable). + +3. **Filter out already-installed plugins.** Compare search results against what's already in config. + +4. **Prompt the user.** Present new discoveries and let them choose which to install. + +5. **Record choices.** Accepted plugins are added to config. Declined plugins are recorded as dismissed. + +This two-phase approach (list-deps → search) is what lets the recommendations PM "advise" on other PMs' dependencies without needing its own `list-deps` to return anything. + +### The sync pipeline + +`symposium sync` runs the full pipeline: + +``` +1. Resolve config → installed plugin package-ids (exact versions) +2. Discover deps → candidate plugin package-ids (via list-deps + search) +3. Prompt/auto-install → updated installed set +4. Fetch → populate cache +5. Evaluate predicates → active set +6. Sync to agent dirs → skills, hooks, MCP servers wired in +``` + +#### Step 1: Resolve config + +Read `~/.symposium/config.toml`. For each entry, call the PM's `resolve` to get the current best match. + +#### Step 2: Discover deps + +Run the discovery algorithm described above. + +#### Step 3: Prompt or auto-install + +Present new discoveries to the user: + +``` +New extensions available: + + [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers + (because you depend on serde) + + [2] (cargo, axum-agents, 0.5.1) — Route documentation and testing skills + (because you depend on axum) + +Install? [1,2,all,none]: +``` + +If `auto-sync = true` in config, skip the prompt and install all. + +Selected plugins are added to config. Declined plugins are recorded as dismissed. + +#### Step 4: Fetch + +For each installed plugin, call `fetch` on its PM to populate the cache. Chained plugins declared in a plugin's `Symposium.toml` are fetched transitively. + +Fetching happens in parallel across PMs and packages. + +#### Step 5: Evaluate predicates + +For each cached plugin, evaluate its predicates against the workspace: +- `workspace()` → is this directory part of the workspace? +- `depends-on(cargo, axum, 0.7)` → check if cargo's `list-deps` included axum +- etc. + +Plugins that pass are *active*. Plugins that don't pass are installed but dormant. + +#### Step 6: Sync to agent dirs + +Copy active skills/hooks/MCP servers into agent directories. Same change-awareness as today: +- Compare source and destination content +- Only write when files differ +- Clean up stale entries from deactivated/removed plugins + +### Hook-triggered notification + +On session start, a lightweight check runs: + +1. Use cached `list-deps` results (from lockfile mtime — no network calls). +2. Call `search` on all PMs with each dep. +3. If new matches exist, include in hook response: + ``` + New extensions available for 3 dependencies. Run `symposium sync` to review. + ``` + +The hook does NOT install anything. It only notifies. Installation goes through `symposium sync`. + +### Auto-install configuration + +```toml +# In ~/.symposium/config.toml + +# Install all discoveries without prompting +auto-sync = true + +# Or per-PM granularity: +[auto-sync] +recommendations = true # auto-install from recommendations +cargo = false # prompt for crates.io discoveries +``` + +### Dismissed discoveries + +When a user says "none" to a discovery, it's suppressed until: +- The plugin's version changes (a new release might be more relevant) +- The user explicitly searches for it via `symposium use` + +Dismissals are tracked in state (`~/.symposium/state.toml`). + +### Debouncing and caching + +- `list-deps` results are cached based on lockfile mtime. No cargo invocation if `Cargo.lock` hasn't changed. +- Discovery search results are cached with a 24-hour TTL. +- The session-start hook path uses cached results exclusively — no network calls during hook handling. + +## Frequently asked questions + +### Why not auto-install by default? + +Installing code without consent is a security concern. Users should see what's being proposed and approve it. The `auto-sync = true` opt-in is for users who trust the recommendations set and want zero friction. + +### Why only direct dependencies? + +Transitive deps are numerous and usually not relevant to the user's workflow. Direct deps keep discovery focused. + +### What if `list-deps` is slow? + +The cargo PM's `list-deps` reads `Cargo.lock` directly (fast parse). The result is cached on lockfile mtime. In the common case (lock unchanged), `list-deps` is a no-op. + +### Can discovery be disabled entirely? + +Yes: `auto-sync = false` (the default) means you only get notified, never auto-installed. To suppress even the notification, set `discovery = false` in config. + +## Implementation plan and status + +### Step 1: Sync pipeline skeleton + +Wire up the pipeline with the path PM initially to validate the flow end-to-end. + +- [ ] PR: sync pipeline with path PM + +### Step 2: Discovery algorithm + +Implement `list-deps` → `search` loop across all PMs. + +- [ ] PR: discovery algorithm + +### Step 3: Prompt UX + +Present discoveries, record choices (accept/dismiss). + +- [ ] PR: discovery prompt + +### Step 4: Hook notification + +Add discovery check to session-start hook. Use cached results only. + +- [ ] PR: session start notification + +### Step 5: Auto-install and dismissal + +Add `auto-sync` config, per-PM granularity, and dismissed-discovery tracking. + +- [ ] PR: auto-install + dismissal state diff --git a/md/rfds/registry-centric-plugins/pm-interface/README.md b/md/rfds/registry-centric-plugins/pm-interface/README.md new file mode 100644 index 00000000..e384ebac --- /dev/null +++ b/md/rfds/registry-centric-plugins/pm-interface/README.md @@ -0,0 +1,239 @@ +# PM interface + +## TL;DR + +- Define a four-operation interface (`resolve`, `search`, `fetch`, `list-deps`) that all package managers implement. +- PMs are separate binaries communicating via JSON-RPC over stdio. +- Only `path` is built into the Symposium binary; `cargo`, `git`, and future PMs are external. + +## Motivation + +Symposium needs to fetch plugins from multiple ecosystems without hard-coding each one. The PM interface is the seam: implement four operations and your ecosystem becomes a plugin source. This lets us ship cargo support today, add npm/pypi later, and let enterprises plug in internal registries — all without changing core. + +## Change in a nutshell + +A PM is a separate binary that speaks JSON-RPC over stdio. Here's the cargo PM responding to `resolve`: + +```toml +# User writes in Symposium.toml: +[[plugins]] +source.cargo = { serde-skills = "1" } +``` + +Symposium passes `{ "serde-skills": "1" }` to the cargo PM's `resolve` method. It queries crates.io and returns `(cargo, serde-skills, 1.2.3)`. + +Then `fetch((cargo, serde-skills, 1.2.3))` downloads the crate and unpacks the plugin directory into cache. + +## Detailed plans + +### Package-ids + +A **package-id** is a tuple `(pm, name, version)` where all three components are PM-defined strings. There is no mandated string-serialized format — the tuple is the identity. + +Examples: +- `(cargo, serde, 1.0.210)` +- `(git, git@github.com:rtk-ai/rtk#main, abc123def)` +- `(recommendations, cargo/serde, 0.1.0)` + +In the JSON-RPC protocol, a package-id is represented as: + +```json +{ "pm": "cargo", "name": "serde", "version": "1.0.210" } +``` + +### The protocol + +PMs are separate binaries invoked by Symposium. Communication uses JSON-RPC over stdio (the same pattern as MCP servers). Each PM binary is long-lived — Symposium spawns it once and sends multiple requests. + +The protocol defines four methods: + +#### `resolve` + +```json +// Request +{ "method": "resolve", "params": { "value": { "serde-skills": "1" } } } + +// Response +{ "result": [{ "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }] } +``` + +Takes the opaque TOML value from `source. = { ... }` (passed as JSON). Returns a set of package-ids. + +- Cargo PM: `{ "serde-skills": "1" }` → queries registry → `(cargo, serde-skills, 1.2.3)` +- Git PM: `{ "url": "...", "branch": "main" }` → resolves ref → `(git, git@github.com:org/repo#main, abc123)` +- Path PM (built-in, not JSON-RPC): `{ "path": "./my-plugin" }` → canonicalizes + +May involve network calls. Deterministic given same registry state. + +#### `search` + +```json +// Request +{ "method": "search", "params": { "query": { "pm": "cargo", "name": "serde", "version": "1.0.210" } } } + +// Response +{ "result": [{ "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "description": "..." }] } +``` + +Takes a package-id tuple (all fields provided — as returned by another PM's `list-deps`). Returns matching plugins from this PM's perspective. + +- Each PM decides which tuple components to match on. The recommendations PM ignores version; the cargo PM matches on name. +- If the query's `pm` field doesn't relate to this PM, it may return empty. +- Used during discovery: `list-deps` results are passed as queries to every PM's `search`. + +#### `fetch` + +```json +// Request +{ "method": "fetch", "params": { "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "dest": "/home/user/.symposium/cache/cargo/serde-skills/1.2.3" } } + +// Response +{ "result": { "path": "/home/user/.symposium/cache/cargo/serde-skills/1.2.3" } } +``` + +Downloads exact versioned content into the provided destination directory. + +Contract: +- Same package-id always produces same content. +- PM writes into `dest`, which Symposium provides. +- If `dest` already has content, PM may skip (cache hit). + +#### `list-deps` + +```json +// Request +{ "method": "list_deps", "params": { "workspace": "/home/user/projects/my-app" } } + +// Response +{ "result": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }, { "pm": "cargo", "name": "tokio", "version": "1.38.0" }] } +``` + +Inspects the workspace and reports dependencies relevant to this PM. Returns full package-id tuples. + +Contract: +- Direct dependencies only (not transitive). +- Must be fast — called on every sync. Read lockfiles, don't query the network. + +### Error handling + +Errors use JSON-RPC error codes: + +| Code | Meaning | Symposium behavior | +|------|---------|-------------------| +| -32001 | Not found | Skip gracefully, report in `status` | +| -32002 | Network error | Retry with backoff, fall back to cache | +| -32003 | Invalid input | Hard error at parse time | +| -32004 | Auth required | Report to user with setup instructions | + +### PM lifecycle + +Symposium manages PM binaries as follows: + +1. PMs are installed as `[[installable]]` entries — from the recommendations repository or the user's root config. +2. On first use, Symposium spawns the PM binary and connects via stdio. +3. The PM stays alive for the duration of the sync/hook operation. +4. Symposium may call methods concurrently (the PM should handle this or serialize internally). + +The `path` PM is the exception — it's built into the Symposium binary itself (since it just reads local directories and has no external dependencies). + +### Cache layout + +``` +~/.symposium/cache/ +├── cargo/ +│ └── serde-skills/ +│ └── 1.2.3/ +│ ├── Symposium.toml +│ └── skills/ +├── git/ +│ └── github.com-org-repo/ +│ └── abc123/ +│ └── ... +└── recommendations/ + └── cargo/ + └── serde/ + └── ... +``` + +Cache is a pure optimization — deletable and rebuildable from config. Symposium owns the directory structure; PMs write content into the slot they're given. + +### Built-in PMs + +#### `path` + +Built into the Symposium binary. For local development and workspace-local plugins. + +- `resolve`: canonicalizes a path, returns `(path, /absolute/path, _)`. +- `search`: returns empty (not a searchable registry). +- `fetch`: no-op (content is already on disk). +- `list-deps`: returns empty. + +### External PMs (shipped as installables) + +#### `cargo` + +Separate binary (`symposium-pm-cargo`). See the [cargo PM sub-RFD](../cargo-pm/README.md) for details. + +#### `git` + +Separate binary (`symposium-pm-git`). Resolves refs to commit SHAs, fetches repo content. + +#### `recommendations` + +Separate binary (`symposium-pm-recommendations`). Operates over the curated recommendations repository. See the main README's [recommendations manager section](../README.md#example-the-recommendations-manager) for structure. + +## Frequently asked questions + +### Why JSON-RPC over stdio? + +It's the same pattern used by MCP servers and LSP — well-understood, language-agnostic, and debuggable. We can use the `agent-client-protocol` SDK for the implementation. It also means PMs can be written in any language. + +### Why not compile PMs into the binary? + +Language-agnosticism. We want npm/pypi PMs eventually, and those may be best written in JS/Python. Even for Rust-based PMs, the binary boundary keeps the core small and lets PMs be updated independently. + +### Why is `path` the only built-in? + +It has no external dependencies and no protocol overhead would be justified for "return this local directory." Every other PM needs network access, registry-specific logic, or ecosystem tooling — better as separate binaries. + +### Who resolves version requirements — Symposium or the PM? + +The PM. When config says the user wants `serde-skills` version `1.*`, Symposium calls `resolve` with that constraint. The PM knows how to interpret version ranges for its ecosystem. + +## Implementation plan and status + +### Step 1: Define the JSON-RPC protocol schema + +Document the four methods, their request/response shapes, and error codes. Publish as a schema that PM authors can validate against. + +- [ ] PR: protocol schema definition + +### Step 2: Implement the `path` PM (built-in) + +Simplest case — validates the fetch/cache flow end-to-end without spawning an external process. + +- [ ] PR: path PM implementation + tests + +### Step 3: PM process management + +Spawning, stdio connection, JSON-RPC framing, lifecycle management (start on demand, keep alive during sync). + +- [ ] PR: PM process manager + +### Step 4: Implement `symposium-pm-cargo` + +First external PM. Port existing crate-fetch logic. Validates the full JSON-RPC round-trip. + +- [ ] PR: cargo PM binary + tests + +### Step 5: Implement `symposium-pm-git` + +Resolves refs, fetches repos. Validates a second external PM works with the protocol. + +- [ ] PR: git PM binary + tests + +### Step 6: Implement `symposium-pm-recommendations` + +Operates over the recommendations repository structure. + +- [ ] PR: recommendations PM binary + tests From 5c68ebba1ba1770324a9155fe7c82e63666cd0a4 Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Tue, 7 Jul 2026 15:27:03 +0000 Subject: [PATCH 3/5] Add user-managed plugins sub-RFD Covers symposium use/remove/status commands, config file format ([[plugins]] and [[workspace-plugins]] with source. syntax), global vs. workspace-local scoping (local installs don't modify workspace files), version updates via sync, and interaction with discovery. Co-authored-by: Claude --- md/SUMMARY.md | 1 + md/rfds/registry-centric-plugins/README.md | 2 +- .../user-managed-plugins/README.md | 189 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 md/rfds/registry-centric-plugins/user-managed-plugins/README.md diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 7a84e650..0ed860b4 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -94,6 +94,7 @@ - [PM interface](./rfds/registry-centric-plugins/pm-interface/README.md) - [Cargo PM](./rfds/registry-centric-plugins/cargo-pm/README.md) - [Discovery & sync](./rfds/registry-centric-plugins/discovery-sync/README.md) + - [User-managed plugins](./rfds/registry-centric-plugins/user-managed-plugins/README.md) - [Predicate caching](./rfds/predicate-caching/README.md) - [Completed](./rfds/completed.md) - [Configuration parsing and normalization](./rfds/config-normalization/README.md) diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index b7dae4c5..1a2524aa 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -300,7 +300,7 @@ We plan follow-up RFDs with more details on each component: - **[Plugin model](./plugin-model/README.md)** — what a plugin is, `Symposium.toml` structure, defaults (skill discovery, implicit installations), predicates, chained plugins, installed vs. active. - **[PM interface](./pm-interface/README.md) + [Cargo PM](./cargo-pm/README.md)** — the JSON-RPC protocol for PM binaries, error semantics, caching contract. The cargo PM specifically: `resolve` schema, `fetch` via cargo toolchain, `list-deps` from `Cargo.lock`. - **[Discovery & sync](./discovery-sync/README.md)** — the two-phase discovery algorithm (`list-deps` on all PMs, then `search` on all PMs for each dep), hook-triggered notification, prompt UX, auto-install configuration. -- **User-managed plugins** — `symposium use`/`remove`/`status` commands, config file format, version requirement syntax, global vs. workspace-local scoping. +- **[User-managed plugins](./user-managed-plugins/README.md)** — `symposium use`/`remove`/`status` commands, config file format, version requirement syntax, global vs. workspace-local scoping. ### Future work diff --git a/md/rfds/registry-centric-plugins/user-managed-plugins/README.md b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md new file mode 100644 index 00000000..9ef6620f --- /dev/null +++ b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md @@ -0,0 +1,189 @@ +# User-managed plugins + +## TL;DR + +- `symposium use [--global] X` searches PMs, installs a plugin, records it in config. +- `symposium remove X` removes from config. +- `symposium status` shows what's installed, what's active, and why. +- Global installs apply everywhere; local installs are scoped to a workspace directory without modifying workspace files. + +## Motivation + +Users need to explicitly manage plugins: install tools they've heard about, remove ones they don't want, and understand what's active. The UX should be as familiar as `cargo install` or `npm install -g` — search, pick, done. + +## Change in a nutshell + +```bash +$ symposium use serde-skills +Found plugins matching "serde-skills": + + [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers + +Install? [1]: 1 +✓ Installed (cargo, serde-skills, 1.2.3) +✓ Active (depends-on(cargo, serde, 1.0) matches in this workspace) + +$ symposium status +Installed plugins: + + (cargo, serde-skills, 1.2.3) [local: ~/projects/my-app] + Active: yes + Skills: serde-usage, serde-derive-helper + +$ symposium remove serde-skills +✓ Removed (cargo, serde-skills, 1.2.3) +``` + +## Detailed plans + +### `symposium use [--global] ` + +**Query:** A name or partial identifier. Symposium searches all PMs for matches. + +**Flow:** + +1. Call `search` on all PMs with the query. +2. One result → confirm and install. Multiple → present selection: + ``` + Found plugins matching "serde": + [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers + [2] (recommendations, cargo/serde, 0.1.0) — Recommended serde extensions + Install which? [1]: + ``` +3. Record in config. +4. Fetch into cache. +5. Run sync to activate if predicates pass. + +**Flags:** +- `--global` — active in all workspaces. +- Without `--global` — scoped to the current workspace directory. + +### `symposium remove ` + +Match `` against installed plugins. If ambiguous, prompt. Remove from config. On next sync, content is cleaned from agent directories. Cache entry stays (garbage-collected separately). + +### `symposium status` + +Shows installed plugins grouped by scope, with activation status: + +``` +Global plugins: + (cargo, rtk, 2.1.0) + Active: yes + Skills: rtk-reduce, rtk-expand + +Local plugins (~/projects/my-app): + (cargo, axum-agents, 0.5.1) + Active: yes (workspace-dependency() ✓) + Skills: axum-routing, axum-testing + + (cargo, diesel-helpers, 1.0.0) + Active: no (workspace-dependency() ✗) + Source: discovery (auto-installed 2026-05-15) + +Workspace plugins (from Symposium.toml): + Skills: project-guide, testing-conventions +``` + +### Config file format + +Location: `~/.symposium/config.toml` + +```toml +# Global plugins +[[plugins]] +source.cargo = { serde-skills = "1" } + +[[plugins]] +source.cargo = { rtk = "2" } + +# Workspace-scoped plugins +[[workspace-plugins]] +directory = "/home/user/projects/my-app" +source.cargo = { axum-agents = "0.5" } + +[[workspace-plugins]] +directory = "/home/user/projects/my-app" +source.cargo = { diesel-helpers = "1" } +``` + +Note: config entries use `source.` syntax — the same format as `Symposium.toml` plugin entries. Symposium passes the value to the PM's `resolve` to get the exact package-id. The version in the source value is a *requirement* (e.g., `"1"` means any 1.x); the resolved package-id has the exact version. + +### Scoping: global vs. local + +**Global (`--global`):** Plugin activates in every workspace. Good for universally useful tools. + +**Local (default):** Plugin scoped to the current workspace directory. Stored as `[[workspace-plugins]]` keyed by absolute path. + +Key constraint: **local installs don't modify workspace files.** Scoping lives entirely in `~/.symposium/config.toml`. This means: +- No dotfiles added to the project +- Team members don't see each other's local installs +- Workspace stays clean for version control + +**Workspace plugins (from `Symposium.toml`)** are a separate concept — they're project-managed, apply to all developers, and aren't touched by `use`/`remove`. + +### Version updates + +On each `symposium sync`, Symposium calls `resolve` with the source value from config. The PM finds the best matching version. Upgrades happen within the allowed range; downgrades don't. + +There is no separate `symposium update` command — sync handles this naturally. + +### Interaction with discovery + +Discovery can also add entries to config (when the user accepts a discovered plugin during sync). These show up as regular `[[plugins]]` or `[[workspace-plugins]]` entries. The `status` command shows provenance: + +``` +Source: discovery (auto-installed 2026-05-15) +``` + +vs. + +``` +Source: symposium use axum-agents +``` + +Both are equivalent in config. The distinction is informational. + +## Frequently asked questions + +### Why not modify workspace files for local installs? + +Local installs are personal preferences. Putting them in workspace files would commit them to version control, affecting the whole team. The `Symposium.toml` in the workspace is for team-wide plugins; `~/.symposium/config.toml` is for personal ones. + +### What if I move my project directory? + +`[[workspace-plugins]]` entries use absolute paths. If you move the directory, they stop matching. Fix: update the path in config manually, or re-run `symposium use` in the new location. + +### What happens when global and local plugins conflict? + +If a global and local plugin provide a skill with the same name, the local one wins. `status` shows a warning. + +### Can I install without a workspace? + +`symposium use --global X` works from anywhere. Without `--global`, you need to be in a workspace directory (so Symposium knows what to scope to). + +## Implementation plan and status + +### Step 1: Config file format + +Define and parse the `[[plugins]]` and `[[workspace-plugins]]` entries in config. + +- [ ] PR: config format + parsing + +### Step 2: `symposium use` + +Search flow, selection UX, writing to config, triggering sync. + +- [ ] PR: `use` command + +### Step 3: `symposium remove` + +Matching, removal from config, cleanup on next sync. + +- [ ] PR: `remove` command + +### Step 4: `symposium status` + +Display installed/active/inactive plugins with provenance and predicate status. + +- [ ] PR: `status` command From 54697b8b8d7b2c53ec667aa589b72152f902195c Mon Sep 17 00:00:00 2001 From: jackh726 Date: Sat, 8 Aug 2026 16:25:46 +0000 Subject: [PATCH 4/5] docs(rfd): describe the PM interface and cargo PM as they are meant to work The sub-RFDs describe several things differently from how the design has settled. This brings them in line, so the documents read as the design rather than as a proposal with amendments. The substantive changes: - A PM answers with a plugin *manifest*, not just a directory. That is what lets it synthesize a plugin for a package with no manifest, or translate one from another ecosystem's format, without Symposium learning that ecosystem's conventions. - `resolve` folds into `load_plugin`. A `[[plugins]] source.cargo` reference is resolved by loading the named id; a separate lowering step bought nothing. - `search` takes a query string rather than a package-id tuple, since a search is by definition not yet an identity. - Each PM owns its own cache and reports where content landed, rather than Symposium handing out destination slots. - `path` and `git` are built in, since both only read local directories. - Trust and validation are Symposium's, keyed on the instance an offer came from. A PM cannot make itself trusted by what it returns. - The cargo PM reports where the workspace is, never what it contains, because the workspace is a trust root whose policy core owns. Also drops progress annotations. An RFD describes the design; what exists is git's business, and status in a design document goes stale immediately. --- md/rfds/registry-centric-plugins/README.md | 64 ++-- .../cargo-pm/README.md | 115 +++--- .../discovery-sync/README.md | 56 ++- .../plugin-model/README.md | 33 +- .../pm-interface/README.md | 339 +++++++++++++----- .../user-managed-plugins/README.md | 8 +- 6 files changed, 399 insertions(+), 216 deletions(-) diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index 1a2524aa..5473d060 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -182,7 +182,7 @@ The plugin itself and each of its subsections can be gated with a `predicates = `depends-on` is sugar for the common dependency case: `depends-on = ["serde", "tokio"]` lowers to `any(depends-on(serde), depends-on(tokio))`, ANDed with any `predicates`. -Whether a plugin was **explicitly used** and whether it is a **workspace dependency** are *not* predicates in the shipped design. "Used" is the enablement axis — a `[plugins] use` entry (see [Explicit use](#explicit-use)) — which is also how a [dormant plugin](#dormancy) wakes; dependency presence is `depends-on()`. These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once. +Whether a plugin was **explicitly used** and whether it is a **workspace dependency** are *not* predicates. "Used" is the enablement axis, a `[plugins] use` entry (see [Explicit use](#explicit-use)), which is also how a [dormant plugin](#dormancy) wakes; dependency presence is `depends-on()`. These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once. #### Default content @@ -201,51 +201,48 @@ These defaults establish the skills conventions described earlier. For example, ### Package managers -> **Implementation note.** The shipped PM layer is **in-process**: [`PmRegistry`](../../design/module-structure.md#pm--package-managers) holds each PM as a `Box` — the cargo transport plus one `path` instance per configured registry — and the operation set is `active_plugins(deps)` / `load_plugin(id)` / `list_deps` / `search` / `fetch`. The original `resolve` operation folded into `load_plugin`: a `[[plugins]] source.cargo` reference is resolved by *loading* the named id, not by a separate lowering step. The separate-binary JSON-RPC protocol described below is the out-of-process *target* — not yet built; `PmRegistry` is the seam that will spawn and talk to those binaries. See [remaining work](#future-work). +A package manager (PM) is a pluggable backend that knows how to find, fetch, and enumerate plugins from a particular ecosystem. A PM may run in Symposium's own process or as a separate binary it speaks to over stdio; both implement the same operations, so nothing above the PM layer knows which it is talking to. -A package manager (PM) is a pluggable backend that knows how to find, resolve, fetch, and enumerate plugins from a particular ecosystem. Each PM is a separate binary that Symposium invokes — installed as an `[[installable]]` from either the recommendations repository or the user's root config. The `path` PM is built into the Symposium binary itself (since it just reads local directories), but `cargo`, `git`, and any future PMs (npm, pypi, etc.) are separate binaries. +`path` and `git` are built in, since both only read local directories. `cargo` is a crate of its own that can run either way, and any other ecosystem (npm, pypi, an internal registry) arrives as a binary named by a `[[package-manager]]` config entry. -Every PM implements four operations: +Every PM implements these operations: | Operation | Input | Output | Used by | |-----------|-------|--------|---------| -| `resolve` | opaque TOML value (from `source.`) | set of package-ids | manifest processing | +| `active_plugins` | the workspace's dependency ids | set of plugin offers | discovery, sync | +| `load_plugin` | package-id | set of plugin offers | chained references, `use` | | `search` | partial query string | set of package-ids + metadata | `symposium use` | | `fetch` | package-id | directory with plugin content | sync/install | -| `list-deps` | workspace directory | set of package-ids | auto-discovery | +| `list_deps` | (none) | set of package-ids | auto-discovery | +| `workspace_info` | (none) | workspace root and members | workspace plugins, scoping | +| `refresh` | update level | whether content was pulled | registry sync | + +A plugin *offer* is a resolved id, a content directory, and an unvalidated manifest. Returning a manifest rather than only a directory is what lets a PM synthesize a plugin for a package that ships no manifest, or translate one from its own ecosystem's format, without Symposium learning that ecosystem's conventions. Validation and defaults are applied by Symposium once the manifest arrives. Which plugins actually run is a separate decision, made from the user's `[plugins]` configuration and from the source the offer came from. A **package-id** is a tuple `(pm, name, version)` where all three components are PM-defined strings. Examples: `(cargo, serde, 1.0.210)`, `(git, github.com/rtk-ai/rtk, abc123def)`, `(recommendations, cargo/serde, 0.1.0)`. There is no mandated string-serialized format — the tuple is the identity. See the [PM interface sub-RFD](./pm-interface/README.md) for full protocol details. -#### Example: The recommendations manager - -> **Implementation note.** The shipped design does *not* build a dedicated recommendations PM or the `cargo//` namespace convention below. The actual `symposium-recommendations` repository is a flat registry read by the ordinary `PathPm`, and each entry declares which crates activate it with its own `depends-on` (evaluated when the plugin is loaded, like any registry plugin). A recommendations plugin is just "a plugin activated when certain deps are present," which the normal `depends-on` predicate already expresses — so the layout carries no dependency information and no separate PM is involved. The namespace convention could be re-added later as a thin lowering inside `PathPm` (a `cargo//` entry implying `depends-on(cargo:)`) if it earns its keep. The proposal below is kept as the original design. +#### Example: The recommendations registry -The recommendations PM is provided by the `symposium-recommendations` crate. It operates over a repository of curated plugin directories, organized by the PM namespace they relate to: +The `symposium-recommendations` repository is an ordinary flat registry, read by +the built-in `path` PM once its content has been fetched. Each entry is a plugin +directory that declares which crates activate it with its own `depends-on`: ``` symposium-recommendations/ - cargo/ - serde/ - Symposium.toml - tokio/ - Symposium.toml - symposium/ - yolo-skills/ - Symposium.toml + serde-guidance/ + Symposium.toml # depends-on = ["serde"] + tokio-guidance/ + Symposium.toml # depends-on = ["tokio>=1"] ``` -It defines the core operations as follows: - -| Operation | Definition | -|-----------|------------| -| `resolve` | accepts a string `"foo"` or a list of strings `["foo", "bar"]` and treats them as in search | -| `search` | if PM is specified, search the `pm/name` directory; otherwise, search all directories | -| `fetch` | load the plugin from `pm/name` directory | -| `list-deps` | returns empty set | - -Note: the recommendations PM participates in discovery not via `list-deps` but via `search`. The discovery flow calls `list-deps` on all PMs (e.g., cargo returns `(cargo, serde, 1.0.210)`), then for each dependency calls `search` on all PMs with the full tuple. The recommendations PM matches on `(pm, name)` and ignores the version component. This is where the recommendations PM gets to offer advice for other PMs' dependencies. +No dedicated PM and no namespace convention are involved, because none are +needed: a recommendation is just a plugin that activates when certain +dependencies are present, which the ordinary `depends-on` predicate already +expresses. The layout therefore carries no dependency information of its own, +and a recommendations entry is validated and gated exactly like any other +registry plugin. #### Example: The cargo manager @@ -306,7 +303,8 @@ We plan follow-up RFDs with more details on each component: The remaining work, roughly in dependency order: -- **Out-of-process PM binaries** — the shipped PM layer is in-process (see the note under [Package managers](#package-managers)). The design calls for each ecosystem PM (`cargo`, `git`, npm, pypi, …) to be a **separate binary** spoken to over JSON-RPC, with only the `path` PM built in. `PmRegistry` is the seam that will spawn and talk to them; the operation set and identity tuple are already in that shape, so this is a transport change, not a redesign. The JSON-RPC protocol, error semantics, and caching contract are the sub-RFD to write. +- **Acquiring a PM binary**: a `[[package-manager]]` entry names a command that must already exist. Running it through the existing installation machinery (`source = "cargo"` / `"github"`, as hooks and subcommands do) would let an entry install what it names. Plugin-vended PMs layer on after that. +- **Registries as PMs over the wire**: `path` and `git` registries stay in-process, since both only read local directories. Nothing stops a registry from being a PM binary too; there has just been no reason yet. - **PMs defined by plugins** — letting a plugin *register a new PM type* (so an org can ship an internal-registry PM, or an ecosystem PM like npm/pypi, as an ordinary plugin). Depends on the out-of-process protocol above; the registration and discovery mechanism is TBD. - **Additional built-in ecosystems** — there is no `git` PM yet (git *sources* for skill groups and installations exist, but a chained `source.git` is rejected); npm/pypi are unstarted. - **Custom predicate dispatch across plugins (fixed-point)** — a crate-embedded plugin can *define* a custom predicate, but its definition is not yet registered, so it cannot be evaluated (only registry plugins' custom predicates are). Wiring a crate's *own* custom predicates into its facet evaluation is tractable; the general case — one plugin defines a predicate that another plugin's gate references — needs a convergence loop, since the definition must be loaded before the gate that uses it can be evaluated. @@ -315,8 +313,8 @@ The remaining work, roughly in dependency order: ## Implementation status -1. **Plugin model** — ✅ landed. Plugins, `[defaults]`, predicates, chained plugins, dormancy. -2. **PM interface + Cargo PM** — ✅ landed **in-process**. Identity tuple and the operation set (`active_plugins` / `load_plugin` / `list_deps` / `search` / `fetch`); the out-of-process JSON-RPC form is future work. -3. **Discovery & sync** — ✅ landed. Dependency-embedded plugin discovery, the consent prompt, and the `[plugins]` config. The recommendations-via-`search` half was intentionally replaced by the flat-registry model (see the note under [the recommendations manager](#example-the-recommendations-manager)). -4. **User-managed plugins** — ✅ landed. `use` / `remove` / `status`, workspace vs. global scope. +1. **Plugin model.** Plugins, `[defaults]`, predicates, chained plugins, dormancy. +2. **PM interface and the cargo PM.** The identity tuple, the operation set, the JSON-RPC transport (`symposium_sdk::pm::protocol` and `pm::server` on the PM's side, `pm::RemotePm` on Symposium's), and `symposium-pm-cargo` as a standalone crate and binary. A PM answers with a `PluginOffer`: an id, a content directory, and an unvalidated manifest, so it can synthesize a plugin for a package that has none. `[[package-manager]]` config entries add ecosystems beyond cargo. +3. **Discovery and sync.** Dependency-embedded plugin discovery, the consent prompt, and the `[plugins]` config. Recommendations are a flat registry rather than a `search` result (see the note under [the recommendations manager](#example-the-recommendations-manager)). +4. **User-managed plugins.** `use` / `remove` / `status`, workspace vs. global scope. 5. **Remaining** — see [Future work](#future-work). diff --git a/md/rfds/registry-centric-plugins/cargo-pm/README.md b/md/rfds/registry-centric-plugins/cargo-pm/README.md index 9f40cc9f..e334ad7e 100644 --- a/md/rfds/registry-centric-plugins/cargo-pm/README.md +++ b/md/rfds/registry-centric-plugins/cargo-pm/README.md @@ -4,9 +4,9 @@ - The `cargo` PM bridges crates.io (and alternative Rust registries) to Symposium's plugin system. - It is a separate binary (`symposium-pm-cargo`) communicating with Symposium via JSON-RPC over stdio. -- `resolve` takes an opaque TOML value using cargo's dependency format. +- `load_plugin` takes a crate name and version requirement in cargo's format. - `fetch` leverages the existing cargo toolchain to obtain crate sources. -- `list-deps` reads `Cargo.toml`/`Cargo.lock` to report direct workspace dependencies. +- `list_deps` reports direct workspace dependencies. - Every crate is implicitly a plugin — no opt-in required. ## Motivation @@ -15,7 +15,7 @@ Most Symposium users today are Rust developers. Their project dependencies live ## Change in a nutshell -In the cargo PM, **every crate is a plugin**. No opt-in is required. A crate can optionally include a `Symposium.toml` at its root directory for explicit configuration — but if absent, an empty one is synthesized and [plugin defaults](../plugin-model/README.md) apply (which discovers `skills/` and `.agents/skills/` directories). +In the cargo PM, **every crate is a plugin**. No opt-in is required. A crate can optionally include a `Symposium.toml` at its root directory for explicit configuration, but if absent, an empty one is synthesized and [plugin defaults](../plugin-model/README.md) apply (which discovers `skills/` and `.agents/skills/` directories). This means a crate author can ship skills by simply adding a `skills/` directory: @@ -29,7 +29,7 @@ my-crate/ └── SKILL.md ``` -No `Symposium.toml` needed. When a user depends on `my-crate`, the cargo PM's `list-deps` reports it, discovery finds the plugin content (via defaults), and the skills are offered for installation. +No `Symposium.toml` needed. When a user depends on `my-crate`, the cargo PM's `list_deps` reports it, discovery finds the plugin content (via defaults), and the skills are offered for installation. ## Detailed plans @@ -37,70 +37,64 @@ No `Symposium.toml` needed. When a user depends on `my-crate`, the cargo PM's `l The cargo PM defines package-ids as `(cargo, $crate-name, $version)`. For example: `(cargo, serde, 1.0.210)`, `(cargo, tokio, 1.38.0)`. -### `resolve` schema +### Chained-reference schema -Symposium passes the TOML value from `source.cargo = { ... }` to the cargo PM uninterpreted. The cargo PM accepts the same format cargo uses for dependency specifications — crate names as keys, version requirements as values: +A `[[plugins]]` chained reference names one crate, as a dependency atom or a table: ```toml [[plugins]] -source.cargo = { serde-skills = "1" } +source.cargo = "serde-skills>=1" [[plugins]] -source.cargo = { foo = "1.*", bar = "2.0" } +source.cargo = { name = "serde-skills", version = "1.*" } ``` -`resolve` queries the registry index and returns one package-id per resolved crate: - -``` -source.cargo = { serde-skills = "1.*" } -→ resolve → [(cargo, serde-skills, 1.2.3)] -``` +Symposium lowers either spelling to a package-id whose version component is the requirement, and sends it to `load_plugin`. The cargo PM resolves the requirement and answers with the exact version. ### `search` behavior -`search` receives a package-id tuple (from another PM's `list-deps` result, passed during discovery). If the tuple's `pm` field is `cargo`, it searches the cargo registry for matching crates with Symposium plugin content. - -**How we detect plugin content in a crate:** +`search` receives a partial query string and searches crates.io by name, returning candidate crates. -1. **`Symposium.toml` at crate root** — explicit opt-in. -2. **Presence of `skills/` directory** — implicit. Convention-based discovery. -3. **Keyword convention** — crate authors add a `symposium-plugin` keyword. Search filters on this. - -If the tuple's `pm` field is not `cargo`, return empty. +The results are *candidates*, not confirmed plugin carriers: because every crate is implicitly a plugin, whether a given crate contributes anything is only known once it is fetched. This is deliberate: it lets `cargo agents use ` name a crate the workspace doesn't depend on, and defers the question to the fetch/load step. ### `fetch` behavior Given a package-id like `(cargo, serde-skills, 1.2.3)`: -1. Use the existing cargo toolchain to obtain crate sources — leveraging `~/.cargo/registry/src/` (the unpacked source cache) or triggering `cargo fetch` if needed. -2. Locate the unpacked crate source in cargo's cache. -3. The crate root directory is the plugin directory (defaults apply to discover skills, etc.). -4. Copy (or symlink) the plugin root into the destination path provided by Symposium. +1. A path dependency resolves to its local directory directly. +2. A `(name, version)` already unpacked resolves to that directory with no work at all. A published version is immutable, so once its source is on disk there is nothing to re-check and no reason to ask the network. +3. Otherwise use the existing cargo toolchain: `~/.cargo/registry/src/` (the unpacked source cache), falling back to a crates.io download. +4. The crate root directory is the plugin directory (defaults apply to discover skills, etc.). +5. Return that directory in place. + +Step 2 is what makes `fetch` cheap enough to sit on the hook path. `list_deps` +caching keyed on `Cargo.lock` avoids re-resolving the graph; this avoids +re-acquiring the sources that resolution named. Only an unresolved version +requirement needs the registry, and only to turn it into an exact version. This approach ensures compatibility with users who have custom registry configurations, alternative registries, or corporate mirrors — we go through cargo rather than around it. -### `list-deps` behavior +### `list_deps` behavior Reads the workspace to report direct Rust dependencies. -**Input:** workspace root directory (where `Cargo.toml` lives). - -**Strategy:** - -1. If `Cargo.lock` exists, read it — it has exact versions for all resolved dependencies. Return direct dependencies (those listed in workspace members' `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`). -2. If no lockfile, fall back to reading `Cargo.toml` manifests for dependency names (without exact versions). +**Input:** the workspace root, supplied once at `initialize`. **Output:** set of package-id tuples, e.g., `[(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]`. **Workspace handling:** - For a workspace with multiple members, union all members' direct dependencies. -- Path dependencies within the workspace are excluded (those are the user's own crates, not external deps). - Dev-dependencies are included (they're still dependencies the user works with). **Performance:** -- Parse `Cargo.lock` directly (it's a TOML file). No `cargo metadata` invocation. -- Cache results keyed on `Cargo.lock` mtime. -- If `Cargo.lock` hasn't changed, return cached results immediately. +- Cache results on disk, keyed on `Cargo.lock` mtime. +- If `Cargo.lock` hasn't changed, return cached results immediately: no resolution at all. + +### Workspace information + +Symposium itself needs the workspace root and the member directories: for workspace-local plugins, for scoping `use` entries, and for locating agent skill directories. It reads them off the cargo resolver today. + +Moving the cargo PM out of process means these cross the boundary, either as an extra method or as part of the `initialize` response. Loading plugins *from* those directories should stay in Symposium: they are local directory reads, and the workspace is a trust root whose policy core owns. The cargo PM's job is to report where the workspace is, not what it contains. ### Chained plugins for independent release @@ -109,7 +103,7 @@ If a crate author wants to release plugin content on a separate schedule from th ```toml # In widget-lib's Symposium.toml [[plugins]] -source.cargo = { widget-symposium = "1" } +source.cargo = "widget-symposium>=1" ``` This tells Symposium: "when this plugin is loaded, also load `widget-symposium`." The chained plugin can be published and updated independently. @@ -120,43 +114,46 @@ The cargo PM defaults to crates.io but can be configured to use alternative regi ## Frequently asked questions -### Why keys in `source.cargo` rather than `name`/`version` fields? +### How does `search` know which crates have plugin content without downloading them all? -The key-value style (`{ foo = "1.0", bar = "2.0" }`) mirrors how `[dependencies]` works in `Cargo.toml`, which is familiar to Rust users. It also naturally supports multiple crates per entry. +It doesn't, and doesn't try. Every crate is implicitly a plugin, so "has plugin content" is not knowable from the registry index: search returns name matches and the load step decides what each contributes. -### How does `search` know which crates have plugin content without downloading them all? +A keyword convention such as `symposium-plugin` is deliberately not used as a filter: it would only distinguish anything once crate authors adopted it, and until then it would hide plugin-bearing crates that had not. -Three approaches, in order of preference: -1. **Keyword convention** — crate authors add a `symposium-plugin` keyword. Search filters on this. -2. **Registry metadata** — if crates.io exposes enough metadata to detect `Symposium.toml` or `skills/` presence. -3. **Recommendations fallback** — for crates found via recommendations, we already know they have content. +### When should a crate use `[package.metadata.symposium]` rather than a `Symposium.toml`? -### Why not use `[package.metadata.symposium]` in Cargo.toml? +Both work, and a crate may use both: the table is the same manifest schema, +embedded, and the two are merged with the file taking precedence. The table +suits a crate declaring a small amount of plugin configuration that does not +justify another file. A crate with real plugin content should ship a +`Symposium.toml`, where the configuration is easier to find and to read. -We use `Symposium.toml` as the single configuration mechanism across all ecosystems. This avoids splitting plugin configuration between ecosystem-specific manifest files and keeps things consistent — whether your plugin comes from cargo, npm, or git, the configuration lives in `Symposium.toml`. +Note this is the same capability the PM interface generalizes. Reading plugin configuration out of an ecosystem's own manifest is exactly what [returning a synthesized manifest](../pm-interface/README.md#what-crosses-the-wire) is for; `[package.metadata.symposium]` is that idea applied to cargo, and an npm PM would do the same with `package.json`. ## Implementation plan and status -### Step 1: `list-deps` from Cargo.lock +Steps here follow the [PM interface plan](../pm-interface/README.md#implementation-plan-and-status): the cargo PM binary is step 4 there, and cannot start before the protocol exists. + +### Step 1: Extract the cargo PM into a standalone library -Parse `Cargo.lock` directly for dependency names and versions. Handle workspace members, exclude path deps. Mtime-based caching. +Separate workspace resolution, crate fetching, and crate-manifest merging from Symposium's core, so the binary is a thin wrapper. Keeping it a library is also what lets unit tests keep driving it in-process. -- [ ] PR: cargo PM `list-deps` +- [ ] PR: cargo PM library split -### Step 2: `resolve` with registry index +### Step 2: Carry workspace information over the protocol -Query the crates.io index (or alternative registry) to resolve version requirements to exact versions. +Add the workspace root, member directories, and crate list to the protocol, and move Symposium's readers onto it. -- [ ] PR: cargo PM `resolve` +- [ ] PR: workspace info over the wire -### Step 3: `fetch` via cargo toolchain +### Step 3: `symposium-pm-cargo` binary -Leverage cargo's registry cache to locate crate sources. Copy to dest. +Wrap the library in the SDK's server harness. Forward the cargo binary override so the test harness's fake cargo still applies. -- [ ] PR: cargo PM `fetch` +- [ ] PR: cargo PM binary -### Step 4: `search` with plugin detection +### Step 4: Switch Symposium to the subprocess -Search the registry, filter for plugin content (via keyword or metadata), rank results. +Replace the in-process instance with the spawned one. Measure the hook path before and after; confirm `Cargo.lock`-unchanged still means no resolution. -- [ ] PR: cargo PM `search` +- [ ] PR: cargo PM cutover + benchmark diff --git a/md/rfds/registry-centric-plugins/discovery-sync/README.md b/md/rfds/registry-centric-plugins/discovery-sync/README.md index b74e8f6b..5fa2ff52 100644 --- a/md/rfds/registry-centric-plugins/discovery-sync/README.md +++ b/md/rfds/registry-centric-plugins/discovery-sync/README.md @@ -128,27 +128,49 @@ On session start, a lightweight check runs: The hook does NOT install anything. It only notifies. Installation goes through `symposium sync`. -### Auto-install configuration +### Enablement configuration + +Enablement is keyed on `(pm, canonical-name)`, the identity every PM gives the +plugins it offers (see the [PM interface](../pm-interface/README.md#naming-a-plugin-in-configuration)). +The pair is what lets a user name one specific plugin: a crate for the cargo PM, +an entry path for a registry PM, and never an ambiguous bare word. ```toml # In ~/.symposium/config.toml -# Install all discoveries without prompting -auto-sync = true +[plugins] +# Pre-consented, so a discovery installs without prompting. +auto-enable = [{ pm = "cargo", name = "my-internal-crate" }] + +# Deliberate enablements, global or scoped to one workspace. +use = [ + { pm = "cargo", name = "widget" }, + { pm = "cargo", name = "gadget", workspace = "/path/to/project" }, +] -# Or per-PM granularity: -[auto-sync] -recommendations = true # auto-install from recommendations -cargo = false # prompt for crates.io discoveries +# Pruned from enablement, which is also where a decline is recorded, and how a +# plugin from a trusted source is turned off. +disable = [{ pm = "symposium-recommendations", name = "rtk" }] ``` -### Dismissed discoveries +`auto-enable` also accepts `"*"`, meaning every dependency-embedded plugin is +consented to. `disable` still applies on top, so blanket consent stays +overridable one plugin at a time. + +### Declined discoveries + +A decline is recorded in `[plugins] disable` in the user config, and is +permanent until the user edits it. It lives in config rather than state because +it is a decision the user made and should be able to see and revise, not a +cache Symposium is free to invalidate; a version bump does not re-raise it. -When a user says "none" to a discovery, it's suppressed until: -- The plugin's version changes (a new release might be more relevant) -- The user explicitly searches for it via `symposium use` +Only an explicit "never ask again" is written. The prompt's default answer +("ask me later") and Escape record nothing, so hitting Enter reflexively never +declines anything permanently. -Dismissals are tracked in state (`~/.symposium/state.toml`). +The prompt is inert unless the output is attached to a terminal on both ends. A +hook must never block on stdin, so on the hook path the pending candidates are +rendered into `SessionStart` context pointing at `cargo agents sync` instead. ### Debouncing and caching @@ -180,28 +202,28 @@ Yes: `auto-sync = false` (the default) means you only get notified, never auto-i Wire up the pipeline with the path PM initially to validate the flow end-to-end. -- [ ] PR: sync pipeline with path PM +- [x] PR: sync pipeline with path PM ### Step 2: Discovery algorithm Implement `list-deps` → `search` loop across all PMs. -- [ ] PR: discovery algorithm +- [x] PR: discovery algorithm ### Step 3: Prompt UX Present discoveries, record choices (accept/dismiss). -- [ ] PR: discovery prompt +- [x] PR: discovery prompt ### Step 4: Hook notification Add discovery check to session-start hook. Use cached results only. -- [ ] PR: session start notification +- [x] PR: session start notification ### Step 5: Auto-install and dismissal Add `auto-sync` config, per-PM granularity, and dismissed-discovery tracking. -- [ ] PR: auto-install + dismissal state +- [x] PR: auto-install + dismissal state diff --git a/md/rfds/registry-centric-plugins/plugin-model/README.md b/md/rfds/registry-centric-plugins/plugin-model/README.md index 76dfc9fc..81a4fbee 100644 --- a/md/rfds/registry-centric-plugins/plugin-model/README.md +++ b/md/rfds/registry-centric-plugins/plugin-model/README.md @@ -55,6 +55,12 @@ A plugin is a directory. That's it. The directory may contain: When a directory has no `Symposium.toml`, Symposium behaves as if an empty one exists. This empty manifest still triggers default behavior (see below). +A registry entry is the exception. A manifest that references no dependency anywhere +has nothing to infer a gate from, and treating it as "always on" would fire every +curated plugin in every workspace. Such a plugin loads +[dormant](../README.md#dormancy) and activates only when a `[plugins] use` entry +names it. `depends-on = ["*"]` is the explicit always-active spelling. + ### `Symposium.toml` structure ```toml @@ -137,15 +143,17 @@ The plugin itself and each of its subsections can be gated with a `predicates = Common predicates: * `workspace()` — true if this plugin is part of the active workspace -* `used()` — true if this plugin was explicitly used by the user * `workspace-dependency()` — true if plugin is a dependency of some project in the current workspace * `depends-on(pm, name, version)` — true if the workspace depends on this package * `env(FOO=BAR)` — true if the environment variable is set to the given value * `file-exists(path)` — true if the given file exists relative to workspace root * `shell(command)` — true if the command exits with code 0 -* `workspace-directory(path)` — true if the workspace is a subdirectory of the given path * `not(p)`, `any(p, ...)`, `all(p, ...)` — combinators +Explicit enablement is deliberately *not* a predicate. Enablement is a separate +axis deciding whether a plugin may run at all, recorded in `[plugins]` and +consulted before predicates are evaluated. + The `[depends-on]` shorthand reuses the PM's `resolve` format: ```toml @@ -163,12 +171,13 @@ A plugin can declare additional plugins to be loaded when it activates: ```toml [[plugins]] -source.cargo = { serde-extras = "*" } - -[[plugins]] -source.path = { path = "./sub-plugin" } +source.cargo = "serde-extras>=1" ``` +A chained edge names a *package*, which its package manager resolves. `source.path` +and `source.git` are rejected with a hint: a path is not a package, and local +content is reachable as a `[[skills]] source.path` group or as a workspace plugin. + Chaining is an *activation-time* relationship: when this plugin becomes active, also load these. Chained plugins: - Are fetched and cached transitively (installing A also fetches A's chained plugins) - Have their own predicates (they may not activate even if the parent does) @@ -209,32 +218,34 @@ skills = false ## Implementation plan and status +All five steps landed. One follow-on remains: a crate-embedded plugin can *define* a custom predicate, but the definition isn't registered, so it can't be evaluated. See the parent RFD's [future work](../README.md#future-work). + ### Step 1: Plugin struct and manifest parsing Define the `Plugin` struct, parse `Symposium.toml`, synthesize empty manifests for directories without one. -- [ ] PR: plugin struct + TOML parsing +- [x] PR: plugin struct + TOML parsing ### Step 2: Default application Implement skill discovery from `skills/` and `.agents/skills/`. Suppression via `[defaults]`. -- [ ] PR: plugin defaults +- [x] PR: plugin defaults ### Step 3: Predicates on plugins Evaluate predicates at the plugin level and per-construct level. Gate activation. Implement the `[depends-on]` shorthand. -- [ ] PR: predicate evaluation +- [x] PR: predicate evaluation ### Step 4: Chained plugins Parse `[[plugins]]` entries, resolve via PMs, fetch transitively, evaluate independently. -- [ ] PR: chained plugin loading +- [x] PR: chained plugin loading ### Step 5: Integration with sync Wire the new plugin model into the sync pipeline: iterate installed plugins, evaluate predicates, sync active content to agent directories. -- [ ] PR: sync integration +- [x] PR: sync integration diff --git a/md/rfds/registry-centric-plugins/pm-interface/README.md b/md/rfds/registry-centric-plugins/pm-interface/README.md index e384ebac..e628368d 100644 --- a/md/rfds/registry-centric-plugins/pm-interface/README.md +++ b/md/rfds/registry-centric-plugins/pm-interface/README.md @@ -2,27 +2,36 @@ ## TL;DR -- Define a four-operation interface (`resolve`, `search`, `fetch`, `list-deps`) that all package managers implement. -- PMs are separate binaries communicating via JSON-RPC over stdio. -- Only `path` is built into the Symposium binary; `cargo`, `git`, and future PMs are external. +- Define an operation set (`initialize`, `active_plugins`, `load_plugin`, `list_deps`, `search`, `fetch`, `refresh`) that all package managers implement. +- PMs are separate binaries speaking newline-delimited JSON-RPC over stdio. One long-lived process per PM per Symposium invocation. +- A PM returns plugin *manifests*, not just directories, so it can synthesize a plugin for a package with no `Symposium.toml`, or one whose manifest is in another ecosystem's format. +- Trust is assigned by Symposium, never claimed by the PM. ## Motivation -Symposium needs to fetch plugins from multiple ecosystems without hard-coding each one. The PM interface is the seam: implement four operations and your ecosystem becomes a plugin source. This lets us ship cargo support today, add npm/pypi later, and let enterprises plug in internal registries — all without changing core. +Symposium needs to fetch plugins from multiple ecosystems without hard-coding each one. The PM interface is the seam: implement a handful of operations and your ecosystem becomes a plugin source. Cargo, npm, pypi, and an enterprise's internal registry all arrive the same way — all without changing core. ## Change in a nutshell -A PM is a separate binary that speaks JSON-RPC over stdio. Here's the cargo PM responding to `resolve`: +A PM is a separate binary that speaks JSON-RPC over stdio. Here's the cargo PM responding to `load_plugin`: ```toml # User writes in Symposium.toml: [[plugins]] -source.cargo = { serde-skills = "1" } +source.cargo = "serde-skills>=1" ``` -Symposium passes `{ "serde-skills": "1" }` to the cargo PM's `resolve` method. It queries crates.io and returns `(cargo, serde-skills, 1.2.3)`. +Symposium sends `load_plugin` with the id `(cargo, serde-skills, >=1)`. The cargo PM resolves the requirement, obtains the crate source, and returns the exact id, the content directory, and the plugin manifest it read (or synthesized) from that directory: -Then `fetch((cargo, serde-skills, 1.2.3))` downloads the crate and unpacks the plugin directory into cache. +```json +{ "result": [{ + "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, + "root": "/home/user/.cargo/registry/src/index.crates.io-.../serde-skills-1.2.3", + "manifest": { "skills": [{ "source": { "path": "skills" } }] } +}] } +``` + +Symposium validates that manifest, applies its own defaults and trust rules, and resolves the skill group against `root`. ## Detailed plans @@ -43,76 +52,182 @@ In the JSON-RPC protocol, a package-id is represented as: ### The protocol -PMs are separate binaries invoked by Symposium. Communication uses JSON-RPC over stdio (the same pattern as MCP servers). Each PM binary is long-lived — Symposium spawns it once and sends multiple requests. +PMs are separate binaries invoked by Symposium. Communication uses JSON-RPC 2.0 over stdio, **newline-delimited**: one JSON object per line, no `Content-Length` framing. Nothing in the payloads needs an embedded newline, so the simpler framing is enough. Each PM binary is long-lived: Symposium spawns it once per invocation and sends multiple requests, multiplexed by request id. + +#### `initialize` + +```json +// Request +{ "method": "initialize", "params": { + "protocol_version": 1, + "workspace": "/home/user/projects/my-app", + "cache_dir": "/home/user/.symposium/cache", + "env": { "SYMPOSIUM_CARGO": "/usr/bin/cargo" } +} } + +// Response +{ "result": { "protocol_version": 1, "name": "cargo", "capabilities": ["search", "list_deps"] } } +``` + +Sent once, before any other method. Carries the per-invocation context the PM needs; the PM answers with the name it owns (the `pm` component of every id it mints) and which optional operations it implements. + +A PM is otherwise **self-contained**: it holds whatever it needs to resolve its own ecosystem, so no later method takes ambient context. This is why `workspace` lives here rather than on `list_deps` as originally proposed: with a long-lived process the workspace is fixed for the connection's lifetime. + +Version negotiation is strict for now: a PM reporting a `protocol_version` Symposium doesn't know is refused with a warning, and its plugins are simply absent. + +#### `active_plugins` + +```json +// Request +{ "method": "active_plugins", "params": { "deps": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }] } } + +// Response +{ "result": [{ "id": {...}, "root": "...", "manifest": {...} }] } +``` + +The plugins this PM activates for the workspace's dependency set. The two shapes it covers: -The protocol defines four methods: +- A **registry** instance lists its own entries and ignores `deps`. +- An **ecosystem transport** (cargo) surfaces the plugins its dependencies embed. -#### `resolve` +Whether the result may run without the user's consent is Symposium's decision, not the PM's: see [Enablement](#enablement). + +#### `load_plugin` ```json // Request -{ "method": "resolve", "params": { "value": { "serde-skills": "1" } } } +{ "method": "load_plugin", "params": { "id": { "pm": "cargo", "name": "serde-skills", "version": ">=1" } } } // Response -{ "result": [{ "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }] } +{ "result": [{ "id": {...}, "root": "...", "manifest": {...} }] } ``` -Takes the opaque TOML value from `source. = { ... }` (passed as JSON). Returns a set of package-ids. +The plugin(s) a *specific* id maps to: a `[[plugins]]` chained reference, or a crate the user enabled by name. Resolves the version requirement, obtains the content, and returns the plugin(s) found there. Returning zero plugins is not an error. + +This is the method the original `resolve` folded into. The version component of the request id may be a requirement (`">=1"`, or `"*"` for none); the response id always names the exact resolved version. -- Cargo PM: `{ "serde-skills": "1" }` → queries registry → `(cargo, serde-skills, 1.2.3)` -- Git PM: `{ "url": "...", "branch": "main" }` → resolves ref → `(git, git@github.com:org/repo#main, abc123)` -- Path PM (built-in, not JSON-RPC): `{ "path": "./my-plugin" }` → canonicalizes +#### `list_deps` -May involve network calls. Deterministic given same registry state. +```json +// Response +{ "result": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }, { "pm": "cargo", "name": "tokio", "version": "1.38.0" }] } +``` + +The dependencies of the workspace given at `initialize`, in this PM's ecosystem. PMs with no workspace notion return empty. + +Contract: +- Direct dependencies only (not transitive). +- Must be fast: this is on the hook path. Read lockfiles, don't query the network, cache on the lockfile's mtime. #### `search` ```json // Request -{ "method": "search", "params": { "query": { "pm": "cargo", "name": "serde", "version": "1.0.210" } } } +{ "method": "search", "params": { "query": "serde" } } // Response { "result": [{ "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "description": "..." }] } ``` -Takes a package-id tuple (all fields provided — as returned by another PM's `list-deps`). Returns matching plugins from this PM's perspective. - -- Each PM decides which tuple components to match on. The recommendations PM ignores version; the cargo PM matches on name. -- If the query's `pm` field doesn't relate to this PM, it may return empty. -- Used during discovery: `list-deps` results are passed as queries to every PM's `search`. +Find packages matching a partial query string; backs `cargo agents use` and `cargo agents search`. PMs without a searchable registry return empty. #### `fetch` ```json // Request -{ "method": "fetch", "params": { "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "dest": "/home/user/.symposium/cache/cargo/serde-skills/1.2.3" } } +{ "method": "fetch", "params": { "id": {...}, "update": "none" } } // Response -{ "result": { "path": "/home/user/.symposium/cache/cargo/serde-skills/1.2.3" } } +{ "result": { "id": {...}, "root": "/home/user/.cargo/registry/src/.../serde-skills-1.2.3" } } ``` -Downloads exact versioned content into the provided destination directory. +Acquire a package's content and report where it landed, canonicalizing the id's version. `update` is `none` (serve from cache, never touch the network), `check`, or `fetch` (force). Contract: -- Same package-id always produces same content. -- PM writes into `dest`, which Symposium provides. -- If `dest` already has content, PM may skip (cache hit). +- The same package-id always produces the same content. +- The PM owns the directory and guarantees it stays valid for the connection's lifetime. +- `update: "none"` must not make a network call. This is what keeps per-event hook dispatch offline. -#### `list-deps` +#### `refresh` ```json // Request -{ "method": "list_deps", "params": { "workspace": "/home/user/projects/my-app" } } +{ "method": "refresh", "params": { "update": "check", "force": false } } // Response -{ "result": [{ "pm": "cargo", "name": "serde", "version": "1.0.210" }, { "pm": "cargo", "name": "tokio", "version": "1.38.0" }] } +{ "result": { "refreshed": true } } ``` -Inspects the workspace and reports dependencies relevant to this PM. Returns full package-id tuples. +Pull the PM's backing source: for a git-backed registry, fetch the repository. A no-op returning `false` for PMs whose content is already local. `force` overrides a source's auto-update opt-out, for an explicit `cargo agents plugin sync`. -Contract: -- Direct dependencies only (not transitive). -- Must be fast — called on every sync. Read lockfiles, don't query the network. +### What crosses the wire + +A PM returns a **plugin manifest**, not merely a directory: + +```json +{ "id": {...}, "root": "/path/to/content", "manifest": { /* Symposium.toml schema, as JSON */ } } +``` + +Returning a manifest rather than only a path is what lets a PM **synthesize** a plugin: for a package with no manifest at all, or one whose configuration lives in a different ecosystem's format (an npm PM reading `package.json`, say). A PM that does nothing special just parses the `Symposium.toml` it found and hands it back. + +The manifest on the wire is the **raw, unvalidated** schema: the same shape a `Symposium.toml` deserializes into. Validation stays in Symposium: + +| Concern | Owner | +|---------|-------| +| Producing a manifest (parse, synthesize, translate) | PM | +| Schema validation, inline-installation promotion | Symposium | +| Defaults (`skills/`, `.agents/skills/`), `[defaults]` handling | Symposium | +| Dormancy, trust, consent | Symposium | +| Resolving `source.path` against `root` | Symposium | + +This split keeps policy in one place. A PM reports which plugins exist and what +they contain; which of them are enabled is decided from configuration and from +the source the plugin came from, neither of which is anything the PM says. + +The schema is published as a Rust crate that both Symposium and PM authors depend on, so a Rust PM builds the manifest as a typed value rather than assembling JSON by hand. PMs in other languages target the JSON shape directly. + +**Future optimization.** A PM could answer with `{"manifest_path": "Symposium.toml"}` instead of an inline manifest, letting Symposium read the file itself and skipping a serialize/deserialize round trip for the common case. Not needed to start. + +### Enablement + +A PM reports what is available. Symposium decides what runs, from two inputs: +the user's `[plugins]` configuration, and which source the plugin came from. + +Some sources are trusted, meaning a plugin from them is enabled without the user +being asked: + +- the **recommendations registry**, +- the **current workspace** (its root and members), +- the configured `[[registry]]` entries the user added by hand. + +A plugin embedded in a dependency is not: depending on a package should not let +its author add to your agent's context, so it runs only once the user consents. + +### Naming a plugin in configuration + +To enable or disable a specific plugin, the user has to be able to name it, and +the name has to survive across runs. So every plugin has a **canonical name**, +supplied by the PM that offers it, and configuration entries are the pair +`(pm, canonical-name)`: + +```toml +[plugins] +# Turn off one recommendation, overriding the registry's trusted-by-default +# status. +disable = [{ pm = "symposium-recommendations", name = "rtk" }] + +# Consent to a plugin embedded in a dependency. +auto-enable = [{ pm = "cargo", name = "my-internal-crate" }] +``` + +Each PM picks names that are stable and meaningful for its ecosystem. The cargo +PM uses the crate name. A registry PM uses the entry's path within the registry. +The pair is qualified by PM so that two ecosystems using the same word do not +collide, and so that a name always identifies exactly one thing. + +This is what makes a trusted source overridable. Recommendations are enabled +without asking, which is the point of them, but a user who does not want a +particular one names it and turns it off. ### Error handling @@ -125,115 +240,155 @@ Errors use JSON-RPC error codes: | -32003 | Invalid input | Hard error at parse time | | -32004 | Auth required | Report to user with setup instructions | +Beyond named codes, plugin loading is **best-effort** and must stay that way across the process boundary. A PM that errors, hangs past its timeout, crashes, or fails its `initialize` handshake degrades to "contributes no plugins," logged as a warning. One broken PM never aborts a sync or a hook: the same contract the in-process layer already holds, where a plugin that fails to load is dropped rather than surfaced. + +Anything written to a PM's stderr is captured and logged at debug level, so a PM can be diagnosed without disturbing the protocol on stdout. + ### PM lifecycle Symposium manages PM binaries as follows: -1. PMs are installed as `[[installable]]` entries — from the recommendations repository or the user's root config. -2. On first use, Symposium spawns the PM binary and connects via stdio. -3. The PM stays alive for the duration of the sync/hook operation. -4. Symposium may call methods concurrently (the PM should handle this or serialize internally). +1. On first use, Symposium spawns the PM binary, connects via stdio, and sends `initialize`. +2. The PM stays alive for the rest of the Symposium invocation, and is shut down when `PmRegistry` drops. +3. Spawning is **lazy**: a PM whose operations are never needed is never started. +4. Symposium may have several requests in flight (the PM handles this or serializes internally). + +A PM binary is found one of three ways: -The `path` PM is the exception — it's built into the Symposium binary itself (since it just reads local directories and has no external dependencies). +1. **Built in.** The PMs Symposium ships with are located by name, with no configuration required. +2. **Config-declared.** A `[[package-manager]]` section names the PM and points at an installation source, acquired through the same machinery hook binaries already use. This is the bootstrap channel: it cannot depend on plugins being loaded, since loading plugins is what needs PMs. +3. **Plugin-vended.** A plugin registers a new PM type, per the parent RFD's [future work](../README.md#future-work). The `initialize` handshake is designed so this needs no protocol change. ### Cache layout -``` -~/.symposium/cache/ -├── cargo/ -│ └── serde-skills/ -│ └── 1.2.3/ -│ ├── Symposium.toml -│ └── skills/ -├── git/ -│ └── github.com-org-repo/ -│ └── abc123/ -│ └── ... -└── recommendations/ - └── cargo/ - └── serde/ - └── ... -``` +Symposium hands each PM a `cache_dir` in the `initialize` handshake and the PM +caches whatever it likes underneath it. What goes there, and how it is arranged, +is entirely the PM's business: Symposium never reads or interprets the contents. -Cache is a pure optimization — deletable and rebuildable from config. Symposium owns the directory structure; PMs write content into the slot they're given. +The trade runs both ways. A PM gets one canonical place to write, so it does not +have to invent a location or ask the user to configure one, and everything +Symposium caused to be downloaded is in one place. In exchange, Symposium may +delete that directory at any time, so a PM must treat it as a cache and never as +storage: anything it cannot rebuild does not belong there. -### Built-in PMs +A PM is free to serve content from outside `cache_dir` when its ecosystem +already has a cache worth reusing. The cargo PM does exactly this, serving +sources out of `~/.cargo/registry/src/`, which is why the directory is offered +rather than imposed. + +`fetch` returns a `root` the PM guarantees valid for the connection's lifetime. +Symposium reads it and never writes to it. -#### `path` +### Built-in PMs -Built into the Symposium binary. For local development and workspace-local plugins. +`path` and `git` are built into the Symposium binary, for one reason: bootstrap. +A configured PM is a binary that has to be acquired, and acquiring anything means +reading a registry first. `path` and `git` are what make that first read possible, +so they cannot themselves be things you acquire. The default recommendations +registry is git-sourced, so a fresh install has to be able to read a git registry +before it has acquired anything at all. -- `resolve`: canonicalizes a path, returns `(path, /absolute/path, _)`. -- `search`: returns empty (not a searchable registry). -- `fetch`: no-op (content is already on disk). -- `list-deps`: returns empty. +Neither is built in because a separate process would be technically awkward. +`git` in particular does need the network, and the fetching and caching it needs +already exist in Symposium for git skill-group sources and hook binaries, so +building it in reuses machinery rather than adding any. If the bootstrap +constraint ever went away, either could become an ordinary PM binary without a +protocol change. -### External PMs (shipped as installables) +Every other PM needs ecosystem tooling that Symposium has no reason to carry, and +is a separate binary. #### `cargo` Separate binary (`symposium-pm-cargo`). See the [cargo PM sub-RFD](../cargo-pm/README.md) for details. -#### `git` - -Separate binary (`symposium-pm-git`). Resolves refs to commit SHAs, fetches repo content. - -#### `recommendations` +#### `git` as a chained source -Separate binary (`symposium-pm-recommendations`). Operates over the curated recommendations repository. See the main README's [recommendations manager section](../README.md#example-the-recommendations-manager) for structure. +`source.git` on a `[[plugins]]` chained reference is still rejected. Git *registries* and git *skill-group* sources both work today through the built-in reader; what's missing is naming a git repository as a chained plugin. That does not obviously need a separate binary either, and is left open. ## Frequently asked questions ### Why JSON-RPC over stdio? -It's the same pattern used by MCP servers and LSP — well-understood, language-agnostic, and debuggable. We can use the `agent-client-protocol` SDK for the implementation. It also means PMs can be written in any language. +It's the same pattern used by MCP servers and LSP: well-understood, language-agnostic, and debuggable. It also means PMs can be written in any language. ### Why not compile PMs into the binary? Language-agnosticism. We want npm/pypi PMs eventually, and those may be best written in JS/Python. Even for Rust-based PMs, the binary boundary keeps the core small and lets PMs be updated independently. -### Why is `path` the only built-in? +### Why is the manifest on the wire instead of a directory? -It has no external dependencies and no protocol overhead would be justified for "return this local directory." Every other PM needs network access, registry-specific logic, or ecosystem tooling — better as separate binaries. +So a PM can describe a package that doesn't describe itself. A crate with a bare `skills/` directory has no manifest; an npm package's configuration would live in `package.json`. If the wire form were a path, every such case would need Symposium to learn that ecosystem's conventions, which is exactly what the PM boundary exists to avoid. + +### Doesn't returning a manifest let a PM claim anything it likes? + +It describes content, which is its job. What it does not decide is whether any +of that runs: validation, defaults, and enablement are applied by Symposium +after the manifest arrives, from configuration and from the source the offer +came from. See [Enablement](#enablement). ### Who resolves version requirements — Symposium or the PM? -The PM. When config says the user wants `serde-skills` version `1.*`, Symposium calls `resolve` with that constraint. The PM knows how to interpret version ranges for its ecosystem. +The PM. Symposium sends `load_plugin` with the requirement in the id's version component; the PM interprets the range for its ecosystem and answers with the exact version. + +### What does this cost on the hook path? + +A process spawn per PM per invocation. The property worth protecting is not "no subprocess" but "no `cargo metadata`": that's the expensive part, since it reads and resolves the whole graph. The `update: "none"` contract keeps `fetch` offline, `list_deps` caches on the lockfile mtime, and lazy spawning means a workspace whose predicates never reference a dependency starts no PM at all. + +If spawn cost does turn out to matter, the answer is a daemon mode for PMs (and for Symposium) rather than folding PMs back into the binary. That's a larger change and not proposed here. ## Implementation plan and status -### Step 1: Define the JSON-RPC protocol schema +### Step 1: Extract the manifest schema into a shared crate -Document the four methods, their request/response shapes, and error codes. Publish as a schema that PM authors can validate against. +Move the raw `Symposium.toml` schema and the predicate *syntax* types (parsing, `Display`, serde, not evaluation) into a crate both Symposium and PM authors depend on. Add `Serialize` alongside the existing `Deserialize`. -- [ ] PR: protocol schema definition +Tests: round-trip every manifest fixture in the repo through JSON and assert the validated `Plugin` is identical. -### Step 2: Implement the `path` PM (built-in) +- [ ] PR: manifest schema crate -Simplest case — validates the fetch/cache flow end-to-end without spawning an external process. -- [ ] PR: path PM implementation + tests +### Step 2: Reshape the in-process trait to the wire shape + +`active_plugins` / `load_plugin` return `{id, root, manifest}` instead of an already-validated plugin. Manifest *production* moves to the PM side; validation, defaults, and trust move to a single core seam. Still fully in-process: this is a refactor with no protocol involved, and it is what de-risks step 3. + +Tests: the existing suite passes unchanged. + +- [ ] PR: offer-shaped PM trait + ### Step 3: PM process management -Spawning, stdio connection, JSON-RPC framing, lifecycle management (start on demand, keep alive during sync). +Newline-delimited JSON-RPC client, the `initialize` handshake, lazy spawn, lifecycle and shutdown, timeout and crash handling. A server harness in the SDK so a Rust PM is a `main` plus a trait impl. + +Tests: a fixture PM binary that returns canned manifests, driven end to end; plus failure cases: a PM that exits immediately, one that returns malformed JSON, one that never answers. + +- [ ] PR: PM process manager + SDK harness -- [ ] PR: PM process manager ### Step 4: Implement `symposium-pm-cargo` -First external PM. Port existing crate-fetch logic. Validates the full JSON-RPC round-trip. +Port workspace resolution, crate fetching, and crate-manifest merging into the binary. Add whatever the workspace root and members need to cross the boundary, since core reads them in a dozen places. + +Concretely: + +1. Split the cargo PM into a library the binary wraps, so unit tests can keep driving it in-process through the trait. +2. Carry workspace information over the protocol. Core reads the workspace root and member directories off the cargo resolver in a dozen places, so this is the bulk of the change. Loading plugins *from* those directories stays in Symposium: they are local directory reads, and the workspace is a trust root whose policy core owns. The cargo PM's job is to report where the workspace is, not what it contains. +3. Forward `SYMPOSIUM_CARGO` into the child, since the test harness installs a fake cargo and a child inherits no environment. + +Tests: the existing integration suite, driven through the real subprocess. - [ ] PR: cargo PM binary + tests -### Step 5: Implement `symposium-pm-git` -Resolves refs, fetches repos. Validates a second external PM works with the protocol. +### Step 5: Configuration surface + +The `[[package-manager]]` section and acquisition through the existing installation machinery, replacing the hard-coded lookup from step 3. -- [ ] PR: git PM binary + tests +- [ ] PR: PM configuration -### Step 6: Implement `symposium-pm-recommendations` +### Step 6: A non-Rust-ecosystem reference PM -Operates over the recommendations repository structure. +One PM that synthesizes manifests from a foreign format, proving the boundary carries an ecosystem Symposium knows nothing about. -- [ ] PR: recommendations PM binary + tests +- [ ] PR: reference PM diff --git a/md/rfds/registry-centric-plugins/user-managed-plugins/README.md b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md index 9ef6620f..dc7ed4f7 100644 --- a/md/rfds/registry-centric-plugins/user-managed-plugins/README.md +++ b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md @@ -168,22 +168,22 @@ If a global and local plugin provide a skill with the same name, the local one w Define and parse the `[[plugins]]` and `[[workspace-plugins]]` entries in config. -- [ ] PR: config format + parsing +- [x] PR: config format + parsing ### Step 2: `symposium use` Search flow, selection UX, writing to config, triggering sync. -- [ ] PR: `use` command +- [x] PR: `use` command ### Step 3: `symposium remove` Matching, removal from config, cleanup on next sync. -- [ ] PR: `remove` command +- [x] PR: `remove` command ### Step 4: `symposium status` Display installed/active/inactive plugins with provenance and predicate status. -- [ ] PR: `status` command +- [x] PR: `status` command From 0735c0e0d29d468a9aa78093aa57499f53e685b1 Mon Sep 17 00:00:00 2001 From: jackh726 Date: Wed, 12 Aug 2026 16:01:26 +0000 Subject: [PATCH 5/5] RFD updates --- md/rfds/registry-centric-plugins/README.md | 40 ++++++-- .../discovery-sync/README.md | 75 +++++++++++---- .../plugin-model/README.md | 52 +++++----- .../pm-interface/README.md | 12 ++- .../user-managed-plugins/README.md | 94 ++++++++++++------- 5 files changed, 181 insertions(+), 92 deletions(-) diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index 5473d060..25b983a8 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -92,11 +92,24 @@ symposium use --global X This works the same way but activates those plugins across all workspaces. +`use` is also how a user reaches a plugin that nothing about the workspace implies: a curated plugin that names no dependency, so no [activation root](#activation-roots) would ever pick it up on its own. + Users could also edit their config.toml to define their specific predicates for when they want plugins to be activated (e.g., when a certain file is present in the workspace, for Rust workspaces only, etc). -### Dormancy +### Turning plugins off + +`disable` is the off switch, listing plugins that must not run whatever else says otherwise: + +```toml +[plugins] +disable = [{ pm = "symposium-recommendations", name = "rtk" }] +``` + +It is deliberately the last word. Every other mechanism (a trusted registry, `auto-enable`, an explicit `use`) says a plugin *may* run; `disable` is the single place that says it may not. So a plugin that is both `use`d and disabled stays off, and re-enabling it means dropping the `disable` entry. `symposium use --remove` does not do that: it removes a `use` entry, so it cannot cancel a decision the user made in the other direction. -A registry plugin whose manifest references no dependency anywhere — no `depends-on`, no `depends-on(...)` predicate, no `[[skills]]`/`[[hooks]]`/`[[mcp]]`/`[[plugins]]` gate that names one — has nothing to infer an activation gate from. Rather than treat that as "always on" (which would fire every curated plugin in every workspace), such a plugin is *dormant*: installed and known, but inactive until a `[plugins] use` entry names it. `depends-on = ["*"]` is the explicit "always active" spelling. The positional origins never go dormant, because where they were found supplies the gate: a crate plugin is reached through a reference to its own crate, and a workspace plugin is gated by workspace membership. +A decline at the discovery prompt is recorded here too, which is the same rule seen from the other side: having said "never ask again" about a dependency's plugin, the user does not get asked again, and does not silently get the plugin either. + +Unlike `use`, a `disable` entry carries no workspace scope: it is global. See [enablement configuration](./discovery-sync/README.md#enablement-configuration) for the full precedence rules. ## As a crate author @@ -117,7 +130,7 @@ widget/ crates/ widget-lib/ Cargo.toml - Symposium.toml <-- defines `[[plugins]] source.cargo = { widget-symposium = "1" }` + Symposium.toml <-- defines `[[plugins]] source.cargo = "widget-symposium>=1"` widget-test/ Cargo.toml widget-symposium/ @@ -132,7 +145,7 @@ You can also add a plugin into the central symposium recommendations repository. symposium-recommendations/ ... cargo/ - widget-lib/ <-- defines `[[plugins]] source.cargo = { widget-symposium = "1" }` + widget-lib/ <-- defines `[[plugins]] source.cargo = "widget-symposium>=1"` Symposium.toml ``` @@ -182,7 +195,17 @@ The plugin itself and each of its subsections can be gated with a `predicates = `depends-on` is sugar for the common dependency case: `depends-on = ["serde", "tokio"]` lowers to `any(depends-on(serde), depends-on(tokio))`, ANDed with any `predicates`. -Whether a plugin was **explicitly used** and whether it is a **workspace dependency** are *not* predicates. "Used" is the enablement axis, a `[plugins] use` entry (see [Explicit use](#explicit-use)), which is also how a [dormant plugin](#dormancy) wakes; dependency presence is `depends-on()`. These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once. +Whether a plugin was **explicitly used** and whether it is a **workspace dependency** are *not* predicates. "Used" is the enablement axis, a `[plugins] use` entry (see [Explicit use](#explicit-use)), which is also the only [activation root](#activation-roots) available to a plugin that names no dependency; dependency presence is `depends-on()`. These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once. + +#### Activation roots + +Predicates say *when* a plugin applies, not why it was in play at all. That is a separate question, and every active plugin answers it with an **activation root**. There are three: + +* **Workspace membership**, for a plugin defined by the workspace root or one of its members. +* **A dependency**, either one the plugin is embedded in, or one it names with `depends-on` and the workspace has (`depends-on = ["*"]` names every workspace). +* **An explicit `use` entry**, which needs nothing from the workspace at all. + +`symposium status` reports the root each plugin came in on. A registry entry that names no dependency has none of the three until a `use` entry gives it one, so it loads and is listed but contributes nothing. #### Default content @@ -211,7 +234,7 @@ Every PM implements these operations: |-----------|-------|--------|---------| | `active_plugins` | the workspace's dependency ids | set of plugin offers | discovery, sync | | `load_plugin` | package-id | set of plugin offers | chained references, `use` | -| `search` | partial query string | set of package-ids + metadata | `symposium use` | +| `search` | partial query string | set of package-ids + metadata | `symposium use`, `symposium search` | | `fetch` | package-id | directory with plugin content | sync/install | | `list_deps` | (none) | set of package-ids | auto-discovery | | `workspace_info` | (none) | workspace root and members | workspace plugins, scoping | @@ -309,12 +332,13 @@ The remaining work, roughly in dependency order: - **Additional built-in ecosystems** — there is no `git` PM yet (git *sources* for skill groups and installations exist, but a chained `source.git` is rejected); npm/pypi are unstarted. - **Custom predicate dispatch across plugins (fixed-point)** — a crate-embedded plugin can *define* a custom predicate, but its definition is not yet registered, so it cannot be evaluated (only registry plugins' custom predicates are). Wiring a crate's *own* custom predicates into its facet evaluation is tractable; the general case — one plugin defines a predicate that another plugin's gate references — needs a convergence loop, since the definition must be loaded before the gate that uses it can be evaluated. - **Chained-edge version enforcement** — `[[plugins]] source.cargo = "widget>=1"` records the version requirement but does not enforce it: expansion enqueues the crate with no version, so it resolves against the workspace pin regardless. Enforcement would compare the resolved version to the recorded requirement and warn/skip on mismatch. +- **Workspace-scoped `disable`**: a `use` entry can be scoped to one workspace; a `disable` entry cannot, so turning a plugin off in one project turns it off everywhere. The scoping machinery already exists on the `use` side, so this is mostly a matter of deciding how a scoped "off" and a global "on" compose. - **Policy plugins** — org-level enforcement (deny-lists, approval gates). Separate extension point, design TBD. ## Implementation status -1. **Plugin model.** Plugins, `[defaults]`, predicates, chained plugins, dormancy. +1. **Plugin model.** Plugins, `[defaults]`, predicates, chained plugins, activation roots. 2. **PM interface and the cargo PM.** The identity tuple, the operation set, the JSON-RPC transport (`symposium_sdk::pm::protocol` and `pm::server` on the PM's side, `pm::RemotePm` on Symposium's), and `symposium-pm-cargo` as a standalone crate and binary. A PM answers with a `PluginOffer`: an id, a content directory, and an unvalidated manifest, so it can synthesize a plugin for a package that has none. `[[package-manager]]` config entries add ecosystems beyond cargo. -3. **Discovery and sync.** Dependency-embedded plugin discovery, the consent prompt, and the `[plugins]` config. Recommendations are a flat registry rather than a `search` result (see the note under [the recommendations manager](#example-the-recommendations-manager)). +3. **Discovery and sync.** Dependency-embedded plugin discovery, the consent prompt, and the `[plugins]` config. Recommendations are a flat registry rather than a `search` result (see the note under [the recommendations registry](#example-the-recommendations-registry)). 4. **User-managed plugins.** `use` / `remove` / `status`, workspace vs. global scope. 5. **Remaining** — see [Future work](#future-work). diff --git a/md/rfds/registry-centric-plugins/discovery-sync/README.md b/md/rfds/registry-centric-plugins/discovery-sync/README.md index 5fa2ff52..dc519c10 100644 --- a/md/rfds/registry-centric-plugins/discovery-sync/README.md +++ b/md/rfds/registry-centric-plugins/discovery-sync/README.md @@ -3,7 +3,7 @@ ## TL;DR - `symposium sync` resolves installed plugins, discovers new ones from workspace dependencies, prompts the user, fetches, evaluates predicates, and wires active content into agent directories. -- Discovery calls `list-deps` on all PMs, then passes each result as a query to `search` on all PMs. +- Every PM is asked the same two things: `list-deps`, then `active_plugins` over that dependency set. What differs is the answer's source: a trusted PM's plugins load directly, an untrusted PM's need the user's consent first. - A session-start hook notifies users of available extensions without auto-installing. ## Motivation @@ -35,22 +35,30 @@ Install? [1,all,none]: 1 ### The discovery algorithm -The core discovery loop: +The core loop: -1. **Call `list-deps` on all PMs.** Each PM reports the workspace's dependencies in its ecosystem. For example, the cargo PM returns `[(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]`. PMs that don't have a concept of workspace deps (git, path, recommendations) return empty. +1. **Call `list-deps` on all PMs.** Each PM reports the workspace's dependencies in its ecosystem. For example, the cargo PM returns `[(cargo, serde, 1.0.210), (cargo, tokio, 1.38.0)]`. PMs with no notion of workspace deps (a registry) return empty. -2. **For each dependency, call `search` on all PMs.** The full package-id tuple is passed as the query. Each PM decides how to match: - - The cargo PM: if `pm = cargo`, search the registry for matching plugin crates. Otherwise, return empty. - - The recommendations PM: match on `(pm, name)`, ignoring version. If it has a `cargo/serde/` directory, it returns that as a match. - - The git PM: returns empty (not searchable). +2. **Call `active_plugins` on all PMs**, passing that dependency set. A registry answers with its own entries, ignoring the deps. An ecosystem transport answers with the plugins its dependencies embed: the crate that ships a `skills/` directory or a `Symposium.toml` of its own. Fetching is cache-only here, so this makes no network calls and a workspace dependency is inspected in the source the PM already extracted. -3. **Filter out already-installed plugins.** Compare search results against what's already in config. +3. **Split the offers by the source they came from.** A registry is a trust root, so its plugins are loaded straight away and gated by nothing but their own predicates. A dependency is not: depending on a package means compiling its code, not letting its author add to the agent's context, so a plugin embedded in one needs the user's say-so. Only these reach the next step. -4. **Prompt the user.** Present new discoveries and let them choose which to install. +4. **Classify each remaining offer against `[plugins]`.** A name may be enabled by `use`, pre-consented by `auto-enable`, previously declined via `disable`, or undecided, which makes it a *candidate*. -5. **Record choices.** Accepted plugins are added to config. Declined plugins are recorded as dismissed. +5. **Prompt the user** about the candidates, and record the answers: approvals into `auto-enable`, declines into `disable`. -This two-phase approach (list-deps → search) is what lets the recommendations PM "advise" on other PMs' dependencies without needing its own `list-deps` to return anything. +So every PM is asked, and asked the same thing. Trust does not decide who gets +asked; it decides what happens to the answer. Steps 3 to 5 are what "discovery" +names in the narrow sense, the consent decision, and only dependency-embedded +plugins ever need one. Nothing here fetches or writes until the prompt is +answered. + +Note what is *not* here: no per-dependency `search`. Curated recommendations do +not need one, because a recommendation is an ordinary registry plugin that names +the crates it advises on with `depends-on`, which the ordinary predicate pass +already evaluates. `search` is a user-facing lookup, backing `symposium use` and +`symposium search`, where the input is a partial name typed by a person rather +than a package-id. ### The sync pipeline @@ -58,7 +66,7 @@ This two-phase approach (list-deps → search) is what lets the recommendations ``` 1. Resolve config → installed plugin package-ids (exact versions) -2. Discover deps → candidate plugin package-ids (via list-deps + search) +2. Discover deps → candidate plugin package-ids (via list-deps + active_plugins) 3. Prompt/auto-install → updated installed set 4. Fetch → populate cache 5. Evaluate predicates → active set @@ -67,7 +75,7 @@ This two-phase approach (list-deps → search) is what lets the recommendations #### Step 1: Resolve config -Read `~/.symposium/config.toml`. For each entry, call the PM's `resolve` to get the current best match. +Read `~/.symposium/config.toml`. Each `[plugins]` entry names a `(pm, canonical-name)` pair; `load_plugin` on the owning PM turns it into the current best match. #### Step 2: Discover deps @@ -102,8 +110,8 @@ Fetching happens in parallel across PMs and packages. #### Step 5: Evaluate predicates For each cached plugin, evaluate its predicates against the workspace: -- `workspace()` → is this directory part of the workspace? -- `depends-on(cargo, axum, 0.7)` → check if cargo's `list-deps` included axum +- `workspace-member()` → is this plugin defined by a member of the workspace? +- `depends-on(axum>=0.7)` → did some PM's `list-deps` include a matching axum? - etc. Plugins that pass are *active*. Plugins that don't pass are installed but dormant. @@ -120,8 +128,8 @@ Copy active skills/hooks/MCP servers into agent directories. Same change-awarene On session start, a lightweight check runs: 1. Use cached `list-deps` results (from lockfile mtime — no network calls). -2. Call `search` on all PMs with each dep. -3. If new matches exist, include in hook response: +2. Run discovery over them, cache-only. +3. If undecided candidates exist, include in hook response: ``` New extensions available for 3 dependencies. Run `symposium sync` to review. ``` @@ -157,6 +165,39 @@ disable = [{ pm = "symposium-recommendations", name = "rtk" }] consented to. `disable` still applies on top, so blanket consent stays overridable one plugin at a time. +#### Precedence + +The three lists answer different questions, so they can name the same plugin at +once. The rule is that **`disable` wins**, unconditionally: + +| Configuration | Result | +|---------------|--------| +| `use` only | enabled, subject to its predicates | +| `auto-enable` only | enabled if a dependency embeds it | +| `use` + `auto-enable` | enabled; `use` additionally reaches a plugin no dependency embeds | +| anything + `disable` | off | + +`disable` has to be the last word to be worth having. Everything else (a trusted +registry, `auto-enable`, an explicit `use`) is a way of saying a plugin *may* +run, and `disable` is the only way to say it may not; a precedence rule +that let any of them beat it would mean there is no way to turn a plugin off. + +So `use` on a disabled plugin does not re-enable it, and `symposium use --remove` +does not cancel a `disable` (it removes a `use` entry, which is the opposite +decision). Re-enabling means dropping the `disable` entry. + +#### Scope + +`use` carries a scope: an entry is either global or recorded for one workspace +root, so a plugin can be enabled in the one project that wants it. + +`auto-enable` and `disable` do not: both are global. For `auto-enable` this is a +consequence of what it means, namely standing consent to what your dependencies +carry, which is a judgment about the plugin's author rather than about a +project. For `disable` it is a simplification worth naming: turning a plugin off in one +workspace turns it off in all of them. See the parent RFD's +[future work](../README.md#future-work). + ### Declined discoveries A decline is recorded in `[plugins] disable` in the user config, and is diff --git a/md/rfds/registry-centric-plugins/plugin-model/README.md b/md/rfds/registry-centric-plugins/plugin-model/README.md index 81a4fbee..15ea726b 100644 --- a/md/rfds/registry-centric-plugins/plugin-model/README.md +++ b/md/rfds/registry-centric-plugins/plugin-model/README.md @@ -29,15 +29,14 @@ Adding a `Symposium.toml` lets you control behavior — add predicates, declare ```toml # Symposium.toml -[depends-on] -cargo = { tokio = "1" } +depends-on = ["tokio>=1"] [[hooks]] event = "PreToolUse" command = "my-linter" [[plugins]] -source.cargo = { tokio-extras = "*" } +source.cargo = "tokio-extras" ``` ## Detailed plans @@ -55,21 +54,24 @@ A plugin is a directory. That's it. The directory may contain: When a directory has no `Symposium.toml`, Symposium behaves as if an empty one exists. This empty manifest still triggers default behavior (see below). -A registry entry is the exception. A manifest that references no dependency anywhere -has nothing to infer a gate from, and treating it as "always on" would fire every -curated plugin in every workspace. Such a plugin loads -[dormant](../README.md#dormancy) and activates only when a `[plugins] use` entry -names it. `depends-on = ["*"]` is the explicit always-active spelling. +An empty manifest is enough because where the directory was found supplies the +plugin's [activation root](../README.md#activation-roots): a workspace member is +rooted in workspace membership, a crate in the reference that reached it. A +registry entry is not found anywhere in particular, being offered to every +workspace equally, so it has to name its own root, which for a curated plugin +means naming the dependencies it advises on (`depends-on = ["*"]` claims every +workspace as one). An entry that names none is left with `use` as its only root: +it loads and is reported, but contributes nothing until a `[plugins] use` entry +names it. ### `Symposium.toml` structure ```toml # Predicates gating activation -predicates = ["workspace()", "file-exists(build.rs)"] +predicates = ["workspace-member()", "path_exists(build.rs)"] -# Shorthand: depends-on reuses the PM's resolve format -[depends-on] -cargo = { tokio = "1", serde = "1" } +# Shorthand for the common dependency case +depends-on = ["tokio>=1", "serde>=1"] # Suppress defaults [defaults] @@ -98,10 +100,7 @@ args = ["serve"] # Chained plugins — loaded when this plugin activates [[plugins]] -source.cargo = { tokio-extras = "*" } - -[[plugins]] -source.git = { url = "github.com/org/helpers", branch = "main" } +source.cargo = "tokio-extras>=1" # Installable content (binaries referenced by hooks/MCP servers) [[installable]] @@ -140,28 +139,23 @@ These defaults establish the skills conventions: The plugin itself and each of its subsections can be gated with a `predicates = [...]` field. When a plugin is installed, the content is only *activated* if the predicate matches. -Common predicates: - -* `workspace()` — true if this plugin is part of the active workspace -* `workspace-dependency()` — true if plugin is a dependency of some project in the current workspace -* `depends-on(pm, name, version)` — true if the workspace depends on this package -* `env(FOO=BAR)` — true if the environment variable is set to the given value -* `file-exists(path)` — true if the given file exists relative to workspace root -* `shell(command)` — true if the command exits with code 0 -* `not(p)`, `any(p, ...)`, `all(p, ...)` — combinators +The functions are listed in the parent RFD's [predicates +section](../README.md#predicates) and specified in full in the [predicates +reference](../../../reference/predicates.md): `depends-on()`, +`workspace-member()`, `env(...)`, `path_exists(...)`, `shell(...)`, and the +combinators `not`, `any`, `all`. Explicit enablement is deliberately *not* a predicate. Enablement is a separate axis deciding whether a plugin may run at all, recorded in `[plugins]` and consulted before predicates are evaluated. -The `[depends-on]` shorthand reuses the PM's `resolve` format: +The `depends-on` shorthand covers the common dependency case: ```toml -[depends-on] -cargo = { tokio = "1", serde = "1" } +depends-on = ["tokio>=1", "serde>=1"] ``` -This is equivalent to `predicates = ["depends-on(cargo, tokio, 1)", "depends-on(cargo, serde, 1)"]`. +This is equivalent to `predicates = ["any(depends-on(tokio>=1), depends-on(serde>=1))"]`. Predicates can appear at any level (plugin, skill, hook, MCP server). A predicate on a plugin gates all its direct contents. Chained plugins have their own predicates and are evaluated independently. diff --git a/md/rfds/registry-centric-plugins/pm-interface/README.md b/md/rfds/registry-centric-plugins/pm-interface/README.md index e628368d..c567db57 100644 --- a/md/rfds/registry-centric-plugins/pm-interface/README.md +++ b/md/rfds/registry-centric-plugins/pm-interface/README.md @@ -131,6 +131,12 @@ Contract: Find packages matching a partial query string; backs `cargo agents use` and `cargo agents search`. PMs without a searchable registry return empty. +The query is a fragment of a name a person typed, never a package-id: the cargo +PM queries crates.io with it, a registry PM substring-matches its entry names. +Discovery does not use `search`: it works from `list_deps` and `active_plugins` +(see [discovery](../discovery-sync/README.md#the-discovery-algorithm)), so a PM +that implements nothing but `active_plugins` still participates fully in it. + #### `fetch` ```json @@ -177,7 +183,7 @@ The manifest on the wire is the **raw, unvalidated** schema: the same shape a `S | Producing a manifest (parse, synthesize, translate) | PM | | Schema validation, inline-installation promotion | Symposium | | Defaults (`skills/`, `.agents/skills/`), `[defaults]` handling | Symposium | -| Dormancy, trust, consent | Symposium | +| Activation roots, trust, consent | Symposium | | Resolving `source.path` against `root` | Symposium | This split keeps policy in one place. A PM reports which plugins exist and what @@ -227,7 +233,9 @@ collide, and so that a name always identifies exactly one thing. This is what makes a trusted source overridable. Recommendations are enabled without asking, which is the point of them, but a user who does not want a -particular one names it and turns it off. +particular one names it and turns it off. `disable` beats every other entry, +including a `use` naming the same plugin: see +[precedence](../discovery-sync/README.md#precedence). ### Error handling diff --git a/md/rfds/registry-centric-plugins/user-managed-plugins/README.md b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md index dc7ed4f7..bbec5a7c 100644 --- a/md/rfds/registry-centric-plugins/user-managed-plugins/README.md +++ b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md @@ -3,7 +3,7 @@ ## TL;DR - `symposium use [--global] X` searches PMs, installs a plugin, records it in config. -- `symposium remove X` removes from config. +- `symposium use --remove X` removes that record from config. - `symposium status` shows what's installed, what's active, and why. - Global installs apply everywhere; local installs are scoped to a workspace directory without modifying workspace files. @@ -30,7 +30,7 @@ Installed plugins: Active: yes Skills: serde-usage, serde-derive-helper -$ symposium remove serde-skills +$ symposium use --remove serde-skills ✓ Removed (cargo, serde-skills, 1.2.3) ``` @@ -38,29 +38,40 @@ $ symposium remove serde-skills ### `symposium use [--global] ` -**Query:** A name or partial identifier. Symposium searches all PMs for matches. +**Query:** A plugin name. A name is not an identity, since two ecosystems may use +the same word, so `use` resolves it across every PM and then decides. **Flow:** -1. Call `search` on all PMs with the query. -2. One result → confirm and install. Multiple → present selection: +1. Collect every plugin the name could mean: registry plugins that name themselves, the workspace's own dependencies (checked offline, before anything reaches the network), and `search` hits from every PM, which is what lets `use` name a package the workspace does not depend on yet. Matches are deduplicated on `(pm, canonical-name)`. +2. Exactly one match is used. Several is an error naming them, which the user resolves by picking the ecosystem: ``` - Found plugins matching "serde": - [1] (cargo, serde-skills, 1.2.3) — Schema-aware serialization helpers - [2] (recommendations, cargo/serde, 0.1.0) — Recommended serde extensions - Install which? [1]: + `serde` is offered by more than one package manager: + cargo (--pm cargo) + symposium-recommendations (--pm symposium-recommendations) + pick one with `--pm ` ``` -3. Record in config. +3. Record the `(pm, canonical-name)` pair in config, so the entry round-trips to the same plugin. 4. Fetch into cache. 5. Run sync to activate if predicates pass. +A plugin a trust root already offers needs no entry, and `use` says so rather +than writing one. The exception is a plugin with no [activation +root](../README.md#activation-roots) of its own, where `use` is precisely the +root being supplied. + **Flags:** -- `--global` — active in all workspaces. -- Without `--global` — scoped to the current workspace directory. +- `--global`: active in all workspaces. +- Without `--global`: scoped to the current workspace directory. +- `--pm `: the package manager to pick when more than one offers the name. + +### `symposium use --remove ` + +Drop the `use` entry for `` and re-sync, so the plugin's content is reaped from the agent directories straight away. The cache entry stays (garbage-collected separately). -### `symposium remove ` +The scope has to match: without `--global` this removes the entry recorded for the current workspace, with it the unscoped one. A scope mismatch is an error rather than a silent success, since "nothing to remove" and "removed" are answers the user needs to tell apart. -Match `` against installed plugins. If ambiguous, prompt. Remove from config. On next sync, content is cleaned from agent directories. Cache entry stays (garbage-collected separately). +Removal is the inverse of `use`, not a general off switch: it withdraws an enablement the user recorded. Turning off a plugin that was never `use`d, such as one a registry offers, is `disable`. ### `symposium status` @@ -90,30 +101,33 @@ Workspace plugins (from Symposium.toml): Location: `~/.symposium/config.toml` ```toml -# Global plugins -[[plugins]] -source.cargo = { serde-skills = "1" } - -[[plugins]] -source.cargo = { rtk = "2" } - -# Workspace-scoped plugins -[[workspace-plugins]] -directory = "/home/user/projects/my-app" -source.cargo = { axum-agents = "0.5" } - -[[workspace-plugins]] -directory = "/home/user/projects/my-app" -source.cargo = { diesel-helpers = "1" } +[plugins] +use = [ + # Global: active in every workspace. + { pm = "cargo", name = "serde-skills" }, + { pm = "cargo", name = "rtk" }, + + # Workspace-scoped, keyed by absolute path. + { pm = "cargo", name = "axum-agents", workspace = "/home/user/projects/my-app" }, + { pm = "cargo", name = "diesel-helpers", workspace = "/home/user/projects/my-app" }, +] ``` -Note: config entries use `source.` syntax — the same format as `Symposium.toml` plugin entries. Symposium passes the value to the PM's `resolve` to get the exact package-id. The version in the source value is a *requirement* (e.g., `"1"` means any 1.x); the resolved package-id has the exact version. +An entry names a plugin, not a version requirement: the pair `(pm, +canonical-name)` is the identity ([naming a plugin in +configuration](../pm-interface/README.md#naming-a-plugin-in-configuration)), and +the version is whatever the PM resolves at load time. A bare string is read as a +cargo package, since that is what an unqualified name has always meant. ### Scoping: global vs. local **Global (`--global`):** Plugin activates in every workspace. Good for universally useful tools. -**Local (default):** Plugin scoped to the current workspace directory. Stored as `[[workspace-plugins]]` keyed by absolute path. +**Local (default):** Plugin scoped to the current workspace directory. Stored as a `use` entry carrying that absolute path. + +Scope is a property of `use` only. `disable` is global, so it is not the way to +turn a plugin off in one project. See +[precedence and scope](../discovery-sync/README.md#precedence). Key constraint: **local installs don't modify workspace files.** Scoping lives entirely in `~/.symposium/config.toml`. This means: - No dotfiles added to the project @@ -124,13 +138,13 @@ Key constraint: **local installs don't modify workspace files.** Scoping lives e ### Version updates -On each `symposium sync`, Symposium calls `resolve` with the source value from config. The PM finds the best matching version. Upgrades happen within the allowed range; downgrades don't. +On each `symposium sync`, Symposium calls `load_plugin` with the configured `(pm, canonical-name)` pair. The PM finds the best matching version. Upgrades happen within the allowed range; downgrades don't. There is no separate `symposium update` command — sync handles this naturally. ### Interaction with discovery -Discovery can also add entries to config (when the user accepts a discovered plugin during sync). These show up as regular `[[plugins]]` or `[[workspace-plugins]]` entries. The `status` command shows provenance: +Discovery also writes to `[plugins]` when the user answers its prompt: approvals go to `auto-enable`, declines to `disable`. `use` entries and `auto-enable` entries both enable, and `status` shows which root a plugin came in on: ``` Source: discovery (auto-installed 2026-05-15) @@ -152,12 +166,20 @@ Local installs are personal preferences. Putting them in workspace files would c ### What if I move my project directory? -`[[workspace-plugins]]` entries use absolute paths. If you move the directory, they stop matching. Fix: update the path in config manually, or re-run `symposium use` in the new location. +Workspace-scoped `use` entries record absolute paths. If you move the directory, they stop matching. Fix: update the path in config manually, or re-run `symposium use` in the new location. ### What happens when global and local plugins conflict? If a global and local plugin provide a skill with the same name, the local one wins. `status` shows a warning. +### What if a plugin is both `use`d and disabled? + +It stays off. `disable` is the last word over every enabling mechanism, so a +`use` entry naming a disabled plugin has no effect, and `use --remove` cannot +cancel a `disable`: it removes a `use` entry, which is the opposite decision. +Re-enabling means dropping the `disable` entry. See +[precedence](../discovery-sync/README.md#precedence). + ### Can I install without a workspace? `symposium use --global X` works from anywhere. Without `--global`, you need to be in a workspace directory (so Symposium knows what to scope to). @@ -166,7 +188,7 @@ If a global and local plugin provide a skill with the same name, the local one w ### Step 1: Config file format -Define and parse the `[[plugins]]` and `[[workspace-plugins]]` entries in config. +Define and parse the `[plugins]` section: `use`, `auto-enable`, and `disable`, with global and workspace-scoped `use` entries. - [x] PR: config format + parsing @@ -176,7 +198,7 @@ Search flow, selection UX, writing to config, triggering sync. - [x] PR: `use` command -### Step 3: `symposium remove` +### Step 3: `symposium use --remove` Matching, removal from config, cleanup on next sync.