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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ accumulate under `Unreleased` until a `v*` tag publishes them.
statistics and eviction estimates count tokens without materializing
full text, and the eviction frontier measures only the batches it
consumes.
- Plugin pack contents take effect in sessions instead of staying
inventory-only: pack skills merge into the catalog as the new `pack`
source (below user and project, which keep their slots on a name
collision), pack MCP entries convert into configured servers for the
live connect path (a user-configured server owns its name; later
packs win among themselves), and pack hook rules join the engine
after the config-declared ones with the same matcher/timeout
semantics. Collisions and invalid entries degrade to session
warnings; `plugin list` notes that pack contents are wired.

### Fixed
- Leaving the console TUI runs the SessionEnd hooks and the memory
Expand Down
42 changes: 24 additions & 18 deletions crates/capabilities/hooks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ impl HookDef {
self.timeout_ms
})
}

/// Reasons this entry can never do anything useful at `point`: an
/// empty command, a blank matcher, or a matcher on a point that
/// carries no tool name. Empty when the entry is usable; a zero
/// timeout is not listed because it falls back to the default
/// rather than disabling the entry. [`HookEngine::validate`] and
/// the assembly layers share this rule, so a skipped entry and the
/// static diagnostics cannot drift apart.
pub fn unusable_reasons(&self, point: HookEventPoint) -> Vec<&'static str> {
let mut reasons = Vec::new();
if self.command.trim().is_empty() {
reasons.push("has an empty command (never executes)");
}
if let Some(matcher) = &self.matcher {
if matcher.trim().is_empty() {
reasons.push("matcher is blank (never matches)");
} else if !is_tool_point(point) {
reasons.push("has a matcher but this point carries no tool name (never fires)");
}
}
reasons
}
}

/// An event payload for one hook firing.
Expand Down Expand Up @@ -293,31 +315,15 @@ impl HookEngine {
tables.sort_by_key(|(p, _)| p.as_str());
for (point, defs) in tables {
for (idx, def) in defs.iter().enumerate() {
if def.command.trim().is_empty() {
out.push(format!(
"[{}] entry #{idx} has an empty command (never executes)",
point.as_str()
));
for reason in def.unusable_reasons(point) {
out.push(format!("[{}] entry #{idx} {reason}", point.as_str()));
}
if def.timeout_ms == 0 {
out.push(format!(
"[{}] entry #{idx} timeout_ms is 0 (falls back to {DEFAULT_TIMEOUT_MS}ms)",
point.as_str()
));
}
if let Some(matcher) = &def.matcher {
if matcher.trim().is_empty() {
out.push(format!(
"[{}] entry #{idx} matcher is blank (never matches)",
point.as_str()
));
} else if !is_tool_point(point) {
out.push(format!(
"[{}] entry #{idx} has a matcher but this point carries no tool name (never fires)",
point.as_str()
));
}
}
}
}
out
Expand Down
5 changes: 5 additions & 0 deletions crates/capabilities/skills/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const SKILL_DIR_VARIABLE: &str = "${WAVECODE_SKILL_DIR}";
pub enum SkillSource {
/// Builtin skill set shipped with the binary.
Builtin,
/// Installed plugin pack (`~/.wavecode/plugins/<pack>/…`). Ranks
/// below user- and project-level skills: a pack is a bundle someone
/// else curated, so the user's own same-named skill wins.
Pack,
/// User-level `~/.wavecode/skills`.
User,
/// Project-level `<cwd>/.wavecode/skills`.
Expand All @@ -77,6 +81,7 @@ impl SkillSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Builtin => "builtin",
Self::Pack => "pack",
Self::User => "user",
Self::Project => "project",
Self::Mcp => "mcp",
Expand Down
146 changes: 119 additions & 27 deletions crates/capabilities/skills/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
* - Warn-and-skip invalid packs without failing discovery.
*
* Constraints:
* - This module is inventory only: the session assembly consumes
* builtin/user/project skill roots and never merges pack contents, so
* a pack's skills, MCP servers, and hooks are counted and surfaced
* (e.g. by `plugin list`) but do not take effect.
* - This module parses and validates but does not decide precedence: the
* session assembly merges pack skills under the standard roots via
* [`PluginLoader::apply_skills`], and the composition root converts
* the raw MCP entries and hook rules into configured servers and
* engine definitions (config-crate types this module must not import).
*
* This module must not depend on: workspace crates beyond its own
* (manifest shapes mirror config/hook types instead of importing them).
Expand All @@ -24,12 +25,12 @@
//!
//! A manifest carries `{name, version, skills_dir?, hooks_file?}` plus
//! optional `[mcp_servers.<server>]` inline tables. Skills load through
//! the [`discover`] pipeline with [`SkillSource::User`]. The loader is
//! inventory only: MCP entries and hook rules are returned raw (mirrors
//! of the config/hook shapes, so this module imports neither), and
//! nothing currently converts them into configured servers or active
//! hook rules — consumers read counts and entries for listing and
//! diagnostics. Invalid packs warn-and-skip with a reason.
//! the [`discover`] pipeline with [`SkillSource::Pack`]. MCP entries and
//! hook rules are returned raw (mirrors of the config/hook shapes, so
//! this module imports neither); the session assembly merges the skills
//! below user/project sources and the composition root converts the
//! entries into configured servers and active hook rules. Invalid packs
//! warn-and-skip with a reason.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
Expand All @@ -51,8 +52,8 @@ fn valid_name(name: &str) -> bool {
/// Raw MCP server entry from a plugin manifest.
///
/// Mirrors the config-crate server shape without depending on it.
/// Retained for listing and diagnostics; no session assembly consumes
/// these entries today.
/// The session assembly converts entries into configured servers for
/// the live connect path.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PluginMcpServer {
/// Stdio executable command (stdio servers).
Expand All @@ -73,8 +74,8 @@ pub struct PluginMcpServer {
/// Raw hook rule from a plugin hooks file.
///
/// Mirrors the config-crate hook rule without depending on it.
/// Retained for listing and diagnostics; no session assembly converts
/// these into engine definitions today.
/// The session assembly converts rules into engine definitions with
/// the same matcher/timeout semantics as config-declared rules.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PluginHookRule {
/// Tool-name matcher (optional).
Expand Down Expand Up @@ -123,12 +124,13 @@ pub struct LoadedPlugin {
pub name: String,
/// Pack version string.
pub version: String,
/// Skills discovered from `skills_dir` ([`SkillSource::User`]).
/// Skills discovered from `skills_dir` ([`SkillSource::Pack`]).
pub skills: SkillSet,
/// Raw MCP entries, sorted by server name (listing only; see the
/// module docs for the inventory-only contract).
/// Raw MCP entries, sorted by server name (converted into
/// configured servers by the composition root).
pub mcp_servers: Vec<(String, PluginMcpServer)>,
/// Raw hook rules, keyed by event-point name (listing only).
/// Raw hook rules, keyed by event-point name (converted into
/// engine definitions by the session assembly).
pub hooks: HashMap<String, Vec<PluginHookRule>>,
}

Expand Down Expand Up @@ -203,15 +205,29 @@ impl PluginLoader {
&self.warnings
}

/// Merge every pack's skills into `set` (same names override, so
/// later packs win over earlier ones).
/// Merge every pack's skills into `set` as the pack source.
///
/// No production caller today — the session assembly never merges
/// pack skills; this is the hook a wiring pass would call.
/// Pack skills rank between builtin and the standard roots: they
/// displace a same-named builtin entry and yield to user and project
/// entries, which keep their slots, so the caller merges packs after
/// the standard roots. Later packs win among themselves, in
/// directory order.
pub fn apply_skills(&self, set: &mut SkillSet) {
for plugin in &self.plugins {
for skill in plugin.skills.iter() {
set.add(skill.clone());
// A user or project entry keeps its slot; a pack entry
// yields to the later pack, in directory order; a builtin
// entry yields to the pack (source order: builtin < pack
// < user < project).
let displaced = match set.get(&skill.name) {
Some(existing) => {
matches!(existing.source, SkillSource::Builtin | SkillSource::Pack)
}
None => true,
};
if displaced {
set.add(skill.clone());
}
}
}
}
Expand Down Expand Up @@ -239,9 +255,9 @@ impl PluginLoader {
let skills = match &manifest.skills_dir {
Some(rel) => {
// Reuse the Discovery pipeline: same SKILL.md parsing and
// per-file warn-and-skip, attributed to the User source.
// per-file warn-and-skip, attributed to the Pack source.
let roots = [SkillRoot {
source: SkillSource::User,
source: SkillSource::Pack,
dir: dir.join(rel),
}];
let discovery = discover(&roots);
Expand Down Expand Up @@ -295,6 +311,7 @@ impl PluginLoader {

#[cfg(test)]
mod tests {
use super::super::Skill;
use super::*;

const SKILL_MD: &str = "---\ndescription: Review helper skill.\n---\n\nReview body here.\n";
Expand Down Expand Up @@ -454,7 +471,7 @@ command = "notify.sh"
}

#[test]
fn apply_skills_merges_as_user_source() {
fn apply_skills_merges_as_pack_source_below_existing_entries() {
let home = home_with_pack(
"demo",
&[
Expand All @@ -467,6 +484,81 @@ command = "notify.sh"
let mut set = SkillSet::default();
loader.apply_skills(&mut set);
let skill = set.get("greet").expect("merged skill");
assert_eq!(skill.source, SkillSource::User);
assert_eq!(skill.source, SkillSource::Pack);

// An entry already in the set keeps its slot: user/project
// skills win over a pack skill of the same name.
let user = tempfile::tempdir().unwrap();
let greet = user.path().join("greet");
std::fs::create_dir_all(&greet).unwrap();
std::fs::write(greet.join("SKILL.md"), SKILL_MD).unwrap();
let mut set = SkillSet::default();
set.add(Skill::parse(&greet, SkillSource::User).expect("hand-built skill"));
loader.apply_skills(&mut set);
assert_eq!(set.get("greet").unwrap().source, SkillSource::User);

// A builtin entry yields to the pack: pack ranks above builtin
// in the source order (builtin < pack < user < project).
let builtin = tempfile::tempdir().unwrap();
let greet = builtin.path().join("greet");
std::fs::create_dir_all(&greet).unwrap();
std::fs::write(greet.join("SKILL.md"), SKILL_MD).unwrap();
let mut set = SkillSet::default();
set.add(Skill::parse(&greet, SkillSource::Builtin).expect("hand-built skill"));
loader.apply_skills(&mut set);
assert_eq!(set.get("greet").unwrap().source, SkillSource::Pack);
}

/// Two packs declaring the same skill name: the later pack (directory
/// order) wins the slot, while a user skill still beats both.
#[test]
fn later_pack_wins_the_same_named_skill() {
let manifest = |name: &str| {
format!(
"name = \"{name}\"
version = \"1\"
skills_dir = \"skills\"
"
)
};
let home = home_with_pack("aaa", &[("plugin.toml", &manifest("aaa"))]);
// Distinct SKILL.md fixtures: both packs carry the same skill
// name, so the merged entry's description is what tells the
// later pack's body apart from the first pack's.
let first_skill = "---\ndescription: First pack skill.\n---\n\nFirst pack body.\n";
let later_skill = "---\ndescription: Later pack skill.\n---\n\nLater pack body.\n";
let pack = home.path().join(".wavecode").join("plugins").join("bbb");
std::fs::create_dir_all(pack.join("skills").join("greet")).unwrap();
std::fs::write(pack.join("plugin.toml"), manifest("bbb")).unwrap();
std::fs::write(
pack.join("skills").join("greet").join("SKILL.md"),
later_skill,
)
.unwrap();
let first = home.path().join(".wavecode").join("plugins").join("aaa");
std::fs::create_dir_all(first.join("skills").join("greet")).unwrap();
std::fs::write(
first.join("skills").join("greet").join("SKILL.md"),
first_skill,
)
.unwrap();
let loader = PluginLoader::load(Some(home.path()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
assert_eq!(loader.plugins().len(), 2);

let mut set = SkillSet::default();
loader.apply_skills(&mut set);
assert_eq!(set.get("greet").unwrap().source, SkillSource::Pack);
let merged = set.get("greet").unwrap();
assert_eq!(
merged.meta.description, "Later pack skill.",
"the later pack's metadata displaced the first pack's entry"
);
assert_eq!(
merged.body, "Later pack body.",
"the merged entry carries the later pack's body, not just its metadata"
);

// The collision leaves one entry, not a duplicate.
assert_eq!(set.len(), 1);
}
}
4 changes: 4 additions & 0 deletions crates/frontends/harness/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub(crate) fn run_plugin_list(home: Option<PathBuf>) {
plugin.name, plugin.version, plugin.skills, plugin.mcp_servers, plugin.hooks
);
}
println!(
"pack contents take effect in new sessions; a same-named user skill or \
configured mcp server wins over a pack entry"
);
}

/// Serve live sessions over local HTTP until Ctrl-C or `POST /shutdown`.
Expand Down
10 changes: 7 additions & 3 deletions crates/operations/bootstrap/src/plugin_inventory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ pub struct PluginSummary {
/// Load every installed plugin pack under `home`.
///
/// Returns the summaries plus non-fatal parse warnings: a broken pack
/// degrades to a warning, never a hard failure. The summaries are
/// inventory only — pack skills, MCP servers, and hooks are counted for
/// listing and diagnostics; none of them is wired into a session.
/// degrades to a warning, never a hard failure. The summaries mirror
/// what a session would wire: pack skills merge into the catalog under
/// user/project sources, pack MCP servers convert into configured
/// servers, and pack hook rules join the hook engine. Whether a given
/// entry survives session-time collision checks (a same-named user
/// skill or configured server wins) depends on the session's cwd and
/// config, which this listing deliberately does not load.
pub fn discover_plugin_summaries(home: Option<&Path>) -> (Vec<PluginSummary>, Vec<String>) {
let loader = wavecode_skills::plugin::PluginLoader::load(home);
let summaries = loader
Expand Down
Loading
Loading