Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Key modules

Symposium is a Rust crate with both a library (src/lib.rs) and a binary (src/bin/cargo-agents.rs). The library re-exports all modules so that integration tests can access internals.

config.rs — application context

Everything hangs off the Symposium struct, which wraps the parsed Config with resolved paths for config, cache, and log directories. Two constructors: from_environment() for production and from_dir() for tests.

Defines the user-wide Config (stored at ~/.symposium/config.toml) with [[agent]] entries, logging, [[registry]] entries ([[plugin-source]] is the retired spelling, still accepted), defaults, auto-update (off/warn/on, default on), and the [plugins] enablement section. User config is deserialized through RawConfig and validated into the runtime Config; runtime code does not deserialize Config directly. Provides registry_instances() to build the effective registry PmInstances directly (the builtin recommendations entry, the builtin user-plugins entry, then the configured ones): a git [[registry]] entry becomes a GitPm, a path entry a PathPm, each a trust root named for its registry (that name is what its plugins are attributed to). There is no ResolvedRegistry/content_dir intermediate — a git registry’s cache directory and its refresh live on the GitPm itself. package_managers(deps) prepends the fixed cargo transport (a CargoPm built over the shared deps resolver) to those to make the active PmRegistry. detached_managers() is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs cargo metadata. The workspace_deps(cwd) factory is the standard way to create a WorkspaceDeps — it wires in cargo_override and cache_dir so callers get both the SYMPOSIUM_CARGO override and cross-invocation disk caching, and returns it as an Arc so a CargoPm can hold one.

PluginsConfig (the [plugins] section) is the config surface of the enablement axis: auto-enable (dependency names pre-consented to, "*" for all), use (UseEntry::Global(name) or { name, workspace } — the durable record of a deliberate enablement, scoped to one workspace or to all), and disable (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods — used_names_in(root), is_auto_enabled, is_disabled, is_used_in — all match names hyphen/underscore-insensitively, since these are user-typed package names; has_enablement_entries is the cheap “could enablement pull in a crate plugin?” check the hook path uses to decide whether to resolve the crate graph. The lists are plain Vecs so a later cargo agents use can add and remove entries and call save_config.

pm/cargo/workspace.rs — cargo workspace resolution

The cargo-workspace resolution is CargoPm’s, so it lives in the cargo PM’s module (not a top-level crate::workspace) — cargo metadata is cargo’s ecosystem, not a generic concern. WorkspaceDeps is the lazy, cached resolver for the cargo dependency graph: the first load() reads a disk cache keyed on Cargo.lock mtime, and on miss runs cargo metadata (extracting root, direct crates, and member dirs into a LoadedWorkspace) and writes through. The result is memoized in a OnceLock, so every accessor reads through a shared &self — which is what lets one resolver be shared as an Arc: a CargoPm holds one and drives it (self.workspace.crates() runs the metadata call), and core code that needs the workspace root/members reads the same instance, rather than each caller resolving its own. WorkspaceCrate carries path (the local dir for a path dependency) and source_dir (the extracted source cargo metadata located, populated for registry crates too), so a workspace dependency’s source is served without a fresh probe. detached() is a resolver pre-set to “no workspace” for workspace-independent operations (registry listing, search). The types re-export at crate::pm for the core consumers of the workspace root/members (registry loading, sync, hook). The config-level dirs.rs (SymposiumDirs: config/cache paths + the SYMPOSIUM_CARGO override) stays in core; Symposium::workspace_deps(cwd) is the factory that wires them together.

agents.rs — agent abstraction

Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses .claude/skills/ and Kiro uses .kiro/skills/, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral .agents/skills/. OpenCode and Goose are skills-only agents (no hook registration).

init.rs — initialization command

Implements cargo agents init. Prompts for agents (or accepts --add-agent/--remove-agent flags), hook scope, auto-update behavior, and opt-in telemetry; writes user config; and registers global hooks.

sync.rs — synchronization command

Implements cargo agents sync. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent’s skill directory. The core primitive is sync_skill_dir(source_dir, dest_dir, project_root). It copies the entire source directory (not just SKILL.md) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (sync-debounce-secs, default 5s, keyed on the .symposium marker’s mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent’s skills parent directory and reaps any marker-bearing subdirectory it didn’t install this time, leaving user-managed skills (which lack the marker) untouched. Writes a .gitignore with * only into individual skill directories (not parent directories like .claude/ or .claude/skills/). Also provides register_hooks() for use by init, which registers only symposium’s own global hook handler — individual plugin hooks are never written into agent configs.

Two entry points: sync(sym, cwd) for standalone CLI use (creates its own WorkspaceDeps) and sync_with_deps(sym, deps) for the hook pipeline (shares the cached workspace resolution with other hook stages).

sync takes an UpdateLevel that it threads into skill resolution (skills::collect_skills), controlling how aggressively source.git skill groups are re-fetched. Callers choose: the auto-sync path passes Check on SessionStart (refresh) and None otherwise (debounced); the binary’s global --update flag feeds manual cargo agents sync.

plugins.rs — plugin registry

Loads plugin manifests from the configured registries and parses them into Plugin structs. Loading goes through the package-manager layer: load_registry asks each trusted PmRegistry instance (the configured registries — the cargo transport is not a trust root) for its plugins via active_plugins. A PathPm interprets each entry through load_entry as either a SYMPOSIUM.toml manifest plugin or a bare SKILL.md synthesized into a default plugin (load_standalone_skill_plugin); dependency-embedded crate plugins never load here. Refreshing a git registry’s content is the GitPm’s refresh operation, driven by ensure_registries (startup) and sync_registries (plugin sync). scan_source_dir remains as the offline form used by the plugin validate CLI, which points at an arbitrary directory rather than a configured registry; it walks the same layout rules and synthesizes bare skills the same way.

Validation here turns the raw TOML into:

  • Installation entries (optional source, optional executable/script, optional args, plus requirements and install_commands) collected on Plugin.installations. Inline installation references on hooks or other installations are promoted into synthetic Installation entries with derived names (<hook> for an inline command, <owner>__req_<i> for an inline requirement), so all references in the validated form are plain names.
  • Hook entries with command: String (the name of an Installation) plus optional hook-level executable / script / args. Validation guarantees at most one of executable/script is set across hook + installation, and at most one layer sets args.
  • SkillGroup and PluginMcpServer entries whose depends-on sugar and predicates list are merged into one runtime PredicateSet. Skill group source syntax is deserialized as raw string/table forms, then validated into PluginSource.
  • ChainedPlugin entries from [[plugins]]: a per-edge PredicateSet plus a source.cargo reference (dependency-atom string "widget>=1" or { name, version } table) naming the crate that carries the referenced plugin. This is the “package ≡ plugin” edge — how one plugin (e.g. a recommendations manifest) names another plugin by its package. Validation rejects git/path sources and the retired dependency-table form with hints. Expansion is wired in skills.rs: when the owning plugin is active and the edge predicates hold, the referenced crate is loaded (see important flows) — as a first-class plugin from its own SYMPOSIUM.toml if it ships one, otherwise from the crate’s metadata / default-skills/ path. The recorded version requirement is not yet enforced at resolution — the crate resolves against the workspace.

load_crate_manifest(metadata, file, crate_name) is the entry point for a crate-embedded plugin. It parses each source — the [package.metadata.symposium] table and a SYMPOSIUM.toml file, both in the ordinary plugin-manifest schema — independently and leniently (a malformed layer is logged and dropped), merges them (RawPluginManifest::merge: list fields append, scalar/keyed fields take the later layer, gates AND together), and runs the result through the same validate_manifest pipeline under a new ManifestOrigin::Crate variant: the name defaults to the crate, the dormancy rule does not apply (the reference that reached the crate is the gate), [defaults] is accepted, and the default skills/ group is appended (but not the workspace-only .agents/skills group). A crate with neither source still yields that default group. ParsedPlugin carries a required canonical: PackageId — the resolved crate id for a crate-sourced plugin, or a placeholder id tagged with the source name (registry) / "local" (workspace) for plugins with no real package identity. It keys chained-plugin cycle/diamond detection on the normalized crate name (skills.rs); it does not affect skill identity, which is the SKILL.md path hash (see skills.rs). Every loader (load_plugin_as, load_standalone_skill_plugin, workspace_plugin_for_dir, and CargoPm::build_from_fetched) runs resolve_group_sources before returning, so each [[skills]] source.path group carries an absolute directory plus a display source_label — a ParsedPlugin needs no base/manifest dir. A ParsedPlugin carries no manifest or base path at all — its identity is its canonical id. plugin show renders a plugin’s effective config keyed by that id (not a re-read manifest file); plugin validate reports each item by its id/name (a failed load’s error message still carries the file it came from).

There is no separate “standalone skill” concept: a registry directory holding only a SKILL.md (no SYMPOSIUM.toml) is loaded by load_standalone_skill_plugin as a plugin with default values — named for the skill’s own frontmatter name (falling back to the directory), carrying a single source.path = "." skill group that rediscovers that SKILL.md, and with the skill’s frontmatter depends-on/predicates hoisted to the plugin gate so the ordinary dormancy rule applies (a bare skill that names no dependency is dormant until used). This mirrors how a crate with no manifest still yields a plugin with the default skills/ group. So PluginRegistry holds only plugins; the plugin validate CLI likewise reports a bare skill as its synthesized plugin, whose one child is the skill. Returns a PluginRegistry — a table of contents that doesn’t load skill content.

A registry manifest that references no dependency anywhere — plugin, [[skills]], [[hooks]], [[mcp_servers]], or [[plugins]] chain edge, via depends-on, a depends-on(...) predicate, or a custom predicate — is not an error: it validates and loads with Plugin::requires_use = true, i.e. dormant. Plugin::applies short-circuits to false for a dormant plugin unless PredicateContext::is_used says a [plugins] use entry names it, so every activation path (skills, hooks, MCP, subcommands, help) agrees. depends-on = ["*"] remains the explicit always-active spelling, and plugin validate reports dormancy as a warning. So a recommendations-registry entry — an ordinary flat plugin — stays out of dormancy by declaring its own depends-on (the crates it advises, or ["*"]). The positional origins never go dormant, because where they were found supplies the gate.

Workspace-scoped callers use load_registry_with_workspace, which additionally loads workspace plugins (workspace_plugins): the workspace root and every member directory each define a plugin when they carry a SYMPOSIUM.toml (validated with ManifestOrigin::WorkspaceMembername defaults to the directory name, membership is the gate so dormancy never applies, and the default groups are appended unless [defaults] skills = false: [[skills]] source.path = "skills" plus, when the agents-syncing config is on, a workspace-member()-gated [[skills]] source.path = ".agents/skills" — the maintainer-skills convention, unified into the ordinary pipeline) or a bare skills/ or .agents/skills/ directory (an all-defaults manifest-less plugin). Workspace plugins are stamped workspace_member = true — the producer of the workspace-member() predicate — and attributed to the "(workspace)" source with skill paths relative to the workspace root.

installation.rs — sources and acquisition

Defines Source (the source = "..."-tagged enum: cargo, github) and acquire_source, which downloads / installs / clones the source and returns an AcquiredSource whose resolve_executable / resolve_script methods turn a relative executable/script name into a concrete path. The Runnable enum (Exec(PathBuf) or Script(PathBuf)) is the final form a hook command resolves to. The git submodule handles GitHub tarball acquisition and caching.

acquire_source (and the main-crate acquire_installation wrapper) take an UpdateLevel. None serves the cache without touching the network; Check/Fetch re-resolve. Hook dispatch acquires with None; the SessionStart prewarm uses Check. The three source kinds:

  • crates.io cargo: the resolved version is recorded in a current pointer (<cache>/binaries/<crate>/current). None reads the pointer and serves that version with no crates.io query — so a per-event dispatch never hits the registry. Check/Fetch query for the newest matching version, install into its version-keyed dir, and rewrite the pointer (so newly published versions are picked up at session start, not on every event). Only Fetch forces a same-version reinstall.
  • cargo + git: the cache key folds in only the URL + user version, never the resolved commit, so a moved branch never invalidates it on its own. Check/Fetch resolve the remote HEAD with a cheap git ls-remote and compare against the commit recorded in a .commit-sha file in the cache dir; the binary is reinstalled (cargo install --force) only when the SHA changed (or Fetch, or the binary is missing). None never resolves the remote.
  • github (script/subtree sources): honors the level directly via the git cache manager (freshness checks debounced to a 60s window under None).

Validates skill group source constraints during manifest validation: a group must set exactly one of source.path or source.git — a missing source (or an empty source = {}) is rejected, as are both together. A crate is not a skill-group source — source = "crate" (and the legacy source.crate table) is rejected with a hint to use a [[plugins]] source.cargo chained reference instead.

pm/ — package managers

The in-process seam from the registry-centric plugin distribution RFD. A PackageId is the canonical (pm, name, version) tuple; version may still be a requirement (a semver range, or * for “no requirement”), and fetch canonicalizes it — a FetchedPackage carries the exact resolved id plus the content directory. A PluginInfo (id plus optional description) is the lightweight result of search.

The PackageManager trait is the RFD’s operation set. Plugin loading has two forms — active_plugins(deps) (the plugins a PM activates for the workspace deps) and load_plugin(id) (the plugin(s) a specific id maps to) — both returning fully path-resolved ParsedPlugins and best-effort (failures logged, not surfaced); plus list_deps, search, fetch, refresh (pull a registry’s content — a no-op default for local/dependency sources), and registry_source (the git-vs-path descriptor, for plugin list). A PM value is an instance, not just an ecosystem: a transport can fetch/load_plugin any id of its ecosystem because the id carries the source, while a registry instance fronts one configured source and enumerates its packages via active_plugins. A registry instance’s name() is the configured registry name (user-plugins, symposium-recommendations, …), which is also the pm component of every id it mints and the name its plugins are attributed to. A PM is self-contained: it holds whatever it needs to resolve its own ecosystem, so operations take no ambient context — mirroring the out-of-process shape, where a PM spawned for a workspace answers from its own state. CargoPm holds an Arc<WorkspaceDeps> and drives it (lazy, cached); PathPm holds its directory. PmRegistry is one flat set of instances — fetch / load_plugin dispatch by PackageId::pm; list_deps / search / load_plugin union across all. Each PmInstance carries trusted: registries and the workspace are trust roots, the cargo transport (over dependencies) is not — the one distinction consumers branch on (registry loading takes only trusted instances; discover takes only the untrusted cargo transport). Symposium::package_managers(deps) builds the set — the cargo instance (trusted = false) plus one registry instance per configured registry (trusted = true: a GitPm for a git entry, a PathPm for a path entry); detached_managers() uses a detached resolver for workspace-independent work. workspace_dep_ids(sym, deps) unions list_deps and degrades to empty on failure. CargoPm (pm/cargo/mod.rs): fetch delegates to crate_sources::RustCrateFetch (path override, workspace pin, registry); list_deps reads self.workspace.crates() as cargo ids; active_plugins(deps) builds a ParsedPlugin (via the shared build_from_fetched) for each dependency whose source embeds plugin content (a SYMPOSIUM.toml, [package.metadata.symposium], or the default skills/), fetched cache-only into the already-extracted source (no probe) — these are dependency-embedded, so the caller applies consent; load_plugin(id) builds the named crate whatever it embeds (any fetchable crate yields at least a default skills/ plugin); search queries crates.io (crates_io_api, capped at SEARCH_PAGE_SIZE) so use/search can name a crate the workspace doesn’t depend on. CargoPm also owns crate-to-plugin resolution:

  • build_from_fetched(fetched) -> Option<ParsedPlugin> builds a first-class ParsedPlugin from its manifest sources — [package.metadata.symposium] in Cargo.toml and a SYMPOSIUM.toml at the source root — layered over the crate defaults by plugins::load_crate_manifest (merge order: defaults → Cargo.toml → SYMPOSIUM.toml; see important flows). The plugin is stamped with the resolved crate id as its canonical identity. A crate with no manifest sources still yields a plugin whose only content is the default skills/ group — so load_plugin returns Some for any fetchable crate; None means the fetch failed or the merged manifest was invalid (both logged).

Callers stay ignorant of crates: skills.rs hands over a dependency name and gets back a parsed plugin. Consumers: chained-reference expansion in skills.rs calls load_plugin; crate_command.rs builds ids with CargoPm::id_for and fetches through PmRegistry; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) gets its PredicateContext deps from workspace_dep_ids. Sync helpers that used to take &[WorkspaceCrate] and resolve deps themselves (help_render::render, subcommand_dispatch::find_subcommand) now take an already-resolved &[PackageId], so only the async entry points touch the PM layer.

One registry-instance PM exists today, reading content that is already on disk:

  • pm/layout.rs — the packaging convention it reads: classify(dir) (a directory with a SYMPOSIUM.toml is a plugin entry, one with a SKILL.md is a bare-skill entry — loaded as a default plugin — manifest wins) and enumerate(root) (recursive walk that does not descend into a claimed directory, sorted, erroring when the root is itself an entry). The layout carries no dependency information — an entry declares which dependencies activate it through its own manifest depends-on. Interpreting an entry’s manifest stays in plugins.rs.
  • pm/path.rsPathPm — one local directory in the flat layout: ~/.symposium/plugins/, a [[registry]] path entry, or the git cache directory a git registry unpacks into (serving as a GitPm’s inner reader). Its ids name the entry’s subpath within the source. active_plugins loads every entry (via plugins::load_entry), load_plugin(id) loads the entry an id names, fetch joins the subpath back onto the directory, search substring-matches entry names, and registry_source reports it as a Path. A registry is a trust root, so its plugins activate without consent; entry-load failures surface as report warnings.
  • pm/git.rsGitPm — one [[registry]] git entry. Once fetched, a git repo is just a directory, so the reads (active_plugins / load_plugin / search / fetch) delegate to an inner PathPm over the cache directory; the git-specific part is refresh — pull the repository (honoring the entry’s auto-update unless the caller forces it) — and registry_source reports it as a Git. The builtin symposium-recommendations repo is such a registry (an ordinary flat registry — each entry names the crates it advises with its own depends-on, so no namespacing or dedicated convention is involved).

crate_metadata.rs — extract Cargo.toml metadata

Extracts the [package.metadata.symposium] table from a crate Cargo.toml and returns it verbatim as a toml::Table. That table uses the same schema as a SYMPOSIUM.toml plugin manifest — a crate can define its plugin inline in Cargo.toml instead of (or in addition to) shipping a file. Validation against the manifest schema happens in plugins::load_crate_manifest, which deserializes the table and merges it with any SYMPOSIUM.toml. There is no longer a separate crate-metadata skill schema: the old path = "..." / crate = {..} redirect forms are now ordinary [[skills]] source.path groups and [[plugins]] source.cargo chained references.

predicate.rs — unified activation predicates

Defines one Predicate enum covering both dependency-graph matching and runtime/environment gating, plus PredicateSet (a list ANDed together) and PredicateContext (the workspace dependency list it evaluates against — PackageIds from the package-manager layer’s list_deps — plus the use-enabled plugin names that wake dormant plugins, threaded in with with_used_names and read by is_used). Two surface syntaxes lower to the same tree:

  • The depends-on field uses dependency-atom syntax (serde, serde>=1.0, *) and lowers, via DependsOnList, to depends-on(...) / depends-on(*) predicates OR-combined into a single any(...) that is appended to the same list. So depends-on is sugar — there is no separate dependency-predicate type.
  • The predicates field uses function-call syntax: depends-on(<atom>), shell(<cmd>) (verbatim arg, sh -c, exit 0 holds), path_exists(<arg>) (disk, then $PATH for bare names), env(<name>[=<value>]), workspace-member() (the plugin is defined by a member of the active workspace — provenance stamped per plugin into PredicateContext via ParsedPlugin::applies; registry loading stamps false, workspace-plugin loading stamps true), and the combinators not(<p>), any(<p>, …), all(<p>, …). The retired crate(...) spelling is rejected with a migration hint, as are the old crates fields.

Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged predicates: PredicateSet. Evaluation is PredicateSet::evaluate(ctx) -> bool — a predicate is purely a boolean gate. A depends-on atom matches a dependency by exact name; a version requirement is checked when the dependency id’s version component parses as semver. collect_dep_names (crates.io validation) walks all positions regardless. Plugin/group/skill/MCP predicates are evaluated at sync time; hook dispatch evaluates the plugin-level set (so a plugin’s depends-on now gates its hooks) plus the hook-level set. Hook dispatch threads in the workspace crate list, but resolves it (running cargo) only when some plugin- or hook-level predicate references a concrete depends-on(...), or there is crate-plugin expansion to perform — a chained [[plugins]] edge or a [plugins] enablement entry (hook_dispatch_needs_deps) — since expansion evaluates predicates against the crate graph too. A workspace whose plugins have none of these dispatches without a cargo query. See the predicates reference.

skills.rs — skill resolution and matching

Given a PluginRegistry and workspace dependencies, this module resolves skill group sources, discovers SKILL.md files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. It also owns active_plugins — the crate-expansion walk that produces the shared active plugin set every facet resolves over (see below) — so the same seam that resolves skills also feeds MCP-server, hook, and subcommand dispatch. Every source funnels through one seam: resolve_group_dirs turns a group into a list of ResolvedSkillDir (a base directory + report labels), then collect_skills_from_dirs scans each base for SKILL.md files. PluginSource has exactly two variants — Path (already on disk, relative to the plugin’s source dir) and Git (fetched via the git cache); a source is required, so there is no “no source” state.

The single seam every facet resolves over is active_plugins: it returns the full active set — every registry plugin whose gate holds (cloned), followed by the crate-sourced plugins transitively reached through [[plugins]] chained references and dependency enablement. Skills, MCP servers, hooks, and subcommands all iterate this one list, so a crate-sourced plugin’s extensions dispatch exactly like a registry plugin’s. Because crate loading is cache-only (CargoPm::load_plugin fetches with UpdateLevel::None), building the set is safe even on the per-event hook path.

active_plugins is a worklist fixed-point over the PM set (pms). It seeds the active set with the trust-root plugins the registry loaded (registry.plugins, each gated by record_active), then works a queue of PackageIds: each active plugin’s [[plugins]] chained source.cargo references (edges whose predicates hold, evaluated against the owning plugin’s provenance) plus the consented enabled-dependency ids (below). For each id it calls pms.load_plugin(id) — dispatched to the owning PM (the cargo transport builds the crate as a first-class ParsedPlugin from [package.metadata.symposium] + SYMPOSIUM.toml + defaults) — gates each result, records it, and enqueues its own edges. A visited set keyed on (pm, normalized name) collapses diamonds (a crate reached two ways loads once, so its hooks don’t double-fire and its subcommands aren’t a false conflict) and breaks cycles; the finite crate universe bounds termination, so there is no depth cap. collect_skills then walks the active set and runs each plugin’s skill groups through the ordinary load_skills_for_group pipeline; each discovered skill’s install identity is the hash of its on-disk SKILL.md path (below), so a crate reached two ways dedupes to one install. A crate plugin’s custom predicate definitions are the one facet not yet wired in — warn_undispatched_crate_features logs when a crate declares one.

The enabled-dependency ids seed the same worklist: discovery::enabled_dependencies names the crates covered by [plugins] auto-enable or an applicable use entry — both the workspace deps it enables and the used crates that aren’t deps at all — and each is pushed as a cargo id, so a crate’s skills/ (or manifest) installs with no plugin manifest anywhere pointing at it. A name a configured registry already provides as a plugin (including a dormant one use wakes) is skipped here, so it isn’t also fetched from crates.io. This is where consent lands — enabled_dependencies reads the [plugins] config, so only consented crates enter the worklist. workspace_root is a parameter because both this and the use-name context are scoped per workspace.

Production sync shares one PredicateContext across the skill and MCP passes, so it calls active_plugins then collect_skills directly rather than the skills_applicable_to convenience wrapper (which builds its own context and is test-only).

Each applicable skill carries an origin hash (a String) describing where its bytes live, used at sync time for dedup and install-path disambiguation. skill_origin_hash computes it as an 8-hex-char prefix of SHA-256 over the SKILL.md’s canonical on-disk path — nothing else. Identity is the file’s location, not which plugin manifest pointed at it: two references that resolve to the same file (the same crate reached through two chained plugins, or two source.path groups landing on the same bundle) produce the same hash and dedupe; skills at different paths stay distinct. Canonicalizing inside the hash is what makes that hold across discovery paths, since group scan dirs are canonicalized inconsistently — so on a platform whose temp prefix is a symlink (macOS /var/private/var) the same file would otherwise hash two ways and install twice.

Because the hash is the dedup key itself, a 32-bit collision between two genuinely distinct paths would silently drop one skill (rather than clashing loudly at install time) — a deliberate trade for carrying only a string, not a structured origin, to the sync layer.

sync prefers the plain <agent-skills-dir>/<skill-name>/ and only falls back to <skill-name>-<origin-hash>/ when needed: when more than one origin claims the same skill name, or when the unsuffixed slot is already occupied by a user-managed directory (one without the .symposium marker). The suffix is an 8-hex SHA-256 prefix for every origin kind. The .symposium marker, wildcard .gitignore, and stale-cleanup walk all key on the marker file rather than directory name shape, so transitions between unsuffixed and suffixed names self-heal across syncs.

discovery.rs — dependency discovery and enablement

Enablement is the second axis alongside activation predicates: predicates say when a plugin applies, enablement says whether it may run at all. The workspace and the configured registries are trust roots; a dependency deliberately is not, since depending on a crate should not let its author inject agent context. So a plugin embedded in a dependency runs only with consent, and a registry plugin with no gate to infer stays dormant until named. The two trust roots are loaded and gated directly — registry plugins by load_registry + Plugin::applies, workspace plugins the same way — so they never reach discovery. What discover classifies is exactly the untrusted offers: the dependency-embedded plugins a transport (CargoPm) surfaces. The trust boundary is structural: a positional registry entry (an offer with a subpath) is loaded directly, while a dependency-embedded offer (no subpath) is the consent path’s concern.

discover(sym, deps) derives the workspace root from the resolver (empty when there is none) and asks the untrusted instances — the cargo transport — for their active_plugins(dep_ids): the plugins embedded in the workspace’s dependencies (fetched cache-only into the already-extracted source, no probe). Each is classified by decide against [plugins] on its crate name — Used, Declined, AutoEnabled, or Candidate — and lands in the matching field of the returned Discovery (active / auto_enabled / candidates / declined). Explicit decisions outrank standing ones, so a declined name stays declined. Discovery writes nothing; the trusted registries are skipped, since their plugins are trust roots and never need consent.

enabled_dependencies(sym, dep_ids, workspace_root) is the activation side, consumed by skills.rs and status.rs: the crate names to load — workspace deps that auto-enable or an applicable use entry enables, plus used crates that aren’t deps at all (so cargo agents use <crate> pulls a plugin from crates.io whether or not you depend on it), minus the disabled ones. auto-enable contributes only deps — it is consent for what a dependency carries, not a way to add crates. It reads config rather than the offer list, so a name works even before its source has been fetched.

On top of that read side sits the consent write side. prompt_for_consent(sym, deps, out) asks one question per candidate (enable / ask me later / never ask again) and apply_consent records the answers — approvals into auto-enable, declines into disable, saved to the user config. Only explicit answers are recorded: the default (“ask me later”) and Escape write nothing, so reflexively hitting Enter never permanently declines anything.

The prompt is inert unless out.is_interactive() — a non-quiet, non-capturing Output attached to a terminal on both ends. That is the whole safety property: hook dispatch and anything an agent triggers run with a quiet output, and the library test harness runs with a capturing one, so neither can reach stdin. A bare TTY check would not do, since cargo test inherits the developer’s terminal. The only caller is the Commands::Sync arm in cli.rs — the hook-triggered auto-sync path calls sync::sync directly and never passes through it. pending_candidates is the non-interactive counterpart: hook.rs’s consent_hint renders it into SessionStart context so the agent can tell the user, without symposium ever blocking.

use_command.rs / search_command.rs / status_command.rs — the enablement commands

The user-facing surface over discovery and [plugins].

use_command records enablement. use_plugin first checks whether a configured registry already offers the name — registries are trust roots, so that is a no-op — with dormant plugins the exception, since use is exactly how they wake. It then requires the name to resolve to something (a workspace dependency, checked offline first, or a PmRegistry::search hit — which reaches crates.io via CargoPm::search, so a crate you don’t depend on still resolves) before pushing a UseEntry (workspace-scoped by default, Global with --global) and saving. Both it and remove_plugin re-run sync::sync afterward, so skills install or are reaped immediately. remove_plugin matches on scope and errors when nothing matched rather than silently succeeding.

search_command unions two arms: plugin names in the loaded PluginRegistry (bare skills included, since they are now plugins) and PmRegistry::search across every instance (which matches registry entry subpaths, e.g. a skill’s directory name). A PM without a searchable registry returns an empty list and a failing instance is skipped, so an offline registry degrades the results instead of failing the command. Hits are grouped by originating instance for display; the SearchMatch report event carries the origin for the JSON form.

status_command renders the enablement report. workspace_status walks the registry plugins (root: workspace membership, use, or the registry name; state from ParsedPlugin::applies plus the requires_use gate) — this is where every recommendations-registry plugin appears — then every Discovery bucket of dependency-embedded plugins (Used / AutoEnabled → active with that root, Candidate → awaiting consent, Declined), then the used crates that aren’t dependency offers (from enabled_dependencies, e.g. use-ing a crate the workspace doesn’t depend on — otherwise invisible to discovery), then any [plugins] disable name discovery never saw. The four StatusState values — Active, Dormant, Candidate, Declined — are the report’s vocabulary.

subcommand_dispatch.rs — plugin-vended subcommands

Routes the Commands::External arm of clap’s allow_external_subcommands. dispatch_external first resolves the active plugin set (skills::active_plugins), so crate-sourced subcommands dispatch too; find_subcommand walks that set, applying plugin-level and subcommand-level dependency predicates against the workspace (with the applicable use names, so a dormant plugin’s subcommands appear once it is enabled), and returns the matched (Plugin, Subcommand) (or an error if more than one plugin claims the name). dispatch_external then looks up the named Installation, resolves it via installation::resolve_runnable, and spawns the child with stdio inherited — propagating the exit code as a u8 so callers can convert to ExitCode (binary) or treat non-zero as an error (library). applicable_subcommands is the shared iterator over the active set’s applicable subcommands, taking an already-resolved &[ParsedPlugin] so help rendering and the SessionStart discovery hint reuse it.

help_render.rs--help rendering

Renders cargo agents --help as two audience-grouped sections, “Commands for humans” and “Commands for agents”, mixing built-in subcommands with plugin-vended ones filtered by the active workspace. Built-in audience comes from cli::builtin_audience; plugin subcommands come from subcommand_dispatch::applicable_subcommands over the resolved active plugin set (skills::active_plugins), so crate-sourced subcommands appear in help too.

help_text is the single help decision, shared by the binary and the test harness. clap’s own help flag and help subcommand are disabled (in cli::Cli), --help/-h is a manual global bool, and the entry points parse with try_parse_from — so help is decided after parsing and argument order (--help --quiet) is irrelevant. It returns the top-level grouped help for no subcommand / --help / -h / the bare help keyword; for <built-in> --help it re-renders clap’s own per-command help by walking clap’s command tree (so required-arg commands like crate-info, required-subcommand groups like plugin, and nested commands like plugin list all work); a plugin <name> --help returns None so dispatch forwards --help to the child.

render builds the grouped text by slicing clap’s rendered help — keeping the header (before Commands:) and the options block (from Options: on) and hand-rendering only the two section headings between them. If a slice marker is missing (clap format drift), it falls back to clap’s unmodified help rather than panicking.

hook.rs — hook handling

Handles the hook pipeline: parse agent wire-format input → auto-sync → builtin dispatch → plugin hook dispatch → serialize output. A single WorkspaceDeps (created via sym.workspace_deps(cwd)) is threaded through all stages — run_auto_sync, dispatch_builtin, dispatch_plugin_hooks, and the SessionStart prewarm. In-process, at most one cargo metadata invocation occurs per hook call (down from up to three previously). Across invocations, the disk cache means zero cargo metadata calls when Cargo.lock hasn’t changed — the common case for PreToolUse hooks.

run_auto_sync takes a session_start flag: on SessionStart it skips the Cargo.lock freshness gate and syncs with UpdateLevel::Check (so upstream skill/source changes land once per session); every other event keeps the gated, UpdateLevel::None path. The matching ensure_registries refresh level is decided in the binary entry point from the same event. SessionStart additionally runs prewarm_hook_sources (best-effort, gated by auto-sync): it walks every applicable plugin’s hooks and refreshes each installation’s already-cached source via refresh_installation_if_present (UpdateLevel::Check). This is what keeps hook binaries/scripts (not just manifests) current once per session — in particular the only path that re-pulls a cargo + git hook binary whose branch moved — so the dispatch path can keep acquiring with None (cache/debounced) and pay no per-event network cost. It is refresh-only: a source that was never acquired is left alone (it installs lazily on first dispatch), so SessionStart never eagerly installs a tool a hook may never use.

Builtin dispatch currently only acts on SessionStart, where handle_session_start composes three independently-computed additionalContext fragments: a discovery_hint (suggests cargo agents --help when the workspace exposes applicable plugin subcommands, reusing subcommand_dispatch::applicable_subcommands), a consent_hint (names the dependency plugins awaiting consent, via discovery::pending_candidates — a hook must never block on stdin, so the candidates are reported as context pointing at cargo agents sync / cargo agents use rather than asked about), and an update_nudge (the throttled self-update warning); only the nudge is gated behind the update-check throttle. The plugin dispatch path matches plugin Hooks against the event over the active plugin set (skills::active_plugins, so crate-sourced hooks fire too), selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a ResolvedHook per match (looking up the named installations on the plugin), then for each ResolvedHook: acquires its requirements (best-effort), runs install_commands after the source step, picks a Runnable from (hook-or-install) executable/script, and spawns it (binary directly for Exec, via sh <path> for Script). Input is delivered in the selected format; output is converted back to the agent’s wire format before returning.

state.rs — persistent state

Manages state.toml in the config directory. Deserializes through RawState and validates into the runtime State. Tracks the semver of the binary that last touched the directory (for future migration hooks) and the timestamp of the last update check (to throttle crates.io queries to once per 24 hours). ensure_current() is called on startup to silently stamp the current version. should_check_for_update() / record_update_check() gate the auto-update flow.

telemetry.rs — opt-in usage telemetry

Implements the local, opt-in telemetry event log under <config-dir>/telemetry/, one JSONL file per UTC day. Off by default; gated by [telemetry] enabled. A TelemetryEvent is an at timestamp plus a kind-tagged EventKind (session_start / user_prompt / tool_use), serialized one per line. record / record_kind append an event; roll_off deletes files older than RETENTION_DAYS (30); read_events / recent_events read them back; usage + status_text back telemetry status; recent_events backs telemetry show. Events are anonymous by construction — no prompt text, command lines, or file paths. Every write path is best-effort — failures are logged and swallowed so a hook is never broken. The recording entry points are not yet called from the hook pipeline, so no events are produced today even when telemetry is enabled.

report.rs — structured report layer

Provides user-facing output for all commands via a custom tracing layer. Commands emit tracing::info! or tracing::debug! events with a report = %ReportEvent::Variant { ... } field; the ReportLayer intercepts these and renders them based on mode:

  • Normal — prints format_human() to stdout (default for most commands)
  • Verbose (-v) — prints all events (info + debug) to stderr
  • Json (--json) — accumulates events in a buffer, drained as a JSON array at the end

The ReportEvent enum is the stable schema — #[derive(Serialize, Deserialize)] with #[serde(tag = "kind")]. Each variant carries the fields needed to render both human and JSON forms. The Display impl serializes to JSON (for passing through tracing’s % formatter), and format_human() renders the pretty-printed form.

The layer is always installed by the binary. Commands that want output simply emit report events at the appropriate tracing level (info for actions, debug for decision trace). The --json flag also suppresses the Output-based messages and drains the JSON buffer at exit.

self_update.rs — self-update

Implements cargo agents self-update. Queries the registry for the latest published version via cargo search, then installs it via cargo install symposium --force. Also provides re_exec() which replaces the current process with the newly installed binary (Unix exec, spawn-and-exit on Windows) — used by the auto-update = "on" startup path. Contains maybe_warn_for_update() (sync, for the warn library path) and maybe_check_for_update() (async, for the binary on + re-exec path).

crate_command.rs — crate source lookup

Contains dispatch_crate(), which resolves a crate’s version and fetches its source code. Called by the CLI’s crate-info command. Path dependencies are resolved to their local source directory via WorkspaceCrate.path.