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.
symposium-sdk: the shared plugin vocabulary
Depended on by both Symposium and anything that extends it. Beyond the hook handler SDK, it carries what a package manager needs to speak:
manifest: the unvalidatedSymposium.tomlschema (theRaw*types),Serializeas well asDeserializeso a manifest can travel on the wire. Retired fields (crates,source.crate, git/path chained sources) still parse, so validation can reject them with a migration hint rather than an unknown-field error.pm:PackageId,PluginInfo, andUnvalidatedPlugin(what a package manager answers with: Symposium validates it into aPlugin), re-exported from Symposium’spmmodule.predicate::syntax: the predicate tree and its parsing.hook::HookAgent,manifest::HookFormat,manifest::Audience: named by manifests, so they travel with the schema. Mapping aHookAgentto its wire-format handler needs Symposium’s agent modules, so that stays behind ashook_schema::agent_event.
What deliberately stays in Symposium is validation: defaults, inline-installation promotion, dormancy, trust. The schema says what a manifest can contain; Symposium decides what it means.
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. Agent relocation variables (CLAUDE_CONFIG_DIR, …) are read through env_dir, which consults the process environment only for a from_environment() instance, so a test can never reach a developer’s real agent directories; tests inject one with set_env.
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 (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses .claude/skills/ and Kiro uses .kiro/skills/, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral .agents/skills/. OpenCode and Goose are skills-only agents (no hook registration).
Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes .agents/hooks.json but ~/.gemini/config/hooks.json; Copilot .github/hooks/ but ~/.copilot/settings.json), so sync calls the project-scoped functions at project scope rather than rooting the global path at the workspace.
MCP registration is its own axis, and the one place where guessing is expensive: an agent’s MCP file is usually not its hooks file, so a wrong target fails silently - symposium reports success for a file the agent never reads. Agent::mcp_config_path(scope, project_root, sym) is the single seam holding that per-agent truth (the table lives in agent details), with register_mcp_servers / unregister_mcp_servers as the only entry points; there is no separate project/global pair of functions to keep in sync. McpScope is deliberately distinct from HookScope: Antigravity, Claude, OpenCode and Kiro have a project-level MCP file, while Codex, the Copilot CLI and Goose read only a user-level one, so supports_project_mcp_scope reports when project scope is downgraded rather than writing a file nobody reads. Entry shape varies too - OpenCode needs {type, command: [bin, ...args], enabled} and rejects its whole config file otherwise, which is why it has its own serializer (opencode_server_to_json) instead of the shared {command, args} one.
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. Copying and comparing share one traversal, walk_tree — they have to agree entry for entry, since a comparison scan that omitted something the copier writes would see a difference on every run and turn each sync into a full recopy. That traversal follows symlinks (see dir_walk.rs), so a linked file installs as a real file and a linked directory as its contents; a broken link and a link back into the tree being copied are reported and skipped rather than failing the sync. 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.
While PluginRegistry::unreadable_registries is not empty, sync skips that reap and warns once per registry: an unreadable registry offered nothing, so everything it installed would look stale.
One entry point, sync(sym, deps, update). The caller supplies the WorkspaceDeps, so the hook pipeline hands over the resolver its other stages already used rather than resolving the workspace a second time. Callers: the CLI’s sync arm (after the consent prompt), use_command once an enablement is recorded, and the hook pipeline’s auto-sync.
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 validates them into Plugins. 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 returns each entry through entry_plugin: a Symposium.toml manifest read as-is, or a bare SKILL.md synthesized into a manifest (standalone_skill_manifest); 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.
load_registry reads each registry through PmInstance::registry_plugins, which fails where active_plugins would come back empty because the source cannot be read (a PathPm whose directory cannot be listed or is itself an entry, and a GitPm that has not been fetched). Such a registry is reported and recorded in PluginRegistry::unreadable_registries (see common issues); plugin list and plugin show read through the same call.
Validation here turns the raw TOML (parsed into the SDK’s RawPluginManifest) into:
Installationentries (optionalsource, optionalexecutable/script, optionalargs, plusrequirementsandinstall_commands) collected onPluginManifest.installations. Inline installation references on hooks or other installations are promoted into syntheticInstallationentries with derived names (<hook>for an inlinecommand,<owner>__req_<i>for an inline requirement), so all references in the validated form are plain names.Hookentries withcommand: String(the name of anInstallation) plus optional hook-levelexecutable/script/args. Validation guarantees at most one ofexecutable/scriptis set across hook + installation, and at most one layer setsargs.SkillGroupandPluginMcpServerentries whosedepends-onsugar andpredicateslist are merged into one runtimePredicateSet. Skill groupsourcesyntax is deserialized as raw string/table forms, then validated intoPluginSource.ChainedPluginentries from[[plugins]]: a per-edgePredicateSetplus asource.cargoreference (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 inskills.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 ownSymposium.tomlif 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.
merge_crate_manifest(metadata, file, crate_name) builds a crate’s unvalidated manifest. 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). When the plugin reaches validate_plugin, the result runs through the same validate_manifest pipeline under ManifestOrigin::Crate: 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. Plugin 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, workspace_plugin_for_dir, and validate_plugin) runs resolve_group_sources before returning, so each [[skills]] source.path group carries an absolute directory plus a display source_label — a Plugin needs no base/manifest dir. A Plugin carries no manifest file path 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 synthesized by standalone_skill_manifest into a manifest with default values, which ordinary registry validation turns into a plugin — 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 PluginManifest::requires_use = true, i.e. dormant. PluginManifest::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::WorkspaceMember — name 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.
dir_walk.rs — following symlinks without looping
The two rules every source-tree walk needs once symlinks are followed, in one place.
A live source tree — a workspace member, a path or git dependency, a [[registry]] path entry, ~/.symposium/plugins/, a git-cloned registry cache — can contain symlinks, and what sits behind them is skill content like any other. Such a tree is dereferenced rather than copied link-for-link: the destination is a different tree, so a preserved relative link pointing outside the skill directory would dangle there. Dereferencing also makes a source-tree install agree with a crates.io install, which already delivers real files (cargo package follows links, including ones escaping the package root). Links are followed wherever they point — there is no containment check against the source root; the skill reference records that decision and its rationale.
resolved_kind(path, file_type)classifies a directory entry with links resolved.DirEntry::file_typereports the link, which is neither a directory nor a regular file, andDirEntry::metadatadoes not follow it either;fs::metadatadoes.Errcan only come from that follow, so it means a broken link.Ancestorsrecords the directories a walk is currently inside, by canonical path, and refuses to enter one twice. A tree of real directories cannot contain itself, so this only fires once a directory link has been followed — but every directory on the way down is recorded, since the link that closes the loop may sit several real directories below the one it names.
Four walks use it: the copy/compare traversal in sync.rs (both rules), and the cycle guard alone in skill discovery (skills::find_skill_files_recursive), registry enumeration (pm::layout::walk), and crate content detection (pm::cargo::contains_skill_md) — those three already followed directory links through Path::is_dir.
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
currentpointer (<cache>/binaries/<crate>/current).Nonereads the pointer and serves that version with no crates.io query — so a per-event dispatch never hits the registry.Check/Fetchquery 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). OnlyFetchforces 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/Fetchresolve the remoteHEADwith a cheapgit ls-remoteand compare against the commit recorded in a.commit-shafile in the cache dir; the binary is reinstalled (cargo install --force) only when the SHA changed (orFetch, or the binary is missing).Nonenever 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 UnvalidatedPlugins 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. An UnvalidatedPlugin is the resolved id, the content root, and an unvalidated RawPluginManifest (plus an optional label_root, the directory resolved skill paths are displayed relative to, so a registry entry reads as path:<entry>/skills). The manifest is the point: a PM may have parsed it from a Symposium.toml, translated another ecosystem’s manifest, or synthesized one for a package that describes itself not at all, and Symposium cannot tell.
Turning an UnvalidatedPlugin into a Plugin is PmInstance’s job, via plugins::validate_plugin: schema validation, inline-installation promotion, default content, dormancy, and resolving source.path groups against the plugin’s root all happen there. Each PmInstance carries two flags, both Symposium’s to set and neither anything a PM says about itself:
trusted: whether consent is needed. Registries and the workspace are trust roots; the cargo transport (over dependencies) is not. This is the distinction consumers branch on (registry loading takes only trusted instances;discovertakes only the untrusted cargo transport).kind: PluginKind: which validation policy applies.Registry(must name itself,[defaults]rejected, dormant with no gate) orPackage(id supplies the name, the reference that reached it supplies the gate, defaultskills/group appended).
So a package manager cannot make itself trusted or wake a dormant plugin by what it returns. A package’s manifest can still turn off the default skills/ group with [defaults] skills = false, since that is part of the package schema. A registry plugin that fails validation is reported, naming its directory, since fixing it is the user’s job; a package’s failure is only logged, since it is its author’s, and a report on the hook path would land next to the agent’s JSON. 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) returns (via the shared plugin_from_fetched) 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) returns 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:
plugin_from_fetched(fetched) -> UnvalidatedPluginbuilds the unvalidated plugin for a fetched crate from its manifest sources ([package.metadata.symposium]inCargo.tomland aSymposium.tomlat the source root) layered byplugins::merge_crate_manifest(see important flows). The offer’s id is the resolved crate. A crate with no manifest sources still yields one carrying an empty manifest, which validation turns into a plugin whose only content is the defaultskills/group, soload_pluginreturns a plugin for any fetchable crate (a failed fetch is logged and yields nothing). The default group is appended by validation, not by the PM: defaults are policy.
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 aSymposium.tomlis a plugin entry, one with aSKILL.mdis a bare-skill entry — loaded as a default plugin — manifest wins) andenumerate(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 manifestdepends-on. Interpreting an entry’s manifest stays inplugins.rs.pm/path.rs—PathPm— one local directory in the flat layout:~/.symposium/plugins/, a[[registry]]pathentry, or the git cache directory a git registry unpacks into (serving as aGitPm’s inner reader). Its ids name the entry’s subpath within the source.active_pluginsreturns every entry (viaplugins::entry_plugin),load_plugin(id)the entry an id names,fetchjoins the subpath back onto the directory,searchsubstring-matches entry names, andregistry_sourcereports it as aPath. A registry is a trust root, so its plugins activate without consent; entry-load failures surface as report warnings.pm/git.rs—GitPm— one[[registry]]gitentry. Once fetched, a git repo is just a directory, so the reads (active_plugins/load_plugin/search/fetch) delegate to an innerPathPmover the cache directory; the git-specific part isrefresh— pull the repository (honoring the entry’sauto-updateunless the caller forces it) — andregistry_sourcereports it as aGit. The builtinsymposium-recommendationsrepo is such a registry (an ordinary flat registry — each entry names the crates it advises with its owndepends-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. plugins::merge_crate_manifest deserializes the table and merges it with any Symposium.toml; validation happens when the resulting plugin reaches plugins::validate_plugin. 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: predicate evaluation
The predicate syntax (the Predicate tree, both surface spellings, parsing, Display, and serde) lives in symposium_sdk::predicate::syntax and is re-exported here, so crate::predicate::Predicate still names the same type. The split lets a manifest carry predicates without linking evaluation. Evaluation is the PredicateEval extension trait, an extension trait because the types are foreign.
The syntax covers both dependency-graph matching and runtime/environment gating: one Predicate enum, PredicateSet (a list ANDed together), and this module’s 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-onfield uses dependency-atom syntax (serde,serde>=1.0,*) and lowers, viaDependsOnList, todepends-on(...)/depends-on(*)predicates OR-combined into a singleany(...)that is appended to the same list. Sodepends-onis sugar — there is no separate dependency-predicate type. - The
predicatesfield uses function-call syntax:depends-on(<atom>),shell(<cmd>)(verbatim arg,sh -c, exit 0 holds),path_exists(<arg>)(disk, then$PATHfor bare names),env(<name>[=<value>]),workspace-member()(the plugin is defined by a member of the active workspace — provenance stamped per plugin intoPredicateContextviaPlugin::applies; registry loading stamps false, workspace-plugin loading stamps true), and the combinatorsnot(<p>),any(<p>, …),all(<p>, …). The retiredcrate(...)spelling is rejected with a migration hint, as are the oldcratesfields.
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 Plugin 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.
The skill name comes from plugin content and becomes that directory name, so it is untrusted input: load_skill rejects any name is_safe_dir_name refuses (one that could resolve outside the skills directory), and the skill fails to load instead of being installed. Agent::project_skill_dir and global_skill_dir assert the same check before joining the name.
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 + PluginManifest::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_command.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 Plugin::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 &[Plugin] 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 → respond. The response is a HookResponse (stdout, stderr, exit code), since some decisions travel only through the exit code: a plugin hook’s exit 2 on PreToolUse becomes a canonical deny (blocked_response), and Kiro receives any denial as exit 2. 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.
On SessionStart, 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. handle_post_tool_use and handle_user_prompt_submit add one generic notice (there may be new plugins, run cargo agents sync) when run_auto_sync reports that it re-synced because Cargo.lock changed. 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/ — 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.
The replacement recording contract is being built behind private schema, identity, and state submodules before it is connected to production callers. Within schema, mod.rs owns shared row primitives and version dispatch, macros.rs owns strict versioned-row boilerplate for rows with and without cross-field validation while each row module keeps its validation rules, metrics.rs owns the fixed latency histogram and complete-session validation shared by hook aggregate rows, and hook.rs owns the strict hook-metrics row and read-side validation. Its hook/surface.rs, hook/outcome.rs, hook/key.rs, and hook/update.rs children keep SDK surface mapping, outcome accounting, the typed hook_subject dimension and aggregate key, and atomic write updates with their respective tests. plugin_hook.rs owns the strict plugin-hook aggregate row and read-side validation. Its plugin_hook/outcome.rs, plugin_hook/bucket.rs, and plugin_hook/update.rs children keep outcome accounting, validated public identity and pre-admission attribution, and atomic write updates with their respective tests. name.rs owns validation shared by public name types, agent.rs owns agent vocabulary and agent-originated rows, command.rs owns eligible built-in and public plugin-command coordinates and exhaustively maps parsed CLI commands into the recorded built-in set, extension.rs owns validated public extension coordinates and vocabulary shared across event kinds, and resolution.rs owns resolution summaries and shared resolution vocabulary. Package-resolution vocabulary and validated public coordinates live in resolution/package.rs; resolution/extension.rs owns the strict recursive node vocabulary, the validated path wrapper that enforces whole-path depth, leaf-count, and encoded-size limits, the skill-only attribution persisted by the installation index, and the versioned row that derives extension_subject from its target and path. Timestamped rows validate during deserialization that day is the UTC calendar day containing at; the later archive reader separately validates that the row belongs to its daily file. Private state binds its key and identifier-window or return-cohort anchor into distinct identity scopes. Each identifier domain accepts only its contract-selected scope. Row constructors take a bound recording context, derive identifiers from their own source fields, and take their day from the context: package resolution derives package_subject from its coordinate, agent configuration derives agent_subject from its agent, command derives command_subject from its coordinate, and a full resolution reuses one context for its summary, package, and extension rows. Session start extends that context with a return-cohort scope and derives its timestamp, both subjects, and cohort_day from one bound session transition. Within state, mod.rs owns the versioned private-state file shape, state/lifecycle.rs owns the identifier-window transition for every recording plus return-cohort and identifier-reset transitions, state/open_day.rs centralizes the monotonic day transition used by daily state while persistence remains responsible for sourcing it from the one latest-opened-day high-water mark, state/session_counts.rs owns bounded private hook session sets plus contribution-count reconciliation, state/hook.rs owns the deterministic daily map that selects those trackers by aggregate key, and state/plugin_hook.rs keeps each plugin-hook row’s admitted identity, stable event ID, and session tracker behind one key-checked selection. Its state/plugin_hook/admission.rs child owns the deterministic daily map and the separate 128-public-row allowance for this aggregate family. Only this private-state boundary can create an overflow bucket; row constructors receive the coupled identity from the selected entry rather than accepting its scope, coordinate, and subject separately. A completed recording transition is a single-use value; a session transition extends it with the return-cohort selection. Binding either transition against unchanged anchors exposes only the identity scopes valid for that operation. Each hook row and private tracker share a key made from the UTC day and hook_subject, so equally sized trackers for different aggregates cannot be exchanged. Hook-metrics updates derive their row and session identities from one bound recording, reject impossible per-observation plugin counts, stage every row counter before changing private session state, and publish the two objects only after every checked update succeeds. The private storage submodule owns the new contract’s canonical paths and exclusive filesystem access: StoragePaths keeps inspectable data and its permanent .lock under <config-dir>/telemetry/ while keeping telemetry-state.toml beside that directory, and TelemetryLock makes one non-blocking acquisition whose file handle releases the lock on drop or unwind. The eventual recorder will stage rows and aggregate changes in memory, successfully replace the private state containing their anchors and entries, and only then expose those rows for publication. Their tests remain beside the responsibility they exercise.
state/codec.rs owns canonical private-state serialization, version dispatch,
schema decoding, and secret-safe diagnostics. The private storage module
owns the bounded file I/O that calls that codec.
Resolution summaries derive an optional session_id from a paired agent and
raw vendor session identifier rather than accepting a loose scoped identifier.
state/codec.rs owns canonical private-state encoding and content-free decode
errors, so malformed state can never expose the identity key in diagnostics.
schema/extension_invocation.rs is the extension-invocation aggregate boundary. Its extension_invocation/vocabulary.rs child keeps the closed version 1 agent, phase, target-scope, and unnamed-reason vocabulary; extension_invocation/bucket.rs accepts only safe public attribution or a fixed unnamed reason, leaving overflow assignment to private state; and extension_invocation/row.rs owns the strict versioned row and its read-side scope, counter, and session validation. The row’s update.rs child applies one phase observation atomically from a bound recording and an admitted private-state selection, so the event id, agent, target identity, identifier epoch, and session tracker cannot be supplied independently. The independent attempted/completed session-count validator lives with the other aggregate primitives in schema/metrics.rs. state/extension_invocation.rs owns the matching bounded private sets and reconciles their independent contribution counts with the stored row. Failed observations add no session id, but still perform this reconciliation so a lost snapshot row cannot leave published counts inconsistent with private state. Its extension_invocation/admission.rs child derives coupled row identity, keeps the stable row identifier and session tracker behind one epoch-aware aggregate key, and sends new public targets beyond the daily allowance to overflow. state/public_row_budget.rs owns the shared 128-row daily allowance used independently by each named aggregate family: day rollover and clear restore it, while identifier reset does not.
Storage acquisition also tightens a pre-existing telemetry directory and lock
file to owner-only modes on Unix instead of relying only on their creation
modes. storage/atomic.rs writes a complete byte buffer through a
same-directory temporary file before atomically replacing its destination; it
does not fsync, and callers must finish serialization before replacement. Every
telemetry-owned temporary uses the shared .telemetry-tmp- prefix so lazy
cleanup and status recognize the same namespace. Activating the replacement
recorder must remove the legacy record and roll_off paths in the same
change, so two schemas and retention policies never share the telemetry
directory.
LockedStorage owns both the canonical paths and TelemetryLock, so private
state cannot be loaded or replaced outside the lock. Its private_state.rs
child performs a bounded byte read, distinguishes TOML syntax errors, invalid
state content, and unsupported versions without retaining parser source text,
serializes the complete state before replacement, and requires mutable access
to replace it. Its daily_files.rs sibling recognizes only canonical event
and metric filenames. When private state is absent, storage initializes the
high-water mark and identifier-window anchor from the later of the current UTC
day and the newest surviving daily filename; malformed and unsupported state
remain errors rather than entering this path.
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— printsformat_human()to stdout (default for most commands)Verbose(-v) — prints all events (info + debug) to stderrJson(--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.