Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions md/design/important-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ This section describes the logic of each `cargo agents` command.

When a skill group uses `source = "crate"`, the sync flow takes an additional path:

1. `predicate::union_matched_packages()` resolves plugin-level and group-level predicates against the workspace to produce a set of concrete crate name/version pairs.
2. For each crate in the set, `RustCrateFetch` fetches the source — checking path overrides (for local path deps), then the cargo registry cache, then crates.io.
1. `predicate::union_matched_packages()` resolves plugin-level and group-level predicates against the workspace to produce a set of concrete package ids (`depends-on` witnesses).
2. For each crate in the set, the [cargo package manager](./module-structure.md#pm--package-managers) fetches the source: `CargoPm::fetch` delegates to `RustCrateFetch`, which checks path overrides (for local path deps), then the cargo registry cache, then crates.io. The fetched id carries the exact resolved version.
3. `crate_metadata::parse_crate_metadata()` reads `[package.metadata.symposium]` from the crate's `Cargo.toml`:
- **No metadata** — fall back to the default `skills/` subdirectory.
- **`skills = []`** — no skills from this crate.
- **`path = "..."` entries** — scan that subdirectory for skills.
- **`crate = { name, version? }` entries** — redirect: fetch the target crate and follow its metadata recursively (with cycle detection and a depth limit of 10).
4. `discover_skills()` scans each resolved directory for `SKILL.md` files.

The key code paths are in `skills.rs` (`load_crate_skills`, `fetch_and_resolve_skills`), `crate_metadata.rs` (`parse_crate_metadata`), `predicate.rs` (`witness`, `union_matched_packages`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`).
The key code paths are in `skills.rs` (`load_crate_skills`, `fetch_and_resolve_skills`), `crate_metadata.rs` (`parse_crate_metadata`), `predicate.rs` (`witness`, `union_matched_packages`), `pm/cargo.rs` (`CargoPm`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`).

## Help rendering

Expand Down
10 changes: 7 additions & 3 deletions md/design/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,26 @@ Defines `Source` (the `source = "..."`-tagged enum: `cargo`, `github`) and `acqu

Validates skill group source constraints during manifest validation: mutual exclusivity of `source.path`/`source.git`/`source = "crate"`, and the requirement that `source = "crate"` has at least one non-wildcard predicate.

### `pm/` — package managers

The in-process seam from the [registry-centric plugin distribution RFD](../rfds/registry-centric-plugins/README.md). 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. The `PackageManager` trait has two operations today — `fetch` and `list_deps` — and one implementation: `CargoPm` (`pm/cargo.rs`), whose `fetch` delegates to `crate_sources::RustCrateFetch` (path-dependency override, workspace pin, registry) and whose `list_deps` renders the workspace crates as cargo ids, the form [predicate evaluation](#predicaters--unified-activation-predicates) consumes. Consumers: crate-source skill resolution in `skills.rs` and `crate_command.rs` build ids with `CargoPm::id_for` and fetch through the trait; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) builds its `PredicateContext` from `list_deps`. The RFD's other operations (`resolve`/`list_plugins`, `search`) are not routed through the seam yet.

### `crate_metadata.rs` — parse Cargo.toml metadata

Parses `[package.metadata.symposium]` from crate `Cargo.toml` files. Crate authors embed skill layout metadata so Symposium knows where to find skills (or which other crate to redirect to). Returns `SkillSource::Path(subdir)` or `SkillSource::Crate { name, version }` for redirects.

### `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). Two surface syntaxes lower to the same tree:
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 — `PackageId`s from the [package-manager layer](#pm--package-managers)'s `list_deps`). 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`. For `source = "crate"`, `witness` / `union_matched_packages` return the concrete crates that participate in a *satisfying* evaluation (the fetch set): `depends-on(c)` contributes `c` when present, `any` unions its true children, `all` unions all children when all hold, and `not` contributes nothing. `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(...)` — wildcard and env/shell/path predicates dispatch without a cargo query. See the [predicates reference](../reference/predicates.md).
Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged `predicates: PredicateSet`. Evaluation is `PredicateSet::evaluate(ctx) -> bool`. 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. For `source = "crate"`, `witness` / `union_matched_packages` return the concrete `PackageId`s that participate in a *satisfying* evaluation (the fetch set): `depends-on(c)` contributes `c`'s ids when present, `any` unions its true children, `all` unions all children when all hold, and `not` contributes nothing. `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(...)` — wildcard and env/shell/path predicates dispatch without a cargo query. See the [predicates reference](../reference/predicates.md).

### `skills.rs` — skill resolution and matching

Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources (fetching from git if needed), discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. For `source = "crate"` groups, resolves predicates to a matched crate set, fetches each crate's source via `RustCrateFetch`, reads `[package.metadata.symposium]` to determine skill paths, and follows redirects recursively with cycle detection and a depth limit of 10.
Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources (fetching from git if needed), discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. For `source = "crate"` groups, resolves predicates to a matched crate set, fetches each crate's source through the [package-manager layer](#pm--package-managers) (`CargoPm::fetch`), reads `[package.metadata.symposium]` to determine skill paths, and follows redirects recursively with cycle detection and a depth limit of 10.

Each applicable skill carries a `SkillOrigin` describing *where its bytes live*, used at sync time for dedup and install-path disambiguation. What matters for identity is the on-disk location of the skill, not which plugin manifest pointed at it — two plugins in the same source pointing at the same skill bundle dedupe.
- `Crate { name, version }` — from a crate-source resolution (`source = "crate"`). Two `Crate` origins with the same `(name, version)` are the same logical skill, regardless of which plugin pointed at them.
Expand Down
16 changes: 6 additions & 10 deletions src/crate_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::path::Path;

use crate::config::Symposium;
use crate::crate_sources;
use crate::pm::{CargoPm, PackageManager as _};

/// Result of dispatching a command.
pub enum DispatchResult {
Expand All @@ -22,18 +22,14 @@ pub async fn dispatch_crate(
tracing::debug!(%name, ?version, "crate-info dispatched");
let mut deps = sym.workspace_deps(cwd);
let workspace = deps.crates();
let mut fetch = crate_sources::RustCrateFetch::new(name, workspace);
if let Some(v) = version {
fetch = fetch.version(v);
}

match fetch.fetch().await {
let id = CargoPm::id_for(name, version);
match CargoPm.fetch(&id, workspace).await {
Ok(result) => {
let output = format!(
"Crate: {}\nVersion: {}\nSource: {}\n",
result.name,
result.version,
result.path.display()
result.id.name,
result.id.version,
result.root.display()
);
tracing::trace!(%output, "crate-info output");
DispatchResult::Ok(output)
Expand Down
12 changes: 0 additions & 12 deletions src/crate_sources/list.rs

This file was deleted.

3 changes: 0 additions & 3 deletions src/crate_sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,8 @@ use std::path::PathBuf;
use anyhow::Result;
use symposium_sdk::workspace::WorkspaceCrate;

mod list;
mod probe;

pub use list::crate_pairs;

/// Normalize a crate name for hyphen/underscore-insensitive comparison.
///
/// Cargo treats `foo-bar` and `foo_bar` as the same crate name (published
Expand Down
6 changes: 3 additions & 3 deletions src/help_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
use std::{fmt::Write as _, path::Path};

use clap::{Command, CommandFactory};
use semver::Version;

use symposium_sdk::workspace::WorkspaceCrate;

use crate::{
cli::{Cli, Commands, builtin_audience},
config::Symposium,
plugins::{Audience, PluginRegistry, load_registry_with_workspace},
pm::{PackageId, PackageManager as _},
subcommand_dispatch::applicable_subcommands,
};

Expand Down Expand Up @@ -108,7 +108,7 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String {
let header = &full[..commands_idx];
let options = &full[options_idx..];

let deps = crate::crate_sources::crate_pairs(workspace);
let deps = crate::pm::CargoPm.list_deps(workspace);

let humans = collect_section(&cmd, registry, &deps, Audience::Humans);
let agents = collect_section(&cmd, registry, &deps, Audience::Agents);
Expand Down Expand Up @@ -146,7 +146,7 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String {
fn collect_section(
cmd: &Command,
registry: &PluginRegistry,
deps: &[(String, Version)],
deps: &[PackageId],
target: Audience,
) -> Vec<(String, String)> {
let mut builtins = cmd
Expand Down
23 changes: 14 additions & 9 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::installation::{
resolve_runnable,
};
use crate::plugins::{HookFormat, Installation};
use crate::pm::PackageManager as _;
use crate::{
config::Symposium,
hook_schema::{AgentHookInput, symposium},
Expand Down Expand Up @@ -412,12 +413,12 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) {

// Resolving the workspace runs cargo, so only do it when some hook's
// gating references a concrete crate (mirrors dispatch).
let pairs = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) {
crate::crate_sources::crate_pairs(deps.crates())
let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) {
crate::pm::CargoPm.list_deps(deps.crates())
} else {
Vec::new()
};
let mut ctx = crate::predicate::PredicateContext::new(&pairs);
let mut ctx = crate::predicate::PredicateContext::new(&dep_ids);

for parsed in &plugins {
if !parsed.applies(&mut ctx) {
Expand Down Expand Up @@ -496,9 +497,9 @@ fn handle_session_start(
fn discovery_hint(sym: &Symposium, deps: &mut WorkspaceDeps) -> Option<String> {
let workspace = deps.load().cloned();
let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref());
let pairs = crate::crate_sources::crate_pairs(deps.crates());
let dep_ids = crate::pm::CargoPm.list_deps(deps.crates());

let any_subcommand = !applicable_subcommands(&registry, &pairs).is_empty();
let any_subcommand = !applicable_subcommands(&registry, &dep_ids).is_empty();

any_subcommand.then(|| {
format!(
Expand Down Expand Up @@ -576,12 +577,12 @@ pub async fn dispatch_plugin_hooks(
// Resolving the workspace means running cargo, so only do it when some
// plugin's hook gating actually references a concrete crate (a `depends-on(*)`
// wildcard or env/shell/path predicate never needs the crate graph).
let pairs = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) {
crate::crate_sources::crate_pairs(deps.crates())
let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) {
crate::pm::CargoPm.list_deps(deps.crates())
} else {
Vec::new()
};
let mut ctx = crate::predicate::PredicateContext::new(&pairs);
let mut ctx = crate::predicate::PredicateContext::new(&dep_ids);
let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &mut ctx);

let mut output = prior_output;
Expand Down Expand Up @@ -1131,7 +1132,11 @@ mod tests {
);

// serde present → the hook fires.
let deps = vec![("serde".to_string(), semver::Version::new(1, 0, 0))];
let deps = vec![crate::pm::PackageId::new(
crate::pm::CARGO_PM,
"serde",
"1.0.0",
)];
let matched = dispatched_hooks_for_payload(
&[plugin],
&pre_tool_use_input(),
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod hook_schema;
pub(crate) mod installation;
pub mod output;
pub mod plugins;
pub mod pm;
pub mod report;
pub mod self_update;
pub mod state;
Expand Down
10 changes: 5 additions & 5 deletions src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2052,8 +2052,8 @@ mod tests {
PredicateSet::from_depends_on(s).unwrap()
}

fn ctx(crates: &[(String, semver::Version)]) -> crate::predicate::PredicateContext<'_> {
crate::predicate::PredicateContext::new(crates)
fn ctx(deps: &[crate::pm::PackageId]) -> crate::predicate::PredicateContext<'_> {
crate::predicate::PredicateContext::new(deps)
}

fn from_str(s: &str) -> Result<Plugin> {
Expand Down Expand Up @@ -2643,8 +2643,8 @@ mod tests {
#[test]
fn plugin_crate_filtering() {
let workspace_crates = vec![
("serde".to_string(), semver::Version::new(1, 0, 0)),
("tokio".to_string(), semver::Version::new(1, 0, 0)),
crate::pm::PackageId::new(crate::pm::CARGO_PM, "serde", "1.0.0"),
crate::pm::PackageId::new(crate::pm::CARGO_PM, "tokio", "1.0.0"),
];

// Plugin with wildcard - should apply to all
Expand Down Expand Up @@ -2852,7 +2852,7 @@ mod tests {
source_dir: PathBuf::from("/test"),
workspace_member: false,
};
let deps: Vec<(String, semver::Version)> = Vec::new();
let deps: Vec<crate::pm::PackageId> = Vec::new();
let mut c = ctx(&deps);
assert!(!parsed.applies(&mut c));
parsed.workspace_member = true;
Expand Down
41 changes: 41 additions & 0 deletions src/pm/cargo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! The cargo package manager: crates from the active workspace's dependency
//! graph, resolved by [`RustCrateFetch`] (path-dependency override, then the
//! cargo registry cache, then crates.io).

use anyhow::Result;
use symposium_sdk::workspace::WorkspaceCrate;

use crate::crate_sources::RustCrateFetch;

use super::{ANY_VERSION, CARGO_PM, FetchedPackage, PackageId, PackageManager};

pub struct CargoPm;

impl CargoPm {
/// Cargo id for a crate name and optional version requirement.
pub fn id_for(name: &str, version: Option<&str>) -> PackageId {
PackageId::new(CARGO_PM, name, version.unwrap_or(ANY_VERSION))
}
}

impl PackageManager for CargoPm {
async fn fetch(&self, id: &PackageId, workspace: &[WorkspaceCrate]) -> Result<FetchedPackage> {
debug_assert_eq!(id.pm, CARGO_PM);
let mut fetch = RustCrateFetch::new(&id.name, workspace);
if id.version != ANY_VERSION {
fetch = fetch.version(&id.version);
}
let result = fetch.fetch().await?;
Ok(FetchedPackage {
id: PackageId::new(CARGO_PM, result.name, result.version),
root: result.path,
})
}

fn list_deps(&self, workspace: &[WorkspaceCrate]) -> Vec<PackageId> {
workspace
.iter()
.map(|c| PackageId::new(CARGO_PM, c.name.clone(), c.version.to_string()))
.collect()
}
}
Loading
Loading