diff --git a/md/SUMMARY.md b/md/SUMMARY.md index f0fbbaeb..0ed860b4 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -90,6 +90,11 @@ - [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) + - [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 e6b1c7f9..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 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. -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. +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 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 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 @@ -201,51 +224,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 | -| `search` | partial query string | set of package-ids + metadata | `symposium use` | +| `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`, `symposium search` | | `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 @@ -297,26 +317,28 @@ 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. -- **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. +- **[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](./user-managed-plugins/README.md)** — `symposium use`/`remove`/`status` commands, config file format, version requirement syntax, global vs. workspace-local scoping. ### Future work 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. - **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** — ✅ 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, 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 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/cargo-pm/README.md b/md/rfds/registry-centric-plugins/cargo-pm/README.md new file mode 100644 index 00000000..e334ad7e --- /dev/null +++ b/md/rfds/registry-centric-plugins/cargo-pm/README.md @@ -0,0 +1,159 @@ +# 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. +- `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` reports 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)`. + +### Chained-reference schema + +A `[[plugins]]` chained reference names one crate, as a dependency atom or a table: + +```toml +[[plugins]] +source.cargo = "serde-skills>=1" + +[[plugins]] +source.cargo = { name = "serde-skills", version = "1.*" } +``` + +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 partial query string and searches crates.io by name, returning candidate crates. + +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. 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 + +Reads the workspace to report direct Rust dependencies. + +**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. +- Dev-dependencies are included (they're still dependencies the user works with). + +**Performance:** +- 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 + +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 + +### How does `search` know which crates have plugin content without downloading them all? + +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. + +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. + +### When should a crate use `[package.metadata.symposium]` rather than a `Symposium.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. + +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 + +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 + +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 library split + +### Step 2: Carry workspace information over the protocol + +Add the workspace root, member directories, and crate list to the protocol, and move Symposium's readers onto it. + +- [ ] PR: workspace info over the wire + +### Step 3: `symposium-pm-cargo` binary + +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 binary + +### Step 4: Switch Symposium to the subprocess + +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 cutover + benchmark 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..dc519c10 --- /dev/null +++ b/md/rfds/registry-centric-plugins/discovery-sync/README.md @@ -0,0 +1,270 @@ +# 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. +- 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 + +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 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 with no notion of workspace deps (a registry) return empty. + +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. **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. **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. **Prompt the user** about the candidates, and record the answers: approvals into `auto-enable`, declines into `disable`. + +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 + +`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 + active_plugins) +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`. 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 + +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-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. + +#### 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. 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. + ``` + +The hook does NOT install anything. It only notifies. Installation goes through `symposium sync`. + +### 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 + +[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" }, +] + +# 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" }] +``` + +`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. + +#### 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 +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. + +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. + +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 + +- `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. + +- [x] PR: sync pipeline with path PM + +### Step 2: Discovery algorithm + +Implement `list-deps` → `search` loop across all PMs. + +- [x] PR: discovery algorithm + +### Step 3: Prompt UX + +Present discoveries, record choices (accept/dismiss). + +- [x] PR: discovery prompt + +### Step 4: Hook notification + +Add discovery check to session-start hook. Use cached results only. + +- [x] PR: session start notification + +### Step 5: Auto-install and dismissal + +Add `auto-sync` config, per-PM granularity, and dismissed-discovery tracking. + +- [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 new file mode 100644 index 00000000..15ea726b --- /dev/null +++ b/md/rfds/registry-centric-plugins/plugin-model/README.md @@ -0,0 +1,245 @@ +# 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 = ["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). + +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-member()", "path_exists(build.rs)"] + +# Shorthand for the common dependency case +depends-on = ["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>=1" + +# 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. + +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 covers the common dependency case: + +```toml +depends-on = ["tokio>=1", "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. + +### Chained plugins + +A plugin can declare additional plugins to be loaded when it activates: + +```toml +[[plugins]] +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) +- 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 + +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. + +- [x] PR: plugin struct + TOML parsing + +### Step 2: Default application + +Implement skill discovery from `skills/` and `.agents/skills/`. Suppression via `[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. + +- [x] PR: predicate evaluation + +### Step 4: Chained plugins + +Parse `[[plugins]]` entries, resolve via PMs, fetch transitively, evaluate independently. + +- [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. + +- [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 new file mode 100644 index 00000000..c567db57 --- /dev/null +++ b/md/rfds/registry-centric-plugins/pm-interface/README.md @@ -0,0 +1,402 @@ +# PM interface + +## TL;DR + +- 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 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 `load_plugin`: + +```toml +# User writes in Symposium.toml: +[[plugins]] +source.cargo = "serde-skills>=1" +``` + +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: + +```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 + +### 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 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: + +- A **registry** instance lists its own entries and ignores `deps`. +- An **ecosystem transport** (cargo) surfaces the plugins its dependencies embed. + +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": "load_plugin", "params": { "id": { "pm": "cargo", "name": "serde-skills", "version": ">=1" } } } + +// Response +{ "result": [{ "id": {...}, "root": "...", "manifest": {...} }] } +``` + +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. + +#### `list_deps` + +```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": "serde" } } + +// Response +{ "result": [{ "id": { "pm": "cargo", "name": "serde-skills", "version": "1.2.3" }, "description": "..." }] } +``` + +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 +// Request +{ "method": "fetch", "params": { "id": {...}, "update": "none" } } + +// Response +{ "result": { "id": {...}, "root": "/home/user/.cargo/registry/src/.../serde-skills-1.2.3" } } +``` + +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: +- 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. + +#### `refresh` + +```json +// Request +{ "method": "refresh", "params": { "update": "check", "force": false } } + +// Response +{ "result": { "refreshed": true } } +``` + +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`. + +### 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 | +| 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 +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. `disable` beats every other entry, +including a `use` naming the same plugin: see +[precedence](../discovery-sync/README.md#precedence). + +### 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 | + +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. 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: + +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 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. + +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. + +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. + +### Built-in PMs + +`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. + +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. + +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` as a chained source + +`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. 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 the manifest on the wire instead of a directory? + +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. 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: Extract the manifest schema into a shared crate + +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`. + +Tests: round-trip every manifest fixture in the repo through JSON and assert the validated `Plugin` is identical. + +- [ ] PR: manifest schema crate + + +### 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 + +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 + + +### Step 4: Implement `symposium-pm-cargo` + +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: Configuration surface + +The `[[package-manager]]` section and acquisition through the existing installation machinery, replacing the hard-coded lookup from step 3. + +- [ ] PR: PM configuration + +### Step 6: A non-Rust-ecosystem reference PM + +One PM that synthesizes manifests from a foreign format, proving the boundary carries an ecosystem Symposium knows nothing about. + +- [ ] 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 new file mode 100644 index 00000000..bbec5a7c --- /dev/null +++ b/md/rfds/registry-centric-plugins/user-managed-plugins/README.md @@ -0,0 +1,211 @@ +# User-managed plugins + +## TL;DR + +- `symposium use [--global] X` searches PMs, installs a plugin, records it in 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. + +## 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 use --remove serde-skills +✓ Removed (cargo, serde-skills, 1.2.3) +``` + +## Detailed plans + +### `symposium use [--global] ` + +**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. 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: + ``` + `serde` is offered by more than one package manager: + cargo (--pm cargo) + symposium-recommendations (--pm symposium-recommendations) + pick one with `--pm ` + ``` +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. +- `--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). + +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. + +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` + +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 +[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" }, +] +``` + +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 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 +- 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 `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 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) +``` + +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-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). + +## Implementation plan and status + +### Step 1: Config file format + +Define and parse the `[plugins]` section: `use`, `auto-enable`, and `disable`, with global and workspace-scoped `use` entries. + +- [x] PR: config format + parsing + +### Step 2: `symposium use` + +Search flow, selection UX, writing to config, triggering sync. + +- [x] PR: `use` command + +### Step 3: `symposium use --remove` + +Matching, removal from config, cleanup on next sync. + +- [x] PR: `remove` command + +### Step 4: `symposium status` + +Display installed/active/inactive plugins with provenance and predicate status. + +- [x] PR: `status` command