From d64a3ac91b9bd100041a296a74766f0fa285b626 Mon Sep 17 00:00:00 2001 From: jackh726 Date: Sun, 12 Jul 2026 17:47:22 +0000 Subject: [PATCH 1/3] Add the workspace-member() predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace-member() holds when the plugin being evaluated is defined by a member of the active workspace. It is provenance, not a workspace fact: the same bytes can arrive as a workspace member or as a fetched dependency, and only the loading route distinguishes them — so the loader stamps it per plugin. ParsedPlugin carries the stamp (registry sources always stamp false; the workspace-plugin loader will stamp true when workspace-local extensions land) and ParsedPlugin::applies() writes it into the PredicateContext before evaluating, covering the plugin's nested components on the same context. All plugin-gate call sites (skills, hook dispatch, prewarm, MCP collection, subcommands) route through it; standalone skills clear the stamp. --- md/design/module-structure.md | 2 +- md/reference/predicates.md | 1 + src/help_render.rs | 1 + src/hook.rs | 5 +-- src/plugins.rs | 51 +++++++++++++++++++++++++++++ src/predicate.rs | 61 +++++++++++++++++++++++++++++++++++ src/skills.rs | 13 ++++++-- src/subcommand_dispatch.rs | 9 ++++-- src/sync.rs | 2 +- 9 files changed, 136 insertions(+), 9 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 907921fa..dbfb8c46 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -54,7 +54,7 @@ Parses `[package.metadata.symposium]` from crate `Cargo.toml` files. Crate autho 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: - 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()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, and the combinators `not(

)`, `any(

, …)`, `all(

, …)`. The retired `crate(...)` spelling is rejected with a migration hint, as are the old `crates` fields. +- The **`predicates`** field uses function-call syntax: `depends-on()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, `workspace-member()` (the plugin is defined by a member of the active workspace — provenance stamped per plugin into `PredicateContext` via `ParsedPlugin::applies`; registry loading always stamps false until workspace-local extensions land), and the combinators `not(

)`, `any(

, …)`, `all(

, …)`. 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). diff --git a/md/reference/predicates.md b/md/reference/predicates.md index 7cd01241..6ef8a953 100644 --- a/md/reference/predicates.md +++ b/md/reference/predicates.md @@ -17,6 +17,7 @@ The available predicate functions are: | `path_exists()` | `` resolves to an existing path. An argument with a path separator is checked on the filesystem (cwd-relative or absolute). A bare name with no separator is checked against the cwd and then searched on `$PATH`, so it matches either a local entry (`path_exists(.git)`) or an installed binary (`path_exists(rg)`). | | `env()` | The environment variable `` is set (to any value). | | `env(=)` | `` is set and equals `` exactly. Only the first `=` separates name from value, so `env(KEY=a=b)` matches the value `a=b`. | +| `workspace-member()` | The plugin this predicate belongs to is defined by a member of the active workspace. Takes no argument. | | `not()` | The inner predicate does **not** hold. The only way to express absence. | | `any(

,

, …)` | At least one inner predicate holds (logical **OR**). | | `all(

,

, …)` | Every inner predicate holds (logical **AND**). | diff --git a/src/help_render.rs b/src/help_render.rs index 2b5e99bf..77799f0a 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -215,6 +215,7 @@ mod tests { }, source_name: "test".into(), source_dir: PathBuf::from("/test"), + workspace_member: false, } } diff --git a/src/hook.rs b/src/hook.rs index 93af9dc8..d7b82ed9 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -420,7 +420,7 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { let mut ctx = crate::predicate::PredicateContext::new(&pairs); for parsed in &plugins { - if !parsed.plugin.applies(&mut ctx) { + if !parsed.applies(&mut ctx) { continue; } for hook in &parsed.plugin.hooks { @@ -750,7 +750,7 @@ fn dispatched_hooks_for_payload( for parsed_plugin in plugins { // Plugin-level predicates gate every hook in the plugin. Evaluated once // per plugin per dispatch — keep them cheap. - if !parsed_plugin.plugin.applies(ctx) { + if !parsed_plugin.applies(ctx) { tracing::debug!( plugin = %parsed_plugin.plugin.name, "plugin predicates failed, skipping hooks" @@ -1056,6 +1056,7 @@ mod tests { plugin, source_name: "test-source".to_string(), source_dir: PathBuf::from(".".to_string()), + workspace_member: false, } } diff --git a/src/plugins.rs b/src/plugins.rs index 892caecb..68c63409 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -314,6 +314,25 @@ pub struct ParsedPlugin { /// The plugin source's root directory on disk. Used as the base for /// computing the `skill_path` field on `SkillOrigin::Source`. pub source_dir: PathBuf, + + /// Whether this plugin is defined by a member of the active workspace. + /// Provenance, stamped by the loader: registry sources stamp `false`; + /// the workspace-plugin loader (workspace-local extensions) will stamp + /// `true`. Backs the `workspace-member()` predicate. + pub workspace_member: bool, +} + +impl ParsedPlugin { + /// Evaluate the plugin-level predicate set, stamping this plugin's + /// provenance into the context first. Use this — not + /// `plugin.applies()` directly — when iterating loaded plugins, so + /// `workspace-member()` sees the right plugin's provenance. The stamp + /// carries over to the plugin's nested component evaluations (groups, + /// skills, hooks, MCP servers, subcommands) on the same context. + pub fn applies(&self, ctx: &mut crate::predicate::PredicateContext) -> bool { + ctx.set_workspace_member(self.workspace_member); + self.plugin.applies(ctx) + } } /// A loaded, *validated* plugin manifest. @@ -1499,6 +1518,9 @@ pub fn load_plugin( plugin, source_name: source_name.to_string(), source_dir: source_dir.to_path_buf(), + // Registry sources are never workspace members; the workspace-plugin + // loader is the only place that stamps true. + workspace_member: false, }) } @@ -2465,6 +2487,34 @@ mod tests { assert!(!plugin_version.applies(&mut ctx(&workspace_crates))); } + #[test] + fn parsed_plugin_applies_stamps_workspace_member() { + let plugin = Plugin { + name: "ws-plugin".to_string(), + predicates: PredicateSet { + predicates: vec![crate::predicate::Predicate::WorkspaceMember], + }, + hooks: vec![], + skills: vec![], + mcp_servers: vec![], + installations: Vec::new(), + subcommands: BTreeMap::new(), + custom_predicates: vec![], + }; + let mut parsed = ParsedPlugin { + path: PathBuf::from("/test/SYMPOSIUM.toml"), + plugin, + source_name: "test".into(), + source_dir: PathBuf::from("/test"), + workspace_member: false, + }; + let deps: Vec<(String, semver::Version)> = Vec::new(); + let mut c = ctx(&deps); + assert!(!parsed.applies(&mut c)); + parsed.workspace_member = true; + assert!(parsed.applies(&mut c)); + } + #[test] fn validate_source_dir_enforces_crates_requirement() { use crate::test_utils::{File, instantiate_fixture}; @@ -4022,6 +4072,7 @@ mod tests { }, source_name: "test".into(), source_dir: std::path::PathBuf::from("/test"), + workspace_member: false, } } diff --git a/src/predicate.rs b/src/predicate.rs index 9743771f..cd044a3d 100644 --- a/src/predicate.rs +++ b/src/predicate.rs @@ -40,6 +40,7 @@ pub const BUILTIN_PREDICATE_NAMES: &[&str] = &[ "shell", "path_exists", "env", + "workspace-member", "not", "any", "all", @@ -54,6 +55,11 @@ pub const BUILTIN_PREDICATE_NAMES: &[&str] = &[ #[derive(Debug)] pub struct PredicateContext<'a> { pub deps: &'a [(String, semver::Version)], + /// Whether the plugin currently being evaluated is defined by a member + /// of the active workspace. This is *provenance*, not a workspace fact: + /// the loader stamps it per plugin (via `ParsedPlugin::applies`) before + /// that plugin's predicate sets are evaluated. + workspace_member: bool, custom_entries: std::collections::HashMap, custom_cache: std::collections::HashMap<(String, String), CustomPredicateResult>, } @@ -62,6 +68,7 @@ impl<'a> PredicateContext<'a> { pub fn new(deps: &'a [(String, semver::Version)]) -> Self { Self { deps, + workspace_member: false, custom_entries: std::collections::HashMap::new(), custom_cache: std::collections::HashMap::new(), } @@ -73,11 +80,20 @@ impl<'a> PredicateContext<'a> { ) -> Self { Self { deps, + workspace_member: false, custom_entries: entries, custom_cache: std::collections::HashMap::new(), } } + /// Stamp whether the plugin about to be evaluated arrived via workspace + /// membership. Call before evaluating each plugin's predicate sets; the + /// value applies to all of that plugin's nested components (groups, + /// skills, hooks, MCP servers, subcommands). + pub fn set_workspace_member(&mut self, workspace_member: bool) { + self.workspace_member = workspace_member; + } + /// Evaluate a custom predicate by name and argument, returning the cached /// result if already computed. fn evaluate_custom(&mut self, name: &str, arg: &str) -> bool { @@ -123,6 +139,11 @@ pub enum Predicate { PathExists(String), /// `env()` / `env(=)` — env var presence / equality. Env(String, Option), + /// `workspace-member()` — the plugin being evaluated is defined by a + /// member of the active workspace (provenance, stamped by the loader). + /// Selects content by audience: gate a component on it to activate only + /// for people developing the defining package, not for dependents. + WorkspaceMember, /// `not(

)` — passes when the inner predicate does not. Not(Box), /// `any(

, …)` — passes when at least one inner predicate does. @@ -151,6 +172,7 @@ impl Predicate { Predicate::Shell(cmd) => run_shell(cmd), Predicate::PathExists(arg) => path_exists(arg), Predicate::Env(name, expected) => env_matches(name, expected.as_deref()), + Predicate::WorkspaceMember => ctx.workspace_member, Predicate::Not(inner) => !inner.evaluate(ctx), Predicate::Any(children) => children.iter().any(|p| p.evaluate(ctx)), Predicate::All(children) => children.iter().all(|p| p.evaluate(ctx)), @@ -184,6 +206,7 @@ impl Predicate { Predicate::Shell(cmd) => run_shell(cmd).then(Vec::new), Predicate::PathExists(arg) => path_exists(arg).then(Vec::new), Predicate::Env(name, expected) => env_matches(name, expected.as_deref()).then(Vec::new), + Predicate::WorkspaceMember => ctx.workspace_member.then(Vec::new), Predicate::Not(inner) => match inner.witness(ctx) { Some(_) => None, None => Some(Vec::new()), @@ -516,6 +539,12 @@ fn parse(input: &str) -> Result { "shell" => Ok(Predicate::Shell(arg.to_string())), "path_exists" => Ok(Predicate::PathExists(arg.to_string())), "env" => parse_env(arg), + "workspace-member" => { + if !arg.is_empty() { + bail!("`workspace-member()` takes no argument, got {arg:?}"); + } + Ok(Predicate::WorkspaceMember) + } "not" => Ok(Predicate::Not(Box::new(parse(arg)?))), "any" => { let preds = parse_comma_separated(arg)?; @@ -790,6 +819,7 @@ impl std::fmt::Display for Predicate { Predicate::PathExists(arg) => write!(f, "path_exists({arg})"), Predicate::Env(name, None) => write!(f, "env({name})"), Predicate::Env(name, Some(value)) => write!(f, "env({name}={value})"), + Predicate::WorkspaceMember => write!(f, "workspace-member()"), Predicate::Not(inner) => write!(f, "not({inner})"), Predicate::Any(preds) => write!(f, "any({})", join(preds)), Predicate::All(preds) => write!(f, "all({})", join(preds)), @@ -991,6 +1021,37 @@ mod tests { .collect() } + // --- workspace-member --- + + #[test] + fn workspace_member_parses_and_roundtrips() { + let p = parse("workspace-member()").unwrap(); + assert_eq!(p, Predicate::WorkspaceMember); + assert_eq!(p.to_string(), "workspace-member()"); + // No-argument predicate: an argument is a parse error. + assert!(parse("workspace-member(foo)").is_err()); + // Reserved: a custom predicate can't claim the name. + assert!(validate_custom_predicate_name("workspace-member").is_err()); + } + + #[test] + fn workspace_member_follows_context_stamp() { + let deps = ws(&[]); + let mut c = ctx(&deps); + let p = Predicate::WorkspaceMember; + assert!(!p.evaluate(&mut c)); + assert_eq!(p.witness(&mut c), None); + + c.set_workspace_member(true); + assert!(p.evaluate(&mut c)); + assert_eq!(p.witness(&mut c), Some(Vec::new())); + // Composes with combinators. + assert!(!Predicate::Not(Box::new(Predicate::WorkspaceMember)).evaluate(&mut c)); + + c.set_workspace_member(false); + assert!(!p.evaluate(&mut c)); + } + // --- crate-atom parsing --- #[test] diff --git a/src/skills.rs b/src/skills.rs index d8050425..ac531ffe 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -165,8 +165,9 @@ pub async fn skills_applicable_to( for parsed in ®istry.plugins { let plugin = &parsed.plugin; // Plugin-level predicates gate everything below. Evaluated before - // group fetching to avoid wasted work. - if !plugin.predicates.evaluate(&mut ctx) { + // group fetching to avoid wasted work. Goes through the ParsedPlugin + // so the plugin's provenance is stamped for `workspace-member()`. + if !parsed.applies(&mut ctx) { tracing::debug!( report = %crate::report::ReportEvent::PluginConsidered { plugin: plugin.name.clone(), @@ -205,6 +206,9 @@ pub async fn skills_applicable_to( }, ); } + // Standalone skills have no defining plugin; they never count as + // workspace members (clear any stamp left by the plugin loop). + ctx.set_workspace_member(false); for entry in ®istry.standalone_skills { collect_skill_applicable_to( entry.skill.clone(), @@ -1335,6 +1339,7 @@ mod tests { plugin, source_name: "test".to_string(), source_dir: tmp.path().to_path_buf(), + workspace_member: false, }], standalone_skills: vec![], warnings: vec![], @@ -1391,6 +1396,7 @@ mod tests { plugin, source_name: "test".to_string(), source_dir: tmp.path().to_path_buf(), + workspace_member: false, }], standalone_skills: vec![], warnings: vec![], @@ -1465,6 +1471,7 @@ mod tests { plugin, source_name: "test".to_string(), source_dir: tmp.path().to_path_buf(), + workspace_member: false, }], standalone_skills: vec![], warnings: vec![], @@ -1544,6 +1551,7 @@ mod tests { plugin, source_name: "test".to_string(), source_dir: PathBuf::from(".".to_string()), + workspace_member: false, }], standalone_skills: vec![], warnings: vec![], @@ -1624,6 +1632,7 @@ mod tests { plugin, source_name: "test".to_string(), source_dir: PathBuf::from(".".to_string()), + workspace_member: false, }], standalone_skills: vec![], warnings: vec![], diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 0bcb4c6b..29f5f80f 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -13,7 +13,7 @@ use symposium_sdk::workspace::WorkspaceCrate; use crate::{ config::Symposium, installation::{acquire_installation, resolve_runnable}, - plugins::{self, ParsedPlugin, Plugin, PluginRegistry, Subcommand}, + plugins::{self, Plugin, PluginRegistry, Subcommand}, }; use anyhow::{Context, Result, bail}; use semver::Version; @@ -28,8 +28,9 @@ pub fn applicable_subcommands<'a>( ) -> Vec<(&'a Plugin, &'a str, &'a Subcommand)> { let mut ctx = crate::predicate::PredicateContext::new(deps); let mut results = Vec::new(); - for ParsedPlugin { plugin, .. } in ®istry.plugins { - if !plugin.applies(&mut ctx) { + for parsed in ®istry.plugins { + let plugin = &parsed.plugin; + if !parsed.applies(&mut ctx) { continue; } for (name, subcommand) in &plugin.subcommands { @@ -158,6 +159,7 @@ fn exit_byte_from(status: ExitStatus) -> u8 { #[cfg(test)] mod tests { use super::*; + use crate::plugins::ParsedPlugin; use crate::{plugins::Audience, predicate::PredicateSet}; use std::{collections::BTreeMap, path::PathBuf}; @@ -188,6 +190,7 @@ mod tests { }, source_name: "test".into(), source_dir: PathBuf::from("/test"), + workspace_member: false, } } diff --git a/src/sync.rs b/src/sync.rs index dbc5b9a9..c37169ce 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -352,7 +352,7 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel let mut ctx = crate::predicate::PredicateContext::new(&semver_pairs); let mut mcp_servers: Vec = Vec::new(); for p in ®istry.plugins { - if p.plugin.applies(&mut ctx) { + if p.applies(&mut ctx) { mcp_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); } } From 1d464d82f6c223da1273b454ac1975a5a25d4b06 Mon Sep 17 00:00:00 2001 From: jackh726 Date: Sun, 12 Jul 2026 18:58:35 +0000 Subject: [PATCH 2/3] Workspace plugins: the workspace's own dirs define plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the RFD's workspace-local extensions. The workspace root and every member directory now define a plugin when they carry a SYMPOSIUM.toml manifest or a bare skills/ directory. Workspace manifests are validated with relaxed rules (ManifestOrigin::WorkspaceMember): the name defaults to the directory name, the must-mention-a-dependency rule is waived (workspace membership is the gate), and the default `[[skills]] source.path = "skills"` group is appended unless the manifest opts out with `[defaults] skills = false`. The SDK's LoadedWorkspace now carries member manifest directories (the disk-cache format bump self-heals: old caches fail to parse and are rebuilt). Workspace-scoped callers — sync, hook dispatch/prewarm, the discovery hint, help rendering, subcommand dispatch — load the registry via load_registry_with_workspace; init's global hook registration stays registry-only. Workspace plugins are stamped workspace_member = true, making them the first producer of the workspace-member() predicate, and their skills are attributed to the "(workspace)" source with paths relative to the workspace root. The existing .agents/skills propagation is untouched; folding it into a workspace-member()-gated default group is a follow-up. --- md/design/module-structure.md | 4 +- md/reference/predicates.md | 2 +- md/workspace-skills.md | 15 +- src/help_render.rs | 8 +- src/hook.rs | 10 +- src/plugins.rs | 323 ++++++++++++++++-- src/subcommand_dispatch.rs | 6 +- src/sync.rs | 5 +- symposium-sdk/src/workspace.rs | 26 +- tests/fixtures/workspace-plugin0/Cargo.toml | 7 + .../skills/ws-hello/SKILL.md | 8 + tests/fixtures/workspace-plugin0/src/lib.rs | 1 + tests/init_sync.rs | 26 ++ 13 files changed, 393 insertions(+), 48 deletions(-) create mode 100644 tests/fixtures/workspace-plugin0/Cargo.toml create mode 100644 tests/fixtures/workspace-plugin0/skills/ws-hello/SKILL.md create mode 100644 tests/fixtures/workspace-plugin0/src/lib.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index dbfb8c46..06077620 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -33,6 +33,8 @@ Scans configured plugin source directories for TOML manifests and parses them in Also discovers standalone `SKILL.md` files not wrapped in a plugin. Returns a `PluginRegistry` — a table of contents that doesn't load skill content. +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, the every-plugin-must-mention-a-dependency rule is waived, and the default `[[skills]] source.path = "skills"` group is appended unless `[defaults] skills = false`) or a bare `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. @@ -54,7 +56,7 @@ Parses `[package.metadata.symposium]` from crate `Cargo.toml` files. Crate autho 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: - 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()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, `workspace-member()` (the plugin is defined by a member of the active workspace — provenance stamped per plugin into `PredicateContext` via `ParsedPlugin::applies`; registry loading always stamps false until workspace-local extensions land), and the combinators `not(

)`, `any(

, …)`, `all(

, …)`. The retired `crate(...)` spelling is rejected with a migration hint, as are the old `crates` fields. +- The **`predicates`** field uses function-call syntax: `depends-on()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, `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(

)`, `any(

, …)`, `all(

, …)`. 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). diff --git a/md/reference/predicates.md b/md/reference/predicates.md index 6ef8a953..c1d2c66c 100644 --- a/md/reference/predicates.md +++ b/md/reference/predicates.md @@ -17,7 +17,7 @@ The available predicate functions are: | `path_exists()` | `` resolves to an existing path. An argument with a path separator is checked on the filesystem (cwd-relative or absolute). A bare name with no separator is checked against the cwd and then searched on `$PATH`, so it matches either a local entry (`path_exists(.git)`) or an installed binary (`path_exists(rg)`). | | `env()` | The environment variable `` is set (to any value). | | `env(=)` | `` is set and equals `` exactly. Only the first `=` separates name from value, so `env(KEY=a=b)` matches the value `a=b`. | -| `workspace-member()` | The plugin this predicate belongs to is defined by a member of the active workspace. Takes no argument. | +| `workspace-member()` | The plugin this predicate belongs to is defined by a member of the active workspace (a [workspace plugin](../workspace-skills.md)). Takes no argument. | | `not()` | The inner predicate does **not** hold. The only way to express absence. | | `any(

,

, …)` | At least one inner predicate holds (logical **OR**). | | `all(

,

, …)` | Every inner predicate holds (logical **AND**). | diff --git a/md/workspace-skills.md b/md/workspace-skills.md index 99384653..0fdb6290 100644 --- a/md/workspace-skills.md +++ b/md/workspace-skills.md @@ -1,6 +1,6 @@ # Workspace skills -In addition to adding skills based on your dependencies, Symposium will also copy any additional skills found in `.agents/skills` into the directory appropriate for your configured agent(s). +In addition to adding skills based on your dependencies, Symposium will also install skills your workspace defines for itself, and copy any additional skills found in `.agents/skills` into the directory appropriate for your configured agent(s). This "skill-syncing" feature allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users will have the skills synced to `.claude/skills`). @@ -11,6 +11,19 @@ The default skill location therefore varies depending on the intended audience: | Maintaining your crate | `.agents/skills` | | [Using your crate](./crate-authors/supporting-your-crate.md) | `skills/` | +## Workspace plugins + +The workspace root and every member crate directory can define a *workspace plugin*: add a `SYMPOSIUM.toml` manifest (see the [plugin definition](./reference/plugin-definition.md)), or just a bare `skills/` directory — a directory with skills and no manifest counts as a plugin whose only content is those skills. + +Workspace plugins are always active while you work in that workspace — no plugin source configuration or `depends-on` gate is needed. A `skills/` directory in a member crate serves double duty: it installs for everyone working in the workspace *and*, once published, for projects that depend on the crate. + +Two details specific to workspace manifests: + +- `name` may be omitted; it defaults to the directory name. +- The default `skills/` group can be disabled with `[defaults] skills = false`. + +Components that should apply only to people developing the workspace (not to dependents of a published crate) can be gated with the [`workspace-member()` predicate](./reference/predicates.md). + ## Recommended git setup We recommend you commit your `.agents/skills` or `skills/` into the repository. Symposium installs a `.gitignore` file into every skill that it creates, so automatically copied and installed skills should not dirty your git status. diff --git a/src/help_render.rs b/src/help_render.rs index 77799f0a..fbe1a5e6 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -16,7 +16,7 @@ use symposium_sdk::workspace::WorkspaceCrate; use crate::{ cli::{Cli, Commands, builtin_audience}, config::Symposium, - plugins::{Audience, PluginRegistry, load_registry}, + plugins::{Audience, PluginRegistry, load_registry_with_workspace}, subcommand_dispatch::applicable_subcommands, }; @@ -89,10 +89,10 @@ pub fn subcommand_help(args: &[String]) -> Option { } pub fn render_help(sym: &Symposium, cwd: &Path) -> String { - let registry = load_registry(sym); let mut deps = sym.workspace_deps(cwd); - let workspace = deps.crates(); - render(®istry, workspace) + let workspace = deps.load().cloned(); + let registry = load_registry_with_workspace(sym, workspace.as_deref()); + render(®istry, deps.crates()) } fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { diff --git a/src/hook.rs b/src/hook.rs index d7b82ed9..0c1b48ae 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -19,7 +19,6 @@ use crate::{ use crate::{ help_render::{AGENTS_HEADING, HUMANS_HEADING}, hook_schema::symposium::{OutputEvent, SessionStartInput}, - plugins::load_registry, subcommand_dispatch::applicable_subcommands, }; use symposium_sdk::workspace::WorkspaceDeps; @@ -408,7 +407,8 @@ async fn run_auto_sync(sym: &Symposium, deps: &mut WorkspaceDeps, session_start: /// lazily when the hook first fires) — `SessionStart` updates installed tools /// but never installs eagerly. Best-effort: failures are logged and skipped. async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { - let plugins = crate::plugins::load_all_plugins(sym); + let workspace = deps.load().cloned(); + let plugins = crate::plugins::load_all_plugins(sym, workspace.as_deref()); // Resolving the workspace runs cargo, so only do it when some hook's // gating references a concrete crate (mirrors dispatch). @@ -494,7 +494,8 @@ fn handle_session_start( /// Suggest `cargo agents --help` when the active workspace exposes crate-aware plugin subcommands. /// Reuses the help renderer's `applicable_subcommands`, so the hint fires only when there is actually something to discover; `None` otherwise. fn discovery_hint(sym: &Symposium, deps: &mut WorkspaceDeps) -> Option { - let registry = load_registry(sym); + 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 any_subcommand = !applicable_subcommands(®istry, &pairs).is_empty(); @@ -569,7 +570,8 @@ pub async fn dispatch_plugin_hooks( prior_output: serde_json::Value, deps: &mut WorkspaceDeps, ) -> Result> { - let plugins = crate::plugins::load_all_plugins(sym); + let workspace = deps.load().cloned(); + let plugins = crate::plugins::load_all_plugins(sym, workspace.as_deref()); // Resolving the workspace means running cargo, so only do it when some // plugin's hook gating actually references a concrete crate (a `depends-on(*)` diff --git a/src/plugins.rs b/src/plugins.rs index 68c63409..62f493d4 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -821,11 +821,48 @@ struct RawCustomPredicate { args: Vec, } +/// `[defaults]` section: opt-outs for the default content added to +/// workspace plugin manifests (and, later, crate-embedded plugins). +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDefaults { + /// Add the default `[[skills]] source.path = "skills"` group. + #[serde(default = "default_skills_flag")] + skills: bool, +} + +fn default_skills_flag() -> bool { + true +} + +impl Default for RawDefaults { + fn default() -> Self { + Self { + skills: default_skills_flag(), + } + } +} + +/// Where a plugin manifest came from, for validation rules that differ by +/// origin: a registry manifest must carry its own `name` and must reference +/// at least one dependency; a workspace-member manifest is already gated by +/// workspace membership, so both are relaxed (the name defaults to the +/// directory name) and default content applies. +enum ManifestOrigin<'a> { + Registry, + WorkspaceMember { dir_name: &'a str }, +} + /// Raw TOML manifest deserialized from a plugin `.toml` file. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawPluginManifest { - name: String, + /// Required for registry plugins; defaults to the directory name for + /// workspace plugins. + name: Option, + /// Default-content opt-outs. Only meaningful for workspace plugins. + #[serde(default)] + defaults: Option, #[serde(default, rename = "depends-on")] depends_on: crate::predicate::DependsOnList, /// Rejected: renamed to `depends-on`. @@ -950,12 +987,16 @@ pub async fn ensure_plugin_sources(sym: &Symposium, update: UpdateLevel) { } } -/// Load all plugins from all configured plugin source directories, -/// discarding load errors with warnings. +/// Load all plugins from all configured plugin source directories plus the +/// active workspace, discarding load errors with warnings. /// -/// Use `load_registry()` instead if you also need standalone skills. -pub fn load_all_plugins(sym: &Symposium) -> Vec { - load_registry(sym).plugins +/// Use `load_registry_with_workspace()` instead if you also need standalone +/// skills. +pub fn load_all_plugins( + sym: &Symposium, + workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, +) -> Vec { + load_registry_impl(sym, workspace).plugins } /// Sync plugin sources. @@ -1150,7 +1191,28 @@ async fn fetch_plugin_source( /// /// Discovers TOML plugin manifests and standalone skill directories, /// then loads both into a `PluginRegistry`. +/// +/// This form loads plugin sources only; workspace-scoped callers use +/// [`load_registry_with_workspace`] to also pick up plugins defined by the +/// active workspace. pub fn load_registry(sym: &Symposium) -> PluginRegistry { + load_registry_impl(sym, None) +} + +/// [`load_registry`] plus the plugins defined by the active workspace (the +/// workspace root and every member directory), stamped as workspace +/// members. `None` (not in a workspace) degrades to plugin sources only. +pub fn load_registry_with_workspace( + sym: &Symposium, + workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, +) -> PluginRegistry { + load_registry_impl(sym, workspace) +} + +fn load_registry_impl( + sym: &Symposium, + workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, +) -> PluginRegistry { let sources = sym.plugin_sources(); let mut plugins = Vec::new(); let mut standalone_skills = Vec::new(); @@ -1201,6 +1263,12 @@ pub fn load_registry(sym: &Symposium) -> PluginRegistry { } } + if let Some(ws) = workspace { + let (ws_plugins, ws_warnings) = workspace_plugins(&ws.root, &ws.members); + plugins.extend(ws_plugins); + warnings.extend(ws_warnings); + } + tracing::debug!( plugins = plugins.len(), standalone_skills = standalone_skills.len(), @@ -1217,6 +1285,81 @@ pub fn load_registry(sym: &Symposium) -> PluginRegistry { } } +/// Display name workspace plugins are attributed to. Parenthesized so it +/// can't collide with a configured plugin-source name. +const WORKSPACE_SOURCE_NAME: &str = "(workspace)"; + +/// Load the plugins defined by the active workspace: the workspace root +/// plus every member package directory. +/// +/// A directory defines a workspace plugin when it has a `SYMPOSIUM.toml` +/// manifest (whose `name` defaults to the directory name) or a `skills/` +/// directory (a manifest-less plugin whose only content is the default +/// skills group). Default content — the `[[skills]] source.path = "skills"` +/// group — is appended unless the manifest opts out with +/// `[defaults] skills = false`. +/// +/// 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, +/// so equal-named skills in different members stay distinct. +pub fn workspace_plugins( + root: &Path, + members: &[PathBuf], +) -> (Vec, Vec) { + let mut seen = std::collections::HashSet::new(); + let mut plugins = Vec::new(); + let mut warnings = Vec::new(); + for dir in std::iter::once(&root.to_path_buf()).chain(members.iter()) { + let dir = fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); + if !seen.insert(dir.clone()) { + continue; + } + match workspace_plugin_for_dir(root, &dir) { + Ok(Some(parsed)) => plugins.push(parsed), + Ok(None) => {} + Err(e) => { + tracing::warn!(dir = %dir.display(), error = %e, "failed to load workspace plugin"); + warnings.push(LoadWarning { + path: dir.join("SYMPOSIUM.toml"), + message: format!("failed to load workspace plugin: {e}"), + }); + } + } + } + (plugins, warnings) +} + +/// Interpret one workspace directory as a plugin, or `None` when the +/// directory defines nothing (no manifest, no `skills/`). +fn workspace_plugin_for_dir(workspace_root: &Path, dir: &Path) -> Result> { + let manifest_path = dir.join("SYMPOSIUM.toml"); + let raw: RawPluginManifest = if manifest_path.is_file() { + toml::from_str(&fs::read_to_string(&manifest_path)?)? + } else if dir.join(CRATE_DEFAULT_SKILLS_PATH).is_dir() { + // Bare convention: a `skills/` directory with no manifest is an + // all-defaults plugin. + toml::from_str("").expect("empty manifest parses") + } else { + return Ok(None); + }; + + let dir_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("workspace"); + let plugin = validate_manifest(raw, ManifestOrigin::WorkspaceMember { dir_name }) + .with_context(|| format!("validating `{}`", manifest_path.display()))?; + + Ok(Some(ParsedPlugin { + path: manifest_path, + plugin, + source_name: WORKSPACE_SOURCE_NAME.to_string(), + source_dir: workspace_root.to_path_buf(), + workspace_member: true, + })) +} + /// Scan a plugin source directory for TOML plugin manifests and standalone skills. /// /// Discovery rules: @@ -1511,7 +1654,7 @@ pub fn load_plugin( ) -> Result { let content = fs::read_to_string(manifest_path)?; let manifest: RawPluginManifest = toml::from_str(&content)?; - let plugin = validate_manifest(manifest) + let plugin = validate_manifest(manifest, ManifestOrigin::Registry) .with_context(|| format!("validating `{}`", manifest_path.display()))?; Ok(ParsedPlugin { path: manifest_path.to_path_buf(), @@ -1530,7 +1673,31 @@ pub fn load_plugin( /// declaration order. Inline references on installations and hooks are /// promoted into synthetic entries appended to the same list so that every /// validated reference is a plain name. -fn validate_manifest(manifest: RawPluginManifest) -> Result { +fn validate_manifest( + mut manifest: RawPluginManifest, + origin: ManifestOrigin<'_>, +) -> Result { + let name = match (manifest.name.take(), &origin) { + (Some(n), _) => n, + (None, ManifestOrigin::WorkspaceMember { dir_name }) => dir_name.to_string(), + (None, ManifestOrigin::Registry) => bail!("plugin manifest is missing `name`"), + }; + match &origin { + ManifestOrigin::Registry => { + if manifest.defaults.is_some() { + bail!("`[defaults]` is only supported in workspace plugin manifests"); + } + } + ManifestOrigin::WorkspaceMember { .. } => { + let defaults = manifest.defaults.take().unwrap_or_default(); + if defaults.skills { + let group: RawSkillGroup = + toml::from_str(r#"source.path = "skills""#).expect("static default group"); + manifest.skills.push(group); + } + } + } + let mut names: std::collections::BTreeSet = std::collections::BTreeSet::new(); for entry in &manifest.installations { if !names.insert(entry.name.clone()) { @@ -1617,31 +1784,33 @@ fn validate_manifest(manifest: RawPluginManifest) -> Result { .map(RawPluginMcpServer::validate) .collect::>>()?; - // Every plugin must reference at least one dependency (or custom - // predicate) somewhere — at the plugin, skill-group, hook, or MCP-server - // level — via `depends-on` or a `depends-on(...)` predicate. Otherwise it - // would never apply to any project. - let has_custom_predicate = predicates - .predicates - .iter() - .any(|p| matches!(p, crate::predicate::Predicate::Custom { .. })); - let mentions_dep = has_custom_predicate - || predicates.mentions_dep() - || skills.iter().any(|g| g.predicates.mentions_dep()) - || hooks.iter().any(|h| h.predicates.mentions_dep()) - || mcp_servers.iter().any(|m| m.predicates.mentions_dep()); - if !mentions_dep { - bail!( - "plugin `{}` references no dependency — add `depends-on = [...]` or a \ - `depends-on(...)` predicate at the plugin, `[[skills]]`, or `[[mcp_servers]]` level", - manifest.name - ); + // Every registry plugin must reference at least one dependency (or + // custom predicate) somewhere — at the plugin, skill-group, hook, or + // MCP-server level — via `depends-on`, a `depends-on(...)` predicate, or + // a custom predicate. Otherwise it would never apply to any project. + // Workspace plugins are exempt: being in the workspace is their gate. + if matches!(origin, ManifestOrigin::Registry) { + let has_custom_predicate = predicates + .predicates + .iter() + .any(|p| matches!(p, crate::predicate::Predicate::Custom { .. })); + let mentions_dep = has_custom_predicate + || predicates.mentions_dep() + || skills.iter().any(|g| g.predicates.mentions_dep()) + || hooks.iter().any(|h| h.predicates.mentions_dep()) + || mcp_servers.iter().any(|m| m.predicates.mentions_dep()); + if !mentions_dep { + bail!( + "plugin `{name}` references no dependency — add `depends-on = [...]` or a \ + `depends-on(...)` predicate at the plugin, `[[skills]]`, or `[[mcp_servers]]` level" + ); + } } validate_skill_groups(&predicates, &skills)?; Ok(Plugin { - name: manifest.name, + name, predicates, installations, hooks, @@ -1845,7 +2014,7 @@ mod tests { fn from_str(s: &str) -> Result { let manifest: RawPluginManifest = toml::from_str(s)?; - validate_manifest(manifest) + validate_manifest(manifest, ManifestOrigin::Registry) } const SAMPLE: &str = indoc! {r#" @@ -2487,6 +2656,102 @@ mod tests { assert!(!plugin_version.applies(&mut ctx(&workspace_crates))); } + #[test] + fn workspace_plugins_interpret_member_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Root: manifest without a name — name falls back to the dir name, + // default skills group is appended. + std::fs::write(root.join("SYMPOSIUM.toml"), "").unwrap(); + + // member-bare: no manifest, but a skills/ dir — bare convention. + let bare = root.join("member-bare"); + std::fs::create_dir_all(bare.join("skills")).unwrap(); + + // member-optout: manifest opting out of default content. + let optout = root.join("member-optout"); + std::fs::create_dir_all(&optout).unwrap(); + std::fs::write( + optout.join("SYMPOSIUM.toml"), + indoc! {r#" + name = "explicit-name" + + [defaults] + skills = false + "#}, + ) + .unwrap(); + + // member-empty: neither manifest nor skills/ — defines nothing. + let empty = root.join("member-empty"); + std::fs::create_dir_all(&empty).unwrap(); + + let members = vec![bare.clone(), optout.clone(), empty.clone()]; + let (plugins, warnings) = workspace_plugins(root, &members); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let names: Vec<&str> = plugins.iter().map(|p| p.plugin.name.as_str()).collect(); + let root_name = root.file_name().unwrap().to_str().unwrap(); + assert_eq!(names, vec![root_name, "member-bare", "explicit-name"]); + + for parsed in &plugins { + assert!(parsed.workspace_member); + assert_eq!(parsed.source_name, WORKSPACE_SOURCE_NAME); + assert_eq!(parsed.source_dir, root); + } + + // Root and bare member each get exactly the default skills group. + assert_eq!(plugins[0].plugin.skills.len(), 1); + assert_eq!( + plugins[1].plugin.skills[0].source, + PluginSource::Path(PathBuf::from("skills")) + ); + // The opt-out member has no groups. + assert!(plugins[2].plugin.skills.is_empty()); + } + + #[test] + fn workspace_manifest_may_omit_dependency_gate() { + // A registry manifest without any depends-on is rejected; the same + // manifest is fine as a workspace plugin (membership is the gate). + let manifest: RawPluginManifest = toml::from_str(indoc! {r#" + name = "gateless" + + [[skills]] + source.path = "extra-skills" + "#}) + .unwrap(); + let err = validate_manifest(manifest, ManifestOrigin::Registry).unwrap_err(); + assert!(err.to_string().contains("references no dependency")); + + let manifest: RawPluginManifest = toml::from_str(indoc! {r#" + name = "gateless" + + [[skills]] + source.path = "extra-skills" + "#}) + .unwrap(); + let plugin = + validate_manifest(manifest, ManifestOrigin::WorkspaceMember { dir_name: "d" }).unwrap(); + // Explicit group plus the appended default group. + assert_eq!(plugin.skills.len(), 2); + } + + #[test] + fn registry_manifest_rejects_defaults_section() { + let manifest: RawPluginManifest = toml::from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [defaults] + skills = false + "#}) + .unwrap(); + let err = validate_manifest(manifest, ManifestOrigin::Registry).unwrap_err(); + assert!(err.to_string().contains("[defaults]")); + } + #[test] fn parsed_plugin_applies_stamps_workspace_member() { let plugin = Plugin { diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 29f5f80f..79f051c6 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -96,11 +96,11 @@ pub async fn dispatch_external( .context("subcommand name must be valid UTF-8")?; let forwarded = argv.collect::>(); - let registry = plugins::load_registry(sym); let mut deps = sym.workspace_deps(cwd); - let workspace = deps.crates(); + let workspace = deps.load().cloned(); + let registry = plugins::load_registry_with_workspace(sym, workspace.as_deref()); - let (plugin, subcommand) = find_subcommand(®istry, name, workspace)? + let (plugin, subcommand) = find_subcommand(®istry, name, deps.crates())? .with_context(|| format!("no plugin defines subcommand `{name}`"))?; let installation = plugin diff --git a/src/sync.rs b/src/sync.rs index c37169ce..a0e5dd28 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -301,11 +301,12 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel .ok_or_else(|| anyhow::anyhow!("not in a Rust workspace"))?; let project_root = loaded.root.clone(); let workspace: Vec<_> = loaded.crates.clone(); + let loaded = loaded.clone(); let debounce = Duration::from_secs(sym.config.sync_debounce_secs); tracing::debug!(root = %project_root.display(), "resolved workspace root"); - // Load plugin registry - let registry = plugins::load_registry(sym); + // Load plugin registry (registry sources + workspace plugins) + let registry = plugins::load_registry_with_workspace(sym, Some(&loaded)); for warning in ®istry.warnings { tracing::info!( diff --git a/symposium-sdk/src/workspace.rs b/symposium-sdk/src/workspace.rs index e6ad4569..df9bf411 100644 --- a/symposium-sdk/src/workspace.rs +++ b/symposium-sdk/src/workspace.rs @@ -37,7 +37,7 @@ impl WorkspaceCrate { } } -/// The resolved workspace: root path + dependency list. +/// The resolved workspace: root path + dependency list + member directories. #[non_exhaustive] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoadedWorkspace { @@ -45,14 +45,19 @@ pub struct LoadedWorkspace { pub root: PathBuf, /// Direct dependencies of all workspace members. pub crates: Vec, + /// Manifest directories of the workspace's member packages. Backs + /// workspace-plugin discovery (a member directory may define a plugin). + pub members: Vec, } -/// On-disk cache format. +/// On-disk cache format. Adding a field is a compatible cache bump: an old +/// cache file fails to deserialize, reads as a miss, and is rebuilt. #[derive(Serialize, Deserialize)] struct DiskCache { lock_mtime: u64, root: PathBuf, crates: Vec, + members: Vec, } /// In-process cache for workspace dependency resolution. @@ -156,6 +161,7 @@ impl WorkspaceDeps { Some(LoadedWorkspace { root: cached.root, crates: cached.crates, + members: cached.members, }) } @@ -172,6 +178,7 @@ impl WorkspaceDeps { lock_mtime: mtime, root: loaded.root.clone(), crates: loaded.crates.clone(), + members: loaded.members.clone(), }; let _ = fs::create_dir_all(ws_cache_dir); @@ -289,5 +296,18 @@ fn load_workspace(cwd: &Path, cargo_path: Option<&Path>) -> Option = metadata + .packages + .iter() + .filter(|p| ws_members.contains(&p.id)) + .filter_map(|p| p.manifest_path.parent().map(|dir| dir.into())) + .collect(); + members.sort(); + members.dedup(); + + Some(LoadedWorkspace { + root, + crates, + members, + }) } diff --git a/tests/fixtures/workspace-plugin0/Cargo.toml b/tests/fixtures/workspace-plugin0/Cargo.toml new file mode 100644 index 00000000..43692309 --- /dev/null +++ b/tests/fixtures/workspace-plugin0/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "test-workspace" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0" diff --git a/tests/fixtures/workspace-plugin0/skills/ws-hello/SKILL.md b/tests/fixtures/workspace-plugin0/skills/ws-hello/SKILL.md new file mode 100644 index 00000000..105e1868 --- /dev/null +++ b/tests/fixtures/workspace-plugin0/skills/ws-hello/SKILL.md @@ -0,0 +1,8 @@ +--- +name: ws-hello +description: Workspace-defined skill installed via the bare skills/ convention. +--- + +# ws-hello + +A skill defined by the workspace itself. diff --git a/tests/fixtures/workspace-plugin0/src/lib.rs b/tests/fixtures/workspace-plugin0/src/lib.rs new file mode 100644 index 00000000..eb80827a --- /dev/null +++ b/tests/fixtures/workspace-plugin0/src/lib.rs @@ -0,0 +1 @@ +// Empty test crate. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 9f1a7721..e543d2a0 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -134,6 +134,32 @@ async fn init_creates_config() { .unwrap(); } +/// A workspace with a bare `skills/` directory defines a workspace plugin; +/// `sync` installs its skills without any configured plugin source or +/// dependency gate. +#[tokio::test] +async fn sync_installs_workspace_plugin_skills() { + with_fixture( + TestMode::SimulationOnly, + &["workspace-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skills_dir = workspace_root.join(".claude/skills"); + let skill_dir = find_installed_skill(&skills_dir, "ws-hello"); + assert!( + skill_dir.join(".symposium").exists(), + "workspace skill should install as symposium-managed" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `sync` installs skill files into the agent's expected location. #[tokio::test] async fn sync_installs_skills() { From 797efc6d0b5f4970ba46e4abbec6d4cb3962f861 Mon Sep 17 00:00:00 2001 From: jackh726 Date: Sun, 12 Jul 2026 20:21:57 +0000 Subject: [PATCH 3/3] Unify .agents/skills into the plugin pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents-syncing feature predates workspace plugins: a bespoke loop at the end of sync copied each dir under /.agents/skills/ into every agent's skill directory, with its own conflict handling and none of the pipeline's origin tracking, dedup, or disambiguation. Per the RFD's default-content section, .agents/skills is now the second default skill group on every workspace plugin, gated by workspace-member() — maintainer skills apply while working in the workspace and never install for dependents of a published crate. The bespoke loop, discover_user_authored_skills, and the SkillPropagated report event are gone; workspace root *and member* .agents/skills (new) flow through ordinary skill resolution. The agents-syncing config knob now gates appending that default group, and a bare .agents/skills/ directory counts for the manifest-less workspace-plugin convention. Two marker guards make .agents/skills safe as both a source and (for vendor-neutral agents) a destination: skill discovery skips .symposium-marked directories, so copies symposium installed there are never re-discovered as sources; and a skill whose source already sits at an agent's install slot is skipped for that agent, so the suffixed-name fallback can't manufacture a duplicate next to the source. Workspace skills stay as informal as the legacy blind copy allowed: the SKILL.md frontmatter block and its name/description fields are optional for them, with the name defaulting to the skill directory's name. The leniency is provenance-keyed — SkillGroup carries a workspace_member stamp set during manifest validation for workspace-member origins — so registry and crate skills keep the agentskills.io contract. --- md/design/module-structure.md | 4 +- md/design/sync-agent-flow.md | 2 +- md/reference/configuration.md | 24 ++-- md/workspace-skills.md | 24 +++- src/plugins.rs | 117 ++++++++++++--- src/report.rs | 10 -- src/skills.rs | 133 ++++++++++++++++-- src/sync.rs | 106 ++------------ .../fixtures/member-agents-skills0/Cargo.toml | 7 + .../fixtures/member-agents-skills0/src/lib.rs | 1 + .../tool/.agents/skills/plain-notes/SKILL.md | 1 + .../.agents/skills/tool-maintainer/SKILL.md | 6 + .../member-agents-skills0/tool/Cargo.toml | 4 + .../member-agents-skills0/tool/src/lib.rs | 1 + tests/init_sync.rs | 37 ++++- 15 files changed, 325 insertions(+), 152 deletions(-) create mode 100644 tests/fixtures/member-agents-skills0/Cargo.toml create mode 100644 tests/fixtures/member-agents-skills0/src/lib.rs create mode 100644 tests/fixtures/member-agents-skills0/tool/.agents/skills/plain-notes/SKILL.md create mode 100644 tests/fixtures/member-agents-skills0/tool/.agents/skills/tool-maintainer/SKILL.md create mode 100644 tests/fixtures/member-agents-skills0/tool/Cargo.toml create mode 100644 tests/fixtures/member-agents-skills0/tool/src/lib.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 06077620..7093e71d 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -18,7 +18,7 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `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)`, shared by both the plugin-skill and user-authored-skill code paths. 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. +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). @@ -33,7 +33,7 @@ Scans configured plugin source directories for TOML manifests and parses them in Also discovers standalone `SKILL.md` files not wrapped in a plugin. Returns a `PluginRegistry` — a table of contents that doesn't load skill content. -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, the every-plugin-must-mention-a-dependency rule is waived, and the default `[[skills]] source.path = "skills"` group is appended unless `[defaults] skills = false`) or a bare `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. +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, the every-plugin-must-mention-a-dependency rule is waived, 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 diff --git a/md/design/sync-agent-flow.md b/md/design/sync-agent-flow.md index 076cb3cd..627461fe 100644 --- a/md/design/sync-agent-flow.md +++ b/md/design/sync-agent-flow.md @@ -17,7 +17,7 @@ Scans workspace dependencies, installs applicable skills into agent directories, - Drop a `.symposium` marker file into each installed skill directory so future syncs (and other tools) can recognize it as symposium-managed. - For every skill directory symposium creates along the way (the skill directory itself or its `skills/` parent), write a `.gitignore` containing a single `*` so symposium-managed files stay out of version control. -6. **Propagate user-authored skills (agents-syncing)** — if `agents-syncing` is enabled in the user config, mirror skills the user placed in `/.agents/skills/` into each configured agent's own skill directory. A skill is "user-authored" when its directory contains `SKILL.md` but lacks the `.symposium` marker (symposium never writes markers into source skills). Propagated destinations receive the same marker and `*` `.gitignore` as plugin-installed skills, so they participate in the normal stale-skill reap: removing the source — or disabling `agents-syncing` — causes the destinations to be cleaned up on the next sync. A destination directory without a marker is user-managed and is never overwritten. +6. **Workspace `.agents/skills/` (agents-syncing)** — not a separate step: when `agents-syncing` is enabled, each workspace plugin carries a `workspace-member()`-gated default group for `.agents/skills/`, so maintainer skills resolve and install through the same pipeline as everything else. Two marker guards make `.agents/skills/` safe as both a source and (for vendor-neutral agents) a destination: discovery skips `.symposium`-marked directories (installed copies are never sources), and a skill whose source already sits at an agent's install slot is skipped for that agent (no self-copy, no suffixed duplicate). 7. **Reap stale skills** — across every known agent's skills parent directory, remove any subdirectory that contains the `.symposium` marker but wasn't installed this sync. Directories without the marker (user-managed) are left untouched. diff --git a/md/reference/configuration.md b/md/reference/configuration.md index 950d29bf..c30f8702 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -37,24 +37,30 @@ path = "my-plugins" | Key | Type | Default | Description | |-----|------|---------|-------------| | `auto-sync` | bool | `true` | Automatically run `cargo agents sync` during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention. | -| `agents-syncing` | bool | `true` | Propagate user-authored skills from `.agents/skills/` into the per-agent skill directories of any configured agent that does not natively use `.agents/skills/` (such as `.claude/skills/` or `.kiro/skills/`). Skills that symposium itself installed — identified by the `.symposium` marker file — are not propagated. See [Workspace skills](../workspace-skills.md) for the user-guide overview, or [Agents syncing](#agents-syncing-mirror-user-authored-skills) below for details. | +| `agents-syncing` | bool | `true` | Include each workspace plugin's `.agents/skills/` default skill group, so skills you author there install into every configured agent's skill directory (such as `.claude/skills/` or `.kiro/skills/`). Skills that symposium itself installed — identified by the `.symposium` marker file — are never treated as sources. See [Workspace skills](../workspace-skills.md) for the user-guide overview, or [Agents syncing](#agents-syncing-mirror-user-authored-skills) below for details. | | `hook-scope` | string | `"global"` | Where agent hooks are installed. `"global"` writes to the user's home directory (e.g., `~/`). `"project"` writes to the project directory, keeping hooks local to the workspace. | | `auto-update` | string | `"on"` | Controls automatic update behavior. `"off"` disables update checks entirely. `"warn"` checks the registry (at most once per 24 hours) and prints a message when a newer version is available. `"on"` automatically installs the update via `cargo install` and re-executes the command with the new binary. | ### Agents syncing: mirror user-authored skills -Agents such as Copilot, Gemini, Codex, Goose, and OpenCode all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, `cargo agents sync` mirrors any skill that *you* put in `.agents/skills/` into each configured agent's own skill directory, so a single authored copy is visible to every agent. +Agents such as Copilot, Gemini, Codex, Goose, and OpenCode all read skills from the vendor-neutral `.agents/skills/` directory, but Claude Code and Kiro use their own paths (`.claude/skills/` and `.kiro/skills/`). When `agents-syncing` is enabled, every [workspace plugin](../workspace-skills.md) — the workspace root and each member directory — carries a second default skill group, gated by the `workspace-member()` predicate: -A skill is treated as user-authored when its directory contains `SKILL.md` but does *not* contain the `.symposium` marker. Symposium never writes a marker into source skills, so the distinction between "user content" and "a copy symposium made" is unambiguous. +```toml +[[skills]] +predicates = ["workspace-member()"] +source.path = ".agents/skills" +``` + +Skills you author in `.agents/skills/` therefore flow through the same pipeline as every other skill and install into each configured agent's own skill directory, so a single authored copy is visible to every agent. The `workspace-member()` gate is what keeps these maintainer skills from installing for *dependents* of a published crate — they apply only while working in the workspace itself. + +Two `.symposium`-marker rules keep sources and copies distinct (symposium never writes a marker into a source, only into directories it installs): -Propagated destinations receive the same `.symposium` marker and `*` `.gitignore` that plugin-installed skills get, which means: +- Skill discovery skips marker-bearing directories, so copies symposium installed into `.agents/skills/` (for agents that read it natively) are never re-discovered as sources. +- For an agent whose skill directory *is* `.agents/skills/`, a skill whose source already sits at its install slot is left in place — nothing is copied. -- Updates to the source (`.agents/skills//`) are re-copied on each sync. -- Removing the source removes the propagated copies on the next sync (via the normal stale-skill reap). -- Disabling `agents-syncing = false` also removes previously propagated copies on the next sync. -- A pre-existing, user-managed file in the target directory (no marker) is never overwritten. +Installed copies receive the same marker and `*` `.gitignore` that plugin-installed skills get, which means: updates to the source are re-copied on each sync; removing the source removes the copies on the next sync (the normal stale-skill reap); disabling `agents-syncing = false` does the same; and a pre-existing user-managed directory in a target is never overwritten (the skill installs under a suffixed name instead). -When the only configured agents use `.agents/skills/` directly, the feature is a no-op (the source and target are the same directory). +Because these are real skills now, `SKILL.md` frontmatter must carry `name` and `description` like any other [skill definition](./skill-definition.md). ### Hook scope: control whether Symposium activates in all projects or only those you select diff --git a/md/workspace-skills.md b/md/workspace-skills.md index 0fdb6290..f41ff473 100644 --- a/md/workspace-skills.md +++ b/md/workspace-skills.md @@ -1,8 +1,8 @@ # Workspace skills -In addition to adding skills based on your dependencies, Symposium will also install skills your workspace defines for itself, and copy any additional skills found in `.agents/skills` into the directory appropriate for your configured agent(s). +In addition to adding skills based on your dependencies, Symposium will also install skills your workspace defines for itself: skills found in `skills/` or `.agents/skills` in the workspace root or any member crate install into the directory appropriate for your configured agent(s). -This "skill-syncing" feature allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users will have the skills synced to `.claude/skills`). +This allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users will have the skills synced to `.claude/skills`). The default skill location therefore varies depending on the intended audience: @@ -17,17 +17,33 @@ The workspace root and every member crate directory can define a *workspace plug Workspace plugins are always active while you work in that workspace — no plugin source configuration or `depends-on` gate is needed. A `skills/` directory in a member crate serves double duty: it installs for everyone working in the workspace *and*, once published, for projects that depend on the crate. +Every workspace plugin gets two default skill groups (unless disabled with `[defaults] skills = false`): + +```toml +[[skills]] +source.path = "skills" + +[[skills]] +predicates = ["workspace-member()"] +source.path = ".agents/skills" +``` + +The second group is how the `.agents/skills` convention works: it is gated by the [`workspace-member()` predicate](./reference/predicates.md), so maintainer skills apply while working in the workspace but never install for dependents of a published crate. (The group can also be turned off globally with `agents-syncing = false` in the [user config](./reference/configuration.md).) + Two details specific to workspace manifests: - `name` may be omitted; it defaults to the directory name. -- The default `skills/` group can be disabled with `[defaults] skills = false`. Components that should apply only to people developing the workspace (not to dependents of a published crate) can be gated with the [`workspace-member()` predicate](./reference/predicates.md). +## Informal skills + +Workspace skills are your own notes, so the [skill frontmatter](./reference/skill-definition.md) requirements are relaxed: the `name` and `description` fields — and the frontmatter block itself — are optional. A `SKILL.md` that is just plain markdown works; its name defaults to the directory that contains it. Skills distributed through a registry or a published crate still require the full frontmatter. + ## Recommended git setup We recommend you commit your `.agents/skills` or `skills/` into the repository. Symposium installs a `.gitignore` file into every skill that it creates, so automatically copied and installed skills should not dirty your git status. ## Pre-existing files -Symposium never touches skills in `.claude/skills/`, `.kiro/skills/` etc. that it did not put there itself. If you previously hand-wrote a skill with the same name as one in `.agents/skills/`, propagation is skipped for that name and a warning is printed — your existing file stays in place. +Symposium never touches skills in `.claude/skills/`, `.kiro/skills/` etc. that it did not put there itself. If you previously hand-wrote a skill with the same name as one in `.agents/skills/`, your existing directory stays in place and the workspace skill installs under a suffixed name (`-`) alongside it. diff --git a/src/plugins.rs b/src/plugins.rs index 62f493d4..7790b62f 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -150,6 +150,11 @@ impl RawPluginSource { /// Default subdirectory used when no `[package.metadata.symposium]` is present. pub const CRATE_DEFAULT_SKILLS_PATH: &str = "skills"; +/// Default location for skills that apply while *maintaining* a workspace +/// (as opposed to using its published crates): the `workspace-member()`-gated +/// second default skill group. +pub const AGENTS_SKILLS_PATH: &str = ".agents/skills"; + impl serde::Serialize for PluginSource { fn serialize(&self, serializer: S) -> Result { use serde::ser::SerializeMap; @@ -185,6 +190,12 @@ pub struct SkillGroup { /// Remote source for skills. #[serde(default)] pub source: PluginSource, + /// The group is defined by a workspace-member plugin. Provenance, stamped + /// during manifest validation, not manifest content: workspace skills are + /// informal, so their SKILL.md `name` defaults to the skill directory's + /// name and `description` (with the frontmatter itself) is optional. + #[serde(skip)] + pub workspace_member: bool, } #[derive(Debug, Deserialize)] @@ -211,6 +222,7 @@ impl RawSkillGroup { .map(RawPluginSource::validate) .transpose()? .unwrap_or_default(), + workspace_member: false, }) } } @@ -850,7 +862,12 @@ impl Default for RawDefaults { /// directory name) and default content applies. enum ManifestOrigin<'a> { Registry, - WorkspaceMember { dir_name: &'a str }, + WorkspaceMember { + dir_name: &'a str, + /// Append the `workspace-member()`-gated `.agents/skills` default + /// group (the `agents-syncing` config knob). + agents_skills: bool, + }, } /// Raw TOML manifest deserialized from a plugin `.toml` file. @@ -1264,7 +1281,8 @@ fn load_registry_impl( } if let Some(ws) = workspace { - let (ws_plugins, ws_warnings) = workspace_plugins(&ws.root, &ws.members); + let (ws_plugins, ws_warnings) = + workspace_plugins(&ws.root, &ws.members, sym.config.agents_syncing); plugins.extend(ws_plugins); warnings.extend(ws_warnings); } @@ -1306,6 +1324,7 @@ const WORKSPACE_SOURCE_NAME: &str = "(workspace)"; pub fn workspace_plugins( root: &Path, members: &[PathBuf], + agents_skills: bool, ) -> (Vec, Vec) { let mut seen = std::collections::HashSet::new(); let mut plugins = Vec::new(); @@ -1315,7 +1334,7 @@ pub fn workspace_plugins( if !seen.insert(dir.clone()) { continue; } - match workspace_plugin_for_dir(root, &dir) { + match workspace_plugin_for_dir(root, &dir, agents_skills) { Ok(Some(parsed)) => plugins.push(parsed), Ok(None) => {} Err(e) => { @@ -1332,13 +1351,19 @@ pub fn workspace_plugins( /// Interpret one workspace directory as a plugin, or `None` when the /// directory defines nothing (no manifest, no `skills/`). -fn workspace_plugin_for_dir(workspace_root: &Path, dir: &Path) -> Result> { +fn workspace_plugin_for_dir( + workspace_root: &Path, + dir: &Path, + agents_skills: bool, +) -> Result> { let manifest_path = dir.join("SYMPOSIUM.toml"); + let bare_convention = dir.join(CRATE_DEFAULT_SKILLS_PATH).is_dir() + || (agents_skills && dir.join(AGENTS_SKILLS_PATH).is_dir()); let raw: RawPluginManifest = if manifest_path.is_file() { toml::from_str(&fs::read_to_string(&manifest_path)?)? - } else if dir.join(CRATE_DEFAULT_SKILLS_PATH).is_dir() { - // Bare convention: a `skills/` directory with no manifest is an - // all-defaults plugin. + } else if bare_convention { + // Bare convention: a `skills/` (or `.agents/skills/`) directory with + // no manifest is an all-defaults plugin. toml::from_str("").expect("empty manifest parses") } else { return Ok(None); @@ -1348,8 +1373,14 @@ fn workspace_plugin_for_dir(workspace_root: &Path, dir: &Path) -> Result Result { let name = match (manifest.name.take(), &origin) { (Some(n), _) => n, - (None, ManifestOrigin::WorkspaceMember { dir_name }) => dir_name.to_string(), + (None, ManifestOrigin::WorkspaceMember { dir_name, .. }) => dir_name.to_string(), (None, ManifestOrigin::Registry) => bail!("plugin manifest is missing `name`"), }; match &origin { @@ -1688,12 +1719,20 @@ fn validate_manifest( bail!("`[defaults]` is only supported in workspace plugin manifests"); } } - ManifestOrigin::WorkspaceMember { .. } => { + ManifestOrigin::WorkspaceMember { agents_skills, .. } => { let defaults = manifest.defaults.take().unwrap_or_default(); if defaults.skills { let group: RawSkillGroup = toml::from_str(r#"source.path = "skills""#).expect("static default group"); manifest.skills.push(group); + if *agents_skills { + let group: RawSkillGroup = toml::from_str(indoc::indoc! {r#" + predicates = ["workspace-member()"] + source.path = ".agents/skills" + "#}) + .expect("static default group"); + manifest.skills.push(group); + } } } } @@ -1773,11 +1812,16 @@ fn validate_manifest( reject_crates_field(&manifest.crates)?; let predicates = crate::predicate::PredicateSet::merged(Some(manifest.depends_on), manifest.predicates); - let skills = manifest + let mut skills = manifest .skills .into_iter() .map(RawSkillGroup::validate) .collect::>>()?; + if matches!(origin, ManifestOrigin::WorkspaceMember { .. }) { + for group in &mut skills { + group.workspace_member = true; + } + } let mcp_servers = manifest .mcp_servers .into_iter() @@ -2688,7 +2732,7 @@ mod tests { std::fs::create_dir_all(&empty).unwrap(); let members = vec![bare.clone(), optout.clone(), empty.clone()]; - let (plugins, warnings) = workspace_plugins(root, &members); + let (plugins, warnings) = workspace_plugins(root, &members, true); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); let names: Vec<&str> = plugins.iter().map(|p| p.plugin.name.as_str()).collect(); @@ -2699,18 +2743,47 @@ mod tests { assert!(parsed.workspace_member); assert_eq!(parsed.source_name, WORKSPACE_SOURCE_NAME); assert_eq!(parsed.source_dir, root); + // Groups carry the provenance too: workspace skills load with + // lenient frontmatter rules. + assert!(parsed.plugin.skills.iter().all(|g| g.workspace_member)); } - // Root and bare member each get exactly the default skills group. - assert_eq!(plugins[0].plugin.skills.len(), 1); + // Root and bare member each get the two default groups: `skills/` + // and the `workspace-member()`-gated `.agents/skills`. + assert_eq!(plugins[0].plugin.skills.len(), 2); assert_eq!( plugins[1].plugin.skills[0].source, PluginSource::Path(PathBuf::from("skills")) ); + assert_eq!( + plugins[1].plugin.skills[1].source, + PluginSource::Path(PathBuf::from(".agents/skills")) + ); + assert!(!plugins[1].plugin.skills[1].predicates.predicates.is_empty()); // The opt-out member has no groups. assert!(plugins[2].plugin.skills.is_empty()); } + #[test] + fn agents_syncing_disabled_omits_agents_skills_group() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join("skills")).unwrap(); + // A member defined only by `.agents/skills/`. + let member = root.join("member"); + std::fs::create_dir_all(member.join(".agents/skills")).unwrap(); + let members = vec![member.clone()]; + + let (plugins, _) = workspace_plugins(root, &members, true); + let names: Vec<&str> = plugins.iter().map(|p| p.plugin.name.as_str()).collect(); + assert!(names.contains(&"member"), "{names:?}"); + + let (plugins, _) = workspace_plugins(root, &members, false); + let names: Vec<&str> = plugins.iter().map(|p| p.plugin.name.as_str()).collect(); + assert!(!names.contains(&"member"), "{names:?}"); + assert_eq!(plugins[0].plugin.skills.len(), 1); + } + #[test] fn workspace_manifest_may_omit_dependency_gate() { // A registry manifest without any depends-on is rejected; the same @@ -2732,10 +2805,16 @@ mod tests { source.path = "extra-skills" "#}) .unwrap(); - let plugin = - validate_manifest(manifest, ManifestOrigin::WorkspaceMember { dir_name: "d" }).unwrap(); - // Explicit group plus the appended default group. - assert_eq!(plugin.skills.len(), 2); + let plugin = validate_manifest( + manifest, + ManifestOrigin::WorkspaceMember { + dir_name: "d", + agents_skills: true, + }, + ) + .unwrap(); + // Explicit group plus the two appended default groups. + assert_eq!(plugin.skills.len(), 3); } #[test] diff --git a/src/report.rs b/src/report.rs index 6aa9dcca..4768478e 100644 --- a/src/report.rs +++ b/src/report.rs @@ -79,13 +79,6 @@ pub enum ReportEvent { /// A hook was registered for an agent. HookRegistered { agent: String, hook: String }, - /// A user-authored skill was propagated to an agent. - SkillPropagated { - skill: String, - agent: String, - dest: String, - }, - /// An MCP server was registered for an agent. McpServerRegistered { agent: String, server: String }, @@ -210,9 +203,6 @@ impl ReportEvent { Self::SkillRemoved { path } => { format!("➖ removed {path}") } - Self::SkillPropagated { skill, agent, dest } => { - format!("✅ propagated skill {skill} for {agent} → {dest}") - } Self::HookRegistered { agent, hook } => { format!("🟢 {hook}: hooks registered for {agent}") } diff --git a/src/skills.rs b/src/skills.rs index ac531ffe..779efe7b 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -651,7 +651,15 @@ pub(crate) fn discover_skills(skills_dir: &Path, group: &SkillGroup) -> Vec) { + if dir.join(crate::sync::MARKER_FILE).exists() { + return; + } let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return, @@ -708,12 +716,27 @@ pub fn load_standalone_skill(skill_md_path: &Path) -> Result { /// A skill should have `depends-on` at either the skill level or /// the group level (or both). If neither provides it, a warning is logged /// but loading succeeds (the skill simply won't match any dependency query). +/// +/// Workspace-member groups load leniently: the frontmatter (and its `name` +/// and `description` fields) is optional — `name` falls back to the skill +/// directory's name. Workspace skills are the maintainers' own informal +/// notes; the agentskills.io contract applies to published skills. fn load_skill(skill_md_path: &Path, group: &SkillGroup) -> Result { let content = std::fs::read_to_string(skill_md_path) .with_context(|| format!("failed to read {}", skill_md_path.display()))?; - let fm = parse_frontmatter(&content) - .with_context(|| format!("failed to parse frontmatter in {}", skill_md_path.display()))?; + let fm = if group.workspace_member && !content.trim_start().starts_with("---") { + RawFrontmatter { + fields: BTreeMap::new(), + depends_on: None, + predicates: None, + body: content, + } + } else { + parse_frontmatter(&content).with_context(|| { + format!("failed to parse frontmatter in {}", skill_md_path.display()) + })? + }; let mut frontmatter = fm.fields; @@ -724,24 +747,37 @@ fn load_skill(skill_md_path: &Path, group: &SkillGroup) -> Result { *name = unquoted.to_string(); } + if group.workspace_member + && !frontmatter.contains_key("name") + && let Some(dir_name) = skill_md_path + .parent() + .and_then(|dir| dir.file_name()) + .and_then(|name| name.to_str()) + { + frontmatter.insert("name".to_string(), dir_name.to_string()); + } + let name = frontmatter .get("name") .context("SKILL.md frontmatter missing required `name` field")?; // Validate description per agentskills.io spec // (https://agentskills.io/specification.md): required, non-empty, max 1024 chars. - let desc = frontmatter - .get("description") - .context("SKILL.md frontmatter missing required `description` field")?; - let trimmed_desc = desc.trim(); - if trimmed_desc.is_empty() { - bail!("SKILL.md `description` must not be empty"); - } - if trimmed_desc.len() > 1024 { - bail!( - "SKILL.md `description` exceeds 1024 characters ({} chars)", - trimmed_desc.len() - ); + match frontmatter.get("description") { + None if group.workspace_member => {} + None => bail!("SKILL.md frontmatter missing required `description` field"), + Some(desc) => { + let trimmed_desc = desc.trim(); + if trimmed_desc.is_empty() { + bail!("SKILL.md `description` must not be empty"); + } + if trimmed_desc.len() > 1024 { + bail!( + "SKILL.md `description` exceeds 1024 characters ({} chars)", + trimmed_desc.len() + ); + } + } } // Merge the skill-level `depends-on` (dependency atoms, OR-combined) with @@ -1282,6 +1318,70 @@ mod tests { assert!(skill.body.contains("Standalone body.")); } + #[test] + fn workspace_group_skill_needs_no_frontmatter() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp.path().join("release-notes"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "Plain maintainer notes.\n").unwrap(); + + let workspace_group = SkillGroup { + workspace_member: true, + ..SkillGroup::default() + }; + let skill = load_skill(&skill_dir.join("SKILL.md"), &workspace_group).unwrap(); + assert_eq!(skill.name(), "release-notes"); + assert!(!skill.frontmatter.contains_key("description")); + assert_eq!(skill.body, "Plain maintainer notes.\n"); + + // Registry groups keep the agentskills.io contract. + let err = load_skill(&skill_dir.join("SKILL.md"), &SkillGroup::default()).unwrap_err(); + assert!(err.to_string().contains("frontmatter"), "{err}"); + } + + #[test] + fn workspace_group_skill_frontmatter_fields_stay_optional() { + let tmp = tempfile::tempdir().unwrap(); + let skill_dir = tmp.path().join("style"); + fs::create_dir_all(&skill_dir).unwrap(); + // Frontmatter present, but neither name nor description: the name + // falls back to the directory, other fields still parse. + fs::write( + skill_dir.join("SKILL.md"), + indoc! {" + --- + depends-on: serde + --- + + Body. + "}, + ) + .unwrap(); + + let workspace_group = SkillGroup { + workspace_member: true, + ..SkillGroup::default() + }; + let skill = load_skill(&skill_dir.join("SKILL.md"), &workspace_group).unwrap(); + assert_eq!(skill.name(), "style"); + assert!(skill.predicates.references_dep("serde")); + + // An explicit name still wins over the directory fallback. + fs::write( + skill_dir.join("SKILL.md"), + indoc! {" + --- + name: explicit + --- + + Body. + "}, + ) + .unwrap(); + let skill = load_skill(&skill_dir.join("SKILL.md"), &workspace_group).unwrap(); + assert_eq!(skill.name(), "explicit"); + } + #[test] fn validate_standalone_skill_bad_depends_on() { let tmp = tempfile::tempdir().unwrap(); @@ -1326,6 +1426,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group targets serde source: PluginSource::default(), + workspace_member: false, }], mcp_servers: vec![], installations: Vec::new(), @@ -1383,6 +1484,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("other-crate"), // But group targets other-crate source: PluginSource::default(), + workspace_member: false, }], mcp_servers: vec![], installations: Vec::new(), @@ -1458,6 +1560,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group also targets serde source: PluginSource::Path(skill_dir.to_path_buf()), + workspace_member: false, }], mcp_servers: vec![], installations: Vec::new(), @@ -1538,6 +1641,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), source: PluginSource::Path(skill_dir.to_path_buf()), + workspace_member: false, }], mcp_servers: vec![], installations: Vec::new(), @@ -1619,6 +1723,7 @@ mod tests { ], }, source: PluginSource::Path(skill_dir.to_path_buf()), + workspace_member: false, }], mcp_servers: vec![], installations: Vec::new(), diff --git a/src/sync.rs b/src/sync.rs index a0e5dd28..c7e7df0b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -24,7 +24,7 @@ use symposium_sdk::workspace::WorkspaceDeps; /// Cleanup walks each agent's skills parent dir and removes any subdir /// containing this marker that isn't in the freshly-installed set, leaving /// user-managed skill directories (which lack the marker) untouched. -const MARKER_FILE: &str = ".symposium"; +pub(crate) const MARKER_FILE: &str = ".symposium"; /// Create `path` and any missing ancestors up to `boundary`. /// @@ -76,29 +76,6 @@ fn has_symposium_marker(dir: &Path) -> bool { dir.join(MARKER_FILE).exists() } -/// Discover user-authored skills in `/.agents/skills/`. -/// -/// A skill is user-authored iff its directory contains `SKILL.md` and does -/// *not* contain the `.symposium` marker. Symposium never writes markers -/// into source skills, so this unambiguously separates user content from -/// copies symposium put there itself. -fn discover_user_authored_skills(project_root: &Path) -> Vec { - let agents_skills_dir = project_root.join(".agents").join("skills"); - let Ok(entries) = fs::read_dir(&agents_skills_dir) else { - return Vec::new(); - }; - - let mut skills: Vec = entries - .flatten() - .map(|e| e.path()) - .filter(|p| p.is_dir()) - .filter(|p| p.join("SKILL.md").is_file()) - .filter(|p| !has_symposium_marker(p)) - .collect(); - skills.sort(); - skills -} - /// Recursively copy the contents of `src` into `dst`. Creates `dst` if /// missing. Regular files are copied with `fs::copy`; subdirectories are /// walked. Symlinks and other special files are ignored. @@ -420,6 +397,19 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel } }; + // The skill's source already sits at this agent's install slot + // (a workspace `.agents/skills/` skill, on an agent that reads + // that same directory) — it is in place as user content, not + // something to copy. + let plain_dir = agent.project_skill_dir(&project_root, skill_name); + let in_place = match (source_dir.canonicalize(), plain_dir.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + }; + if in_place { + continue; + } + // Pick the install dir name for this skill on *this* agent: // - If exactly one origin claims the name and the un-suffixed // slot is "available" (nonexistent or symposium-managed), @@ -428,7 +418,6 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel // distinct origins coexist and we never clobber a // user-managed directory. let unique_name = name_counts.get(skill_name).copied().unwrap_or(0) == 1; - let plain_dir = agent.project_skill_dir(&project_root, skill_name); let plain_available = !plain_dir.exists() || has_symposium_marker(&plain_dir); let dir_name = if unique_name && plain_available { skill_name.clone() @@ -477,73 +466,6 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel } } - // Propagate user-authored skills from `.agents/skills/` into every - // configured agent that reads skills from a different directory. Skills - // are "user-authored" when they lack the `.symposium` marker — symposium - // never writes that marker into a source, so this never re-propagates - // symposium's own installs. See the agents-syncing feature docs. - if sym.config.agents_syncing { - let user_authored = discover_user_authored_skills(&project_root); - if !user_authored.is_empty() { - tracing::debug!( - count = user_authored.len(), - "propagating user-authored skills from .agents/skills/" - ); - for agent_name in &agent_names { - let agent = Agent::from_config_name(agent_name)?; - for source_dir in &user_authored { - let name = match source_dir.file_name().and_then(|n| n.to_str()) { - Some(n) => n, - None => continue, - }; - let dest_dir = agent.project_skill_dir(&project_root, name); - - if dest_dir == *source_dir { - continue; - } - if dest_dir.exists() && !has_symposium_marker(&dest_dir) { - tracing::info!( - report = %crate::report::ReportEvent::Warning { - message: format!( - "skipping propagation to {}: user-managed skill already present", - display_path(&dest_dir) - ), - }, - ); - continue; - } - - match sync_skill_dir(source_dir, &dest_dir, &project_root, debounce) { - Ok(true) => { - installed_dirs.insert(dest_dir.clone()); - tracing::info!( - report = %crate::report::ReportEvent::SkillPropagated { - skill: name.to_string(), - agent: agent_name.clone(), - dest: display_path(&dest_dir), - }, - ); - } - Ok(false) => { - // Debounced or unchanged — still record as - // installed so stale-cleanup doesn't remove it. - if dest_dir.exists() { - installed_dirs.insert(dest_dir.clone()); - } - } - Err(e) => { - tracing::info!( - report = %crate::report::ReportEvent::Warning { - message: format!("failed to propagate skill {name} to {}: {e}", display_path(&dest_dir)), - }, - ); - } - } - } - } - } - } - // Stale-skill cleanup: scan every agent's skills parent directory (across // all known agents, so we also clean up after agents removed from config) // and remove subdirs containing the marker that we didn't just install. diff --git a/tests/fixtures/member-agents-skills0/Cargo.toml b/tests/fixtures/member-agents-skills0/Cargo.toml new file mode 100644 index 00000000..4d52db10 --- /dev/null +++ b/tests/fixtures/member-agents-skills0/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [".", "tool"] + +[package] +name = "app" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/member-agents-skills0/src/lib.rs b/tests/fixtures/member-agents-skills0/src/lib.rs new file mode 100644 index 00000000..eb80827a --- /dev/null +++ b/tests/fixtures/member-agents-skills0/src/lib.rs @@ -0,0 +1 @@ +// Empty test crate. diff --git a/tests/fixtures/member-agents-skills0/tool/.agents/skills/plain-notes/SKILL.md b/tests/fixtures/member-agents-skills0/tool/.agents/skills/plain-notes/SKILL.md new file mode 100644 index 00000000..7db786f0 --- /dev/null +++ b/tests/fixtures/member-agents-skills0/tool/.agents/skills/plain-notes/SKILL.md @@ -0,0 +1 @@ +Informal maintainer notes: no frontmatter, name comes from the directory. diff --git a/tests/fixtures/member-agents-skills0/tool/.agents/skills/tool-maintainer/SKILL.md b/tests/fixtures/member-agents-skills0/tool/.agents/skills/tool-maintainer/SKILL.md new file mode 100644 index 00000000..a6c9bdaf --- /dev/null +++ b/tests/fixtures/member-agents-skills0/tool/.agents/skills/tool-maintainer/SKILL.md @@ -0,0 +1,6 @@ +--- +name: tool-maintainer +description: Guidance for maintaining the tool member crate +--- + +Maintainer guidance for tool. diff --git a/tests/fixtures/member-agents-skills0/tool/Cargo.toml b/tests/fixtures/member-agents-skills0/tool/Cargo.toml new file mode 100644 index 00000000..a9fc3b4f --- /dev/null +++ b/tests/fixtures/member-agents-skills0/tool/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "tool" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/member-agents-skills0/tool/src/lib.rs b/tests/fixtures/member-agents-skills0/tool/src/lib.rs new file mode 100644 index 00000000..eb80827a --- /dev/null +++ b/tests/fixtures/member-agents-skills0/tool/src/lib.rs @@ -0,0 +1 @@ +// Empty test crate. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index e543d2a0..ac9b0b3b 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -1192,6 +1192,17 @@ async fn agents_syncing_noop_when_only_agents_path_used() { // No other agent's skills dir should have been created. assert!(!workspace_root.join(".claude/skills").exists()); assert!(!workspace_root.join(".kiro/skills").exists()); + // And no suffixed duplicate next to the source: the in-place + // source is this agent's install, not a collision to resolve. + let dupes: Vec<_> = std::fs::read_dir(workspace_root.join(".agents/skills"))? + .flatten() + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("user-authored-skill-") + }) + .collect(); + assert!(dupes.is_empty(), "no suffixed duplicate: {dupes:?}"); Ok(()) }, ) @@ -1292,6 +1303,30 @@ async fn agents_syncing_disabling_removes_previously_propagated_skills() { .unwrap(); } +/// A member crate's `.agents/skills/` installs for everyone working in the +/// workspace — the maintainer-skills default group applies per member, not +/// just at the workspace root. +#[tokio::test] +async fn agents_syncing_installs_member_agents_skills() { + with_fixture( + TestMode::SimulationOnly, + &["member-agents-skills0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + find_installed_skill(&workspace_root.join(".claude/skills"), "tool-maintainer"); + // Workspace skills are informal: no frontmatter needed, the + // directory name is the skill name. + find_installed_skill(&workspace_root.join(".claude/skills"), "plain-notes"); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// A pre-existing, user-managed directory in the target (no `.symposium` /// marker) is not overwritten even when a same-named skill exists in /// `.agents/skills/`. @@ -1350,7 +1385,7 @@ async fn agents_syncing_detects_modified_source_skill() { // Modify the source skill. std::fs::write( &source, - "---\nname: user-authored-skill\n---\n\n# Updated content\n", + "---\nname: user-authored-skill\ndescription: updated\n---\n\n# Updated content\n", )?; // Re-sync — should detect the change and update the destination.