Skip to main content

mcp_methods/server/
skills.rs

1//! Skills-aware MCP — runtime types, frontmatter parsing, three-layer
2//! resolution, and the [`Registry`] builder downstream binaries
3//! consume to wire skills into their MCP server.
4//!
5//! # The shape downstream binaries adopt
6//!
7//! ```ignore
8//! use mcp_methods::server::skills::{Registry, BundledSkill};
9//! use mcp_methods::server::manifest::load;
10//!
11//! let manifest = load(yaml_path)?;
12//! let registry = Registry::new()
13//!     // Domain-specific bundled skills (one per custom tool):
14//!     .add_bundled(BundledSkill {
15//!         name: "cypher_query",
16//!         body: include_str!("skills/cypher_query.md"),
17//!     })
18//!     .add_bundled(BundledSkill {
19//!         name: "graph_overview",
20//!         body: include_str!("skills/graph_overview.md"),
21//!     })
22//!     // Framework defaults (ripgrep, github_discussions, etc.):
23//!     .merge_framework_defaults()
24//!     // Operator-declared paths from the manifest's `skills:` field:
25//!     .layer_dirs(&manifest.skills, &manifest.yaml_path)?
26//!     // Project-local <basename>.skills/ adjacent to the YAML:
27//!     .auto_detect_project_layer(&manifest.yaml_path)
28//!     // Resolve all layers, run lint, return the resolved registry:
29//!     .finalise()?;
30//!
31//! // Phase 1c wires this into `serve_prompts(&registry, &mut server)`.
32//! ```
33//!
34//! # Three-layer composition
35//!
36//! 1. **Project layer (top priority).** Auto-detected from
37//!    `<manifest_basename>.skills/` adjacent to the YAML. Files there
38//!    override every other layer per skill name. This is the operator's
39//!    per-deployment tweak zone.
40//! 2. **Root layer (middle).** Each entry in the manifest's `skills:`
41//!    list, walked in declaration order. First-match-per-name wins.
42//!    This is where operator-curated domain skill-packs sit
43//!    (`kglite-skills-legal/`, etc.).
44//! 3. **Bundled layer (bottom).** Compile-time defaults shipped with
45//!    `mcp-methods` plus any added by the downstream binary via
46//!    [`Registry::add_bundled`]. Library authors ship protocol-level
47//!    methodology here; operators inherit it.
48//!
49//! Within the bundled layer, the downstream binary's skills win over
50//! the framework's defaults when names collide.
51//!
52//! # Static markdown — no dynamic rendering
53//!
54//! Skills are pure markdown bodies with YAML frontmatter. The framework
55//! does NOT splice tool output, run shell commands, or evaluate
56//! templates server-side. Skills teach the agent *how* to use tools;
57//! tools provide dynamic content when invoked. This keeps skill loading
58//! deterministic and cheap, and matches Anthropic's own skill format.
59//!
60//! See `dev-documentation/skills-aware-mcp.md` for the full design.
61
62use std::collections::HashMap;
63use std::fs;
64use std::path::{Path, PathBuf};
65use std::sync::Arc;
66
67use serde::Deserialize;
68
69use super::manifest::{SkillSource, SkillsSource};
70
71// ─── Public types ─────────────────────────────────────────────────
72
73/// A compile-time bundled skill, embedded into the binary via
74/// `include_str!`. Downstream binaries (e.g. `kglite-mcp-server`)
75/// construct these for their custom tools; the framework constructs
76/// them for its own (`grep`, `read_source`, etc.).
77///
78/// Bundled skills sit at the bottom of the three-layer composition —
79/// project and root-layer entries override them when names collide.
80#[derive(Debug, Clone)]
81pub struct BundledSkill {
82    /// Skill name. Must match the `name` field in the markdown
83    /// frontmatter. Used as the lookup key in `prompts/get`.
84    pub name: &'static str,
85    /// The full SKILL.md content — frontmatter + body. Parsed at
86    /// `Registry::add_bundled` time; malformed bundled skills are
87    /// errors (caught by the framework's CI tests), not warnings.
88    pub body: &'static str,
89}
90
91/// Parsed YAML frontmatter of a SKILL.md file.
92///
93/// Phase 1b stores all declared fields as raw values. Phase 1f / 2a
94/// will add validation (`applies_to` semver checks, `references_tools`
95/// against the active tool catalogue, `references_arguments` against
96/// each tool's input schema). For now: parse and preserve; the lint
97/// step in `Registry::finalise()` walks these and surfaces issues as
98/// log warnings.
99#[derive(Debug, Clone, Default, Deserialize)]
100pub struct SkillFrontmatter {
101    /// Skill name. Must match the lookup key used in `prompts/get`.
102    /// Required; empty after deserialization triggers a clear
103    /// [`SkillError::MissingRequiredField`] rather than a generic
104    /// YAML parse failure.
105    #[serde(default)]
106    pub name: String,
107    /// One-line description shown in `prompts/list`. Required —
108    /// the agent uses this to decide whether to load the full body.
109    #[serde(default)]
110    pub description: String,
111
112    /// Version constraints. Parsed lazily — Phase 1b stores raw
113    /// values, Phase 1f adds semver validation.
114    #[serde(default)]
115    pub applies_to: Option<HashMap<String, String>>,
116
117    /// Tools this skill teaches or references in prose. Used for
118    /// auto-inject discoverability hints (Phase 1c) and staleness
119    /// detection (Phase 1f).
120    #[serde(default)]
121    pub references_tools: Vec<String>,
122
123    /// Specific tool argument names referenced in the skill body
124    /// (e.g. `"cypher_query.format"`). Lint warns when references
125    /// don't match the tool's actual input schema.
126    #[serde(default)]
127    pub references_arguments: Vec<String>,
128
129    /// Graph properties / domain-specific references the skill calls
130    /// out (e.g. `"Function.module"`). For domain skill-packs to
131    /// declare their domain assumptions. The framework can't validate
132    /// these statically; they're documentation-grade metadata.
133    #[serde(default)]
134    pub references_properties: Vec<String>,
135
136    /// When `true` (the default) AND the skill's name matches a
137    /// registered MCP tool, the framework injects a "see `prompts/get`
138    /// `<name>` for full methodology" pointer into the tool's
139    /// description. Phase 1c wires this up.
140    #[serde(default = "default_auto_inject_hint")]
141    pub auto_inject_hint: bool,
142
143    /// `applies_when:` predicate set. Bounded — not a DSL. All
144    /// populated fields must evaluate true (AND semantics) for the
145    /// skill to surface in `prompts/list` and `prompts/get`. The
146    /// framework dispatches `tool_registered` and `extension_enabled`
147    /// itself; domain predicates (`graph_has_node_type`,
148    /// `graph_has_property`) are evaluated via the optional
149    /// [`SkillPredicateEvaluator`] registered on the
150    /// [`Registry`].
151    ///
152    /// `None` (the default) means "always active" — the skill applies
153    /// regardless of runtime state.
154    #[serde(default)]
155    pub applies_when: Option<AppliesWhen>,
156}
157
158fn default_auto_inject_hint() -> bool {
159    true
160}
161
162/// The parsed shape of a SKILL.md's `applies_when:` block. Each field
163/// is one predicate; `None` means "this predicate is not applied".
164/// All populated fields are ANDed.
165///
166/// Adding a new predicate requires extending this struct and the
167/// matching arm in [`Registry::evaluate_clause`]. The bounded-set
168/// design is intentional — operators get type-checked semantics
169/// instead of an open-ended DSL.
170#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
171pub struct AppliesWhen {
172    /// Active when the running graph has *any* of the listed node
173    /// types in its schema. Domain predicate — evaluated via the
174    /// consumer's [`SkillPredicateEvaluator`].
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub graph_has_node_type: Option<Vec<String>>,
177
178    /// Active when the running graph has the named property on the
179    /// named node type. Domain predicate — evaluated via the
180    /// consumer's [`SkillPredicateEvaluator`].
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub graph_has_property: Option<GraphPropertyCheck>,
183
184    /// Active when the named tool is in the registered catalogue
185    /// at boot. Framework-internal — dispatched against
186    /// `server.tool_router` without consulting any evaluator.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub tool_registered: Option<String>,
189
190    /// Active when the manifest's `extensions:` block has the named
191    /// key set to a truthy value (not absent, not null, not `false`).
192    /// Framework-internal — dispatched against `manifest.extensions`.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub extension_enabled: Option<String>,
195}
196
197/// Nested shape for the `graph_has_property:` predicate.
198#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
199pub struct GraphPropertyCheck {
200    pub node_type: String,
201    pub prop_name: String,
202}
203
204/// A single predicate clause, passed to a
205/// [`SkillPredicateEvaluator`] one at a time. Borrowed slices so
206/// the evaluator doesn't have to allocate.
207#[derive(Debug)]
208pub enum PredicateClause<'a> {
209    /// `graph_has_node_type: [Function, Class]`
210    GraphHasNodeType(&'a [String]),
211    /// `graph_has_property: { node_type: Function, prop_name: module }`
212    GraphHasProperty {
213        node_type: &'a str,
214        prop_name: &'a str,
215    },
216    /// `tool_registered: cypher_query`
217    ToolRegistered(&'a str),
218    /// `extension_enabled: csv_http_server`
219    ExtensionEnabled(&'a str),
220}
221
222/// Per-clause result of evaluating an `applies_when:` block. Surfaced
223/// via [`SkillActivation`] so the operator-facing `skills-list` and
224/// boot log can show *which* predicate suppressed a skill.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum PredicateOutcome {
227    /// Predicate evaluated to true.
228    Satisfied,
229    /// Predicate evaluated to false. The skill is inactive.
230    Unsatisfied,
231    /// No evaluator recognized the predicate. Treated as
232    /// `Unsatisfied` for safety — a typo'd predicate must not
233    /// silently activate the skill against the wrong domain.
234    Unknown,
235}
236
237/// Activation state for a single skill, post-predicate-evaluation.
238/// Skills without an `applies_when:` block resolve to `Active` with
239/// an empty `clauses` vec.
240#[derive(Debug, Clone, Default)]
241pub struct SkillActivation {
242    /// Whether the skill should appear in `prompts/list` /
243    /// `prompts/get`.
244    pub active: bool,
245    /// Per-clause evaluation outcomes, in declaration order. Empty
246    /// for skills without an `applies_when:` block.
247    pub clauses: Vec<(String, PredicateOutcome)>,
248}
249
250/// Trait downstream binaries implement to evaluate domain-specific
251/// predicates. Framework-internal predicates (`tool_registered`,
252/// `extension_enabled`) are dispatched without consulting this trait;
253/// you only handle the domain ones (`graph_has_node_type`,
254/// `graph_has_property`).
255///
256/// Return `Some(true)` / `Some(false)` when you have an answer;
257/// return `None` when the predicate doesn't apply to your domain
258/// (the framework will mark it `Unknown` and the skill will be
259/// inactive — safer than silently activating the wrong skill).
260///
261/// # Example
262///
263/// ```ignore
264/// struct KgliteEvaluator {
265///     graph: Arc<Graph>,
266/// }
267///
268/// impl SkillPredicateEvaluator for KgliteEvaluator {
269///     fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
270///         match clause {
271///             PredicateClause::GraphHasNodeType(types) => {
272///                 Some(types.iter().any(|t| self.graph.has_node_type(t)))
273///             }
274///             PredicateClause::GraphHasProperty { node_type, prop_name } => {
275///                 Some(self.graph.has_property(node_type, prop_name))
276///             }
277///             _ => None,   // framework dispatches the rest
278///         }
279///     }
280/// }
281/// ```
282pub trait SkillPredicateEvaluator: Send + Sync {
283    fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool>;
284}
285
286/// Where a [`Skill`] came from. Used for the boot-time collision-
287/// resolution log and surfaced via the JSON shape kglite consumes
288/// from `to_json()` (in Phase 1d).
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum SkillProvenance {
291    /// Auto-detected from `<basename>.skills/` adjacent to the
292    /// manifest YAML — top-priority operator overrides.
293    Project,
294    /// Loaded from an operator-declared path in the manifest's
295    /// `skills:` list (a domain skill-pack or shared library).
296    DomainPack(PathBuf),
297    /// Compile-time bundled — shipped with `mcp-methods` (framework
298    /// defaults) or with a downstream binary like `kglite-mcp-server`.
299    Bundled,
300}
301
302/// A loaded skill, post-parse + post-resolution. The body is the
303/// markdown content after the closing `---` frontmatter delimiter.
304#[derive(Debug, Clone)]
305pub struct Skill {
306    pub frontmatter: SkillFrontmatter,
307    pub body: String,
308    pub provenance: SkillProvenance,
309}
310
311impl Skill {
312    /// Convenience accessor for the skill's name (read from
313    /// frontmatter at parse time).
314    pub fn name(&self) -> &str {
315        &self.frontmatter.name
316    }
317
318    /// One-line description for `prompts/list` responses.
319    pub fn description(&self) -> &str {
320        &self.frontmatter.description
321    }
322}
323
324// ─── Errors ───────────────────────────────────────────────────────
325
326/// Errors surfaced during skill loading + resolution. Variants are
327/// kept distinct so downstream binaries (and the future skills-lint
328/// CLI) can report locations and surface fixes precisely.
329#[derive(Debug)]
330pub enum SkillError {
331    /// Filesystem error reading the skill file.
332    Io {
333        path: PathBuf,
334        source: std::io::Error,
335    },
336    /// Missing or malformed frontmatter delimiters.
337    MissingFrontmatter { path: PathBuf },
338    /// Frontmatter present but invalid YAML.
339    InvalidFrontmatter { path: PathBuf, message: String },
340    /// Required frontmatter field missing (name or description).
341    MissingRequiredField { path: PathBuf, field: &'static str },
342    /// Skill body exceeds the hard size limit (16 KB by default).
343    SkillTooLarge {
344        path: PathBuf,
345        bytes: usize,
346        limit: usize,
347    },
348    /// Path declared in the manifest's `skills:` list doesn't exist
349    /// or isn't a directory.
350    PathNotFound { raw: String, resolved: PathBuf },
351    /// Compile-time bundled skill (added via `add_bundled`) failed to
352    /// parse. This is a framework-author or downstream-binary-author
353    /// bug — the bundled skill files should round-trip through their
354    /// own CI tests before shipping.
355    BundledSkillInvalid { name: &'static str, message: String },
356}
357
358impl std::fmt::Display for SkillError {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            SkillError::Io { path, source } => {
362                write!(f, "skill I/O error at {}: {source}", path.display())
363            }
364            SkillError::MissingFrontmatter { path } => write!(
365                f,
366                "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
367                path.display()
368            ),
369            SkillError::InvalidFrontmatter { path, message } => {
370                write!(
371                    f,
372                    "skill frontmatter at {} is not valid YAML: {message}",
373                    path.display()
374                )
375            }
376            SkillError::MissingRequiredField { path, field } => write!(
377                f,
378                "skill at {} is missing required frontmatter field `{field}`",
379                path.display()
380            ),
381            SkillError::SkillTooLarge {
382                path,
383                bytes,
384                limit,
385            } => write!(
386                f,
387                "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
388                path.display()
389            ),
390            SkillError::PathNotFound { raw, resolved } => write!(
391                f,
392                "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
393                resolved.display()
394            ),
395            SkillError::BundledSkillInvalid { name, message } => write!(
396                f,
397                "bundled skill `{name}` is malformed: {message}"
398            ),
399        }
400    }
401}
402
403impl std::error::Error for SkillError {
404    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
405        match self {
406            SkillError::Io { source, .. } => Some(source),
407            _ => None,
408        }
409    }
410}
411
412// ─── Size limits ──────────────────────────────────────────────────
413
414/// Per-skill soft limit. Loading a skill larger than this logs a
415/// warning via `tracing::warn!` but does not fail.
416pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
417/// Per-skill hard limit. Loading a skill larger than this returns
418/// [`SkillError::SkillTooLarge`]. Forces authors to keep skills
419/// tight and prevents accidental dump-the-whole-onboarding-doc.
420pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
421/// Total session limit across all resolved skills. Exceeding this
422/// logs a warning at `Registry::finalise` time but does not drop
423/// skills automatically — operators stay in control of which skills
424/// they want loaded.
425pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
426
427// ─── Frontmatter parser ───────────────────────────────────────────
428
429/// Split a SKILL.md file into its YAML frontmatter and markdown body.
430///
431/// Returns the frontmatter content (without the `---` delimiters) and
432/// the body (everything after the closing `---`).
433///
434/// The frontmatter MUST start at byte 0 of the file with the opening
435/// `---` on its own line, and MUST be terminated by a `---` on its
436/// own line. This matches Jekyll / Hugo / Anthropic-skills convention.
437fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
438    let trimmed = content.strip_prefix("---\n").or_else(|| {
439        // Handle CRLF line endings.
440        content.strip_prefix("---\r\n")
441    })?;
442    // Find the closing `---` on its own line.
443    let mut search_start = 0;
444    while let Some(idx) = trimmed[search_start..].find("---") {
445        let abs = search_start + idx;
446        // Must be at the start of a line.
447        let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
448        // Must be followed by `\n`, `\r\n`, or end of file.
449        let after = &trimmed[abs + 3..];
450        let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
451        if at_line_start && line_end_ok {
452            let frontmatter = &trimmed[..abs];
453            let body_start = if after.starts_with("\r\n") {
454                abs + 3 + 2
455            } else if after.starts_with('\n') {
456                abs + 3 + 1
457            } else {
458                abs + 3
459            };
460            let body = &trimmed[body_start..];
461            return Some((frontmatter, body));
462        }
463        search_start = abs + 3;
464    }
465    None
466}
467
468/// Parse a SKILL.md content blob into its frontmatter struct and
469/// markdown body.
470pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
471    let (frontmatter_str, body) =
472        split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
473            path: path.to_path_buf(),
474        })?;
475
476    let frontmatter: SkillFrontmatter =
477        serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
478            path: path.to_path_buf(),
479            message: e.to_string(),
480        })?;
481
482    if frontmatter.name.is_empty() {
483        return Err(SkillError::MissingRequiredField {
484            path: path.to_path_buf(),
485            field: "name",
486        });
487    }
488    if frontmatter.description.is_empty() {
489        return Err(SkillError::MissingRequiredField {
490            path: path.to_path_buf(),
491            field: "description",
492        });
493    }
494
495    Ok((frontmatter, body.to_string()))
496}
497
498// ─── Skill loaders ────────────────────────────────────────────────
499
500/// Load a single skill from a file path.
501pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
502    let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
503        path: path.to_path_buf(),
504        source: e,
505    })?;
506
507    if content.len() > HARD_SIZE_LIMIT_BYTES {
508        return Err(SkillError::SkillTooLarge {
509            path: path.to_path_buf(),
510            bytes: content.len(),
511            limit: HARD_SIZE_LIMIT_BYTES,
512        });
513    }
514    if content.len() > SOFT_SIZE_LIMIT_BYTES {
515        tracing::warn!(
516            path = %path.display(),
517            bytes = content.len(),
518            soft_limit = SOFT_SIZE_LIMIT_BYTES,
519            "skill exceeds the soft size limit; consider splitting"
520        );
521    }
522
523    let (frontmatter, body) = parse_skill(&content, path)?;
524    Ok(Skill {
525        frontmatter,
526        body,
527        provenance,
528    })
529}
530
531/// Walk a directory for `*.md` files, loading each as a skill.
532///
533/// Files that fail to parse log warnings via `tracing::warn!` and
534/// are skipped — one malformed skill in a domain pack shouldn't take
535/// down the rest. The lint pass surfaces these for fix-it-later.
536pub fn load_skills_from_dir(
537    dir: &Path,
538    provenance: SkillProvenance,
539) -> Result<Vec<Skill>, SkillError> {
540    if !dir.is_dir() {
541        return Ok(Vec::new());
542    }
543
544    let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
545        path: dir.to_path_buf(),
546        source: e,
547    })?;
548
549    let mut skills = Vec::new();
550    for entry in entries {
551        let entry = match entry {
552            Ok(e) => e,
553            Err(e) => {
554                tracing::warn!(
555                    dir = %dir.display(),
556                    error = %e,
557                    "failed to read directory entry; skipping"
558                );
559                continue;
560            }
561        };
562        let path = entry.path();
563        // Only `.md` files. Subdirectories and other extensions are
564        // ignored (no recursion — keeps the model simple).
565        if path.extension().map(|e| e == "md").unwrap_or(false) {
566            match load_skill_from_file(&path, provenance.clone()) {
567                Ok(skill) => skills.push(skill),
568                Err(e) => {
569                    tracing::warn!(
570                        path = %path.display(),
571                        error = %e,
572                        "failed to load skill; skipping"
573                    );
574                }
575            }
576        }
577    }
578    Ok(skills)
579}
580
581// ─── Path resolution ──────────────────────────────────────────────
582
583/// Resolve a skill path declaration against the manifest's parent
584/// directory, applying the same conventions used by other manifest
585/// fields:
586///
587/// - `./foo` or `foo` → relative to the manifest's parent dir
588/// - `~/foo` → home-relative (POSIX `$HOME` expansion)
589/// - `/foo` or `C:\foo` → absolute
590///
591/// Public so downstream binaries can resolve paths consistently if
592/// they need to.
593pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
594    let p = Path::new(raw);
595    if p.is_absolute() {
596        return p.to_path_buf();
597    }
598    if let Some(rest) = raw.strip_prefix("~/") {
599        if let Some(home) = std::env::var_os("HOME") {
600            return PathBuf::from(home).join(rest);
601        }
602        // No HOME — fall through to manifest-relative.
603    }
604    manifest_dir.join(raw)
605}
606
607/// Project layer path for a manifest: `<manifest_stem>.skills/` next
608/// to the manifest YAML.
609///
610/// For a manifest at `mcp-servers/legal_mcp.yaml`, the project layer
611/// lives at `mcp-servers/legal_mcp.skills/`.
612pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
613    let stem = yaml_path
614        .file_stem()
615        .map(|s| s.to_string_lossy().into_owned())
616        .unwrap_or_else(|| "manifest".to_string());
617    let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
618    parent.join(format!("{stem}.skills"))
619}
620
621// ─── Library-bundled framework defaults ───────────────────────────
622
623/// Return the framework's own bundled skills.
624///
625/// The five SKILL.md files are embedded at compile time via the
626/// [`bundled_skills_index`](crate::server::bundled_skills_index)
627/// submodule. Downstream binaries call this through
628/// [`Registry::merge_framework_defaults`] when they want the
629/// framework defaults at the bottom of their three-layer stack.
630pub fn library_bundled_skills() -> Vec<BundledSkill> {
631    crate::server::bundled_skills_index::library_bundled_skills()
632}
633
634// ─── Authoring template ───────────────────────────────────────────
635
636/// Render a starter SKILL.md body as a string.
637///
638/// The returned text is a complete, parse-valid SKILL.md file with
639/// the supplied `name` and `description` filled into the frontmatter
640/// and the rest of the optional extension fields commented out.
641/// The body follows the anatomy documented in
642/// `docs/guides/writing-effective-skills.md` — Overview, Quick
643/// Reference table, a placeholder major-topic section, Common
644/// Pitfalls, and a "When wrong" section — all with `<TODO>`-style
645/// placeholders the operator fills in.
646///
647/// Use [`write_skill_template`] for the on-disk version.
648pub fn render_skill_template(name: &str, description: &str) -> String {
649    format!(
650        "---\n\
651         name: {name}\n\
652         description: {description}\n\
653         # Optional mcp-methods extension fields (uncomment as needed):\n\
654         # applies_to:\n\
655         #   mcp_methods: \">=0.3.35\"\n\
656         # references_tools:\n\
657         #   - {name}\n\
658         # references_arguments:\n\
659         #   - {name}.<arg_name>\n\
660         # auto_inject_hint: true\n\
661         ---\n\
662         \n\
663         # `{name}` methodology\n\
664         \n\
665         ## Overview\n\
666         \n\
667         <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
668         what comes before and after it in the typical workflow.>\n\
669         \n\
670         ## Quick Reference\n\
671         \n\
672         | Task | Approach |\n\
673         |---|---|\n\
674         | <TODO: common task A> | <TODO: one-line pattern> |\n\
675         | <TODO: common task B> | <TODO: one-line pattern> |\n\
676         \n\
677         ## <TODO: Major topic>\n\
678         \n\
679         <TODO: concrete prose, code blocks, examples.>\n\
680         \n\
681         ## Common Pitfalls\n\
682         \n\
683         ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
684         \n\
685         ✅ <TODO: positive guidance, often a heuristic>\n\
686         \n\
687         ## When `{name}` is the wrong tool\n\
688         \n\
689         - **<TODO: scenario>** — use <other tool> because <reason>.\n"
690    )
691}
692
693/// Resolve where a template write should land and write it.
694///
695/// `dest` interpretation:
696/// - If `dest` is an existing directory, the file is written to
697///   `dest/<name>.md`.
698/// - If `dest` ends in `.md`, it is used verbatim and its parent
699///   must already exist.
700/// - Otherwise `dest` is treated as a directory that should be
701///   created (and its parents created with `create_dir_all`) before
702///   writing `dest/<name>.md`.
703///
704/// Existing files are never overwritten — if the destination already
705/// exists, returns a `SkillError::Io` wrapping `AlreadyExists`. The
706/// caller should delete first if they really want to replace.
707pub fn write_skill_template(
708    dest: &Path,
709    name: &str,
710    description: &str,
711) -> Result<PathBuf, SkillError> {
712    let path = resolve_template_dest(dest, name);
713
714    if path.exists() {
715        return Err(SkillError::Io {
716            path: path.clone(),
717            source: std::io::Error::new(
718                std::io::ErrorKind::AlreadyExists,
719                "destination already exists; delete it before re-running",
720            ),
721        });
722    }
723
724    if let Some(parent) = path.parent() {
725        if !parent.as_os_str().is_empty() && !parent.exists() {
726            fs::create_dir_all(parent).map_err(|e| SkillError::Io {
727                path: parent.to_path_buf(),
728                source: e,
729            })?;
730        }
731    }
732
733    let body = render_skill_template(name, description);
734    fs::write(&path, body).map_err(|e| SkillError::Io {
735        path: path.clone(),
736        source: e,
737    })?;
738    Ok(path)
739}
740
741fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
742    if dest.is_dir() {
743        return dest.join(format!("{name}.md"));
744    }
745    if dest
746        .extension()
747        .map(|e| e.eq_ignore_ascii_case("md"))
748        .unwrap_or(false)
749    {
750        return dest.to_path_buf();
751    }
752    dest.join(format!("{name}.md"))
753}
754
755// ─── Registry builder ─────────────────────────────────────────────
756
757/// Builder for a skills [`ResolvedRegistry`]. Downstream binaries
758/// (`kglite-mcp-server`, etc.) construct one of these in their
759/// boot path, layer in their bundled + operator-declared skills,
760/// then call [`Registry::finalise`] to get the resolved set
761/// ready for MCP `prompts/list` + `prompts/get` wiring.
762///
763/// See the module docs for the canonical usage pattern.
764#[derive(Default)]
765pub struct Registry {
766    bundled: Vec<BundledSkill>,
767    /// Sources from the manifest's `skills:` list, in declaration
768    /// order. Each entry contributes a layer; later entries within
769    /// the root layer have lower priority than earlier ones.
770    root_dirs: Vec<(PathBuf, String)>, // (resolved_path, raw_decl_string)
771    root_includes_bundled: bool,
772    /// Project layer — auto-detected `<basename>.skills/` adjacent
773    /// to the manifest YAML. Set via `auto_detect_project_layer`.
774    project_dir: Option<PathBuf>,
775    /// Optional consumer-supplied evaluator for domain predicates
776    /// (`graph_has_node_type`, `graph_has_property`). Wired in via
777    /// [`Registry::with_predicate_evaluator`]; framework-internal
778    /// predicates (`tool_registered`, `extension_enabled`) are
779    /// dispatched without consulting the evaluator.
780    evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
781}
782
783impl std::fmt::Debug for Registry {
784    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785        f.debug_struct("Registry")
786            .field("bundled", &self.bundled)
787            .field("root_dirs", &self.root_dirs)
788            .field("root_includes_bundled", &self.root_includes_bundled)
789            .field("project_dir", &self.project_dir)
790            .field(
791                "evaluator",
792                &self
793                    .evaluator
794                    .as_ref()
795                    .map(|_| "<dyn SkillPredicateEvaluator>"),
796            )
797            .finish()
798    }
799}
800
801impl Registry {
802    /// Construct an empty registry. Chain in `add_bundled`,
803    /// `merge_framework_defaults`, `layer_dirs`,
804    /// `auto_detect_project_layer`, and optionally
805    /// `with_predicate_evaluator`, then call `finalise()`.
806    pub fn new() -> Self {
807        Self::default()
808    }
809
810    /// Register a domain-specific predicate evaluator for the
811    /// `applies_when:` machinery. The evaluator only sees domain
812    /// predicates (`graph_has_node_type`, `graph_has_property`);
813    /// framework-internal ones (`tool_registered`,
814    /// `extension_enabled`) are dispatched against the
815    /// [`McpServer`](crate::server::McpServer)'s runtime state at
816    /// [`serve_prompts`](crate::server::serve_prompts) time.
817    ///
818    /// Without an evaluator, skills using domain predicates resolve
819    /// to inactive (predicate `Unknown` → skill suppressed). This
820    /// is the safe default: a typo'd predicate or a missing
821    /// evaluator must not silently activate the wrong-domain skill.
822    pub fn with_predicate_evaluator(
823        mut self,
824        evaluator: impl SkillPredicateEvaluator + 'static,
825    ) -> Self {
826        self.evaluator = Some(Arc::new(evaluator));
827        self
828    }
829
830    /// Add a compile-time bundled skill. Typically called by
831    /// downstream binaries with their own `include_str!`'d skills,
832    /// once per custom tool.
833    ///
834    /// Bundled skills sit at the bottom of the three-layer
835    /// composition; later layers override them when names collide.
836    /// Within the bundled set, the downstream binary's skills win
837    /// over framework defaults (the downstream calls `add_bundled`
838    /// before or after `merge_framework_defaults` — order doesn't
839    /// matter; resolution dedupes by name with downstream-first
840    /// priority).
841    ///
842    /// Malformed bundled skills are reported at `finalise()` time
843    /// via [`SkillError::BundledSkillInvalid`]. The framework's
844    /// own bundled-skill CI test should catch this for the library
845    /// defaults; downstream binaries should write equivalent tests
846    /// for their own bundled set.
847    pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
848        self.bundled.push(skill);
849        self
850    }
851
852    /// Add a batch of compile-time bundled skills.
853    pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
854        self.bundled.extend(skills);
855        self
856    }
857
858    /// Merge in the framework's own bundled defaults (returned by
859    /// [`library_bundled_skills`]). Idempotent — calling twice is
860    /// harmless (later calls add duplicates which the finalise
861    /// deduper drops, downstream-first).
862    pub fn merge_framework_defaults(self) -> Self {
863        let defaults = library_bundled_skills();
864        self.add_bundled_many(defaults)
865    }
866
867    /// Layer in skill directories declared in the manifest's
868    /// `skills:` field, walked in declaration order. Each path
869    /// becomes a domain-pack-layer source; the bundled marker
870    /// `true` is acknowledged but its skills are already in the
871    /// bundled layer via `add_bundled`/`merge_framework_defaults`.
872    ///
873    /// Path resolution uses the same conventions as the rest of the
874    /// manifest (`./foo` relative to YAML dir, `~/foo` home-relative,
875    /// `/foo` absolute). Non-existent paths are reported as
876    /// [`SkillError::PathNotFound`] at this call site so operators
877    /// see typos immediately.
878    pub fn layer_dirs(
879        mut self,
880        source: &SkillsSource,
881        yaml_path: &Path,
882    ) -> Result<Self, SkillError> {
883        let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
884
885        match source {
886            SkillsSource::Disabled => {
887                // Skills disabled entirely — return the registry
888                // unchanged. Downstream may still have called
889                // add_bundled, but those won't be reachable without
890                // a layer telling us skills are enabled.
891                self.root_includes_bundled = false;
892            }
893            SkillsSource::Sources(sources) => {
894                for src in sources {
895                    match src {
896                        SkillSource::Bundled => {
897                            self.root_includes_bundled = true;
898                        }
899                        SkillSource::Path(raw) => {
900                            let resolved = resolve_skill_path(raw, manifest_dir);
901                            if !resolved.is_dir() {
902                                return Err(SkillError::PathNotFound {
903                                    raw: raw.clone(),
904                                    resolved,
905                                });
906                            }
907                            self.root_dirs.push((resolved, raw.clone()));
908                        }
909                    }
910                }
911            }
912        }
913
914        Ok(self)
915    }
916
917    /// Auto-detect the project layer at `<basename>.skills/`
918    /// adjacent to the manifest YAML. Always called; the directory
919    /// is optional — if it doesn't exist, the project layer is
920    /// simply empty.
921    pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
922        let candidate = project_skills_dir(yaml_path);
923        if candidate.is_dir() {
924            self.project_dir = Some(candidate);
925        }
926        self
927    }
928
929    /// Resolve all three layers and return the final registry.
930    ///
931    /// Resolution order per skill name: project > root layer
932    /// (in declaration order) > bundled. The first source that
933    /// contributes a skill with the given name wins; later sources
934    /// are ignored for that name (no merging, no inheritance —
935    /// full-file replacement).
936    ///
937    /// At this point the framework:
938    /// - Parses all skill files (frontmatter validation)
939    /// - Logs collision-resolution info via `tracing::info!` per skill
940    /// - Enforces per-skill hard size limits ([`HARD_SIZE_LIMIT_BYTES`])
941    /// - Warns on per-skill soft size limit ([`SOFT_SIZE_LIMIT_BYTES`])
942    /// - Warns on session total exceeding [`SESSION_TOTAL_LIMIT_BYTES`]
943    pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
944        let Self {
945            bundled,
946            root_dirs,
947            root_includes_bundled,
948            project_dir,
949            evaluator,
950        } = self;
951
952        // Parse bundled skills first. These are the lowest-priority
953        // layer; they get overridden by anything declared above.
954        let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
955        if root_includes_bundled {
956            for b in &bundled {
957                let path = PathBuf::from(format!("<bundled:{}>", b.name));
958                let (frontmatter, body) =
959                    parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
960                        name: b.name,
961                        message: e.to_string(),
962                    })?;
963                if frontmatter.name != b.name {
964                    return Err(SkillError::BundledSkillInvalid {
965                        name: b.name,
966                        message: format!(
967                            "frontmatter name {:?} does not match the bundled key {:?}",
968                            frontmatter.name, b.name
969                        ),
970                    });
971                }
972                bundled_skills.push(Skill {
973                    frontmatter,
974                    body,
975                    provenance: SkillProvenance::Bundled,
976                });
977            }
978        }
979
980        // Root layer: walk each declared path; first wins per name.
981        let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
982        for (resolved, _raw) in &root_dirs {
983            let provenance = SkillProvenance::DomainPack(resolved.clone());
984            let skills = load_skills_from_dir(resolved, provenance)?;
985            root_skills_per_dir.push(skills);
986        }
987
988        // Project layer: auto-detected adjacent dir.
989        let project_skills: Vec<Skill> = match &project_dir {
990            Some(dir) => load_skills_from_dir(dir, SkillProvenance::Project)?,
991            None => Vec::new(),
992        };
993
994        // Resolve per skill name. Priority:
995        //   1. Project layer
996        //   2. Root layer entries in declaration order
997        //   3. Bundled (downstream entries first, then framework)
998        //
999        // The bundled list is already in downstream-first order
1000        // because downstream binaries call `add_bundled` before
1001        // `merge_framework_defaults` by convention.
1002
1003        let mut resolved: HashMap<String, Skill> = HashMap::new();
1004        let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1005
1006        // Lowest priority first: bundled, then root in reverse
1007        // declaration order, then project. Later inserts overwrite.
1008        // We track collisions for the boot log.
1009        for skill in &bundled_skills {
1010            let name = skill.name().to_string();
1011            collisions
1012                .entry(name.clone())
1013                .or_default()
1014                .push(skill.provenance.clone());
1015            resolved.insert(name, skill.clone());
1016        }
1017        for skills in root_skills_per_dir.iter().rev() {
1018            for skill in skills {
1019                let name = skill.name().to_string();
1020                collisions
1021                    .entry(name.clone())
1022                    .or_default()
1023                    .push(skill.provenance.clone());
1024                resolved.insert(name, skill.clone());
1025            }
1026        }
1027        for skill in &project_skills {
1028            let name = skill.name().to_string();
1029            collisions
1030                .entry(name.clone())
1031                .or_default()
1032                .push(skill.provenance.clone());
1033            resolved.insert(name, skill.clone());
1034        }
1035
1036        // Log collision resolution for skills with more than one
1037        // candidate. Single-candidate skills don't need a log line.
1038        for (name, candidates) in &collisions {
1039            if candidates.len() > 1 {
1040                let winner = resolved
1041                    .get(name)
1042                    .map(|s| format_provenance(&s.provenance))
1043                    .unwrap_or_else(|| "<none>".to_string());
1044                let all_candidates: Vec<String> =
1045                    candidates.iter().map(format_provenance).collect();
1046                tracing::info!(
1047                    skill = %name,
1048                    candidates = ?all_candidates,
1049                    winner = %winner,
1050                    "skill resolved across multiple layers"
1051                );
1052            }
1053        }
1054
1055        // Check session-total size limit.
1056        let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1057        if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1058            tracing::warn!(
1059                total_bytes,
1060                limit = SESSION_TOTAL_LIMIT_BYTES,
1061                skill_count = resolved.len(),
1062                "total resolved skill body size exceeds session limit; \
1063                 consider trimming or splitting skills"
1064            );
1065        }
1066
1067        Ok(ResolvedRegistry {
1068            skills: resolved,
1069            evaluator,
1070        })
1071    }
1072}
1073
1074fn format_provenance(p: &SkillProvenance) -> String {
1075    match p {
1076        SkillProvenance::Project => "project".to_string(),
1077        SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1078        SkillProvenance::Bundled => "bundled".to_string(),
1079    }
1080}
1081
1082// ─── ResolvedRegistry ─────────────────────────────────────────────
1083
1084/// The post-resolution skill set. Consumed by `serve_prompts`
1085/// (Phase 1c) to wire `prompts/list` and `prompts/get` on the
1086/// MCP server.
1087#[derive(Default)]
1088pub struct ResolvedRegistry {
1089    skills: HashMap<String, Skill>,
1090    /// Optional domain-predicate evaluator carried from
1091    /// [`Registry::with_predicate_evaluator`]. `serve_prompts`
1092    /// consults this when evaluating `applies_when:` blocks; absent
1093    /// means domain predicates resolve to `Unknown` → skill
1094    /// inactive.
1095    pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1096}
1097
1098impl std::fmt::Debug for ResolvedRegistry {
1099    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1100        f.debug_struct("ResolvedRegistry")
1101            .field("skills", &self.skills)
1102            .field(
1103                "evaluator",
1104                &self
1105                    .evaluator
1106                    .as_ref()
1107                    .map(|_| "<dyn SkillPredicateEvaluator>"),
1108            )
1109            .finish()
1110    }
1111}
1112
1113impl ResolvedRegistry {
1114    /// All resolved skill names, sorted alphabetically for stable
1115    /// output in `prompts/list`.
1116    pub fn skill_names(&self) -> Vec<String> {
1117        let mut names: Vec<String> = self.skills.keys().cloned().collect();
1118        names.sort();
1119        names
1120    }
1121
1122    /// Look up a skill by name. Used by `prompts/get` to fetch the
1123    /// full body when the agent requests it.
1124    pub fn get(&self, name: &str) -> Option<&Skill> {
1125        self.skills.get(name)
1126    }
1127
1128    /// Iterate all resolved skills. Order is unspecified — use
1129    /// `skill_names()` first if a deterministic iteration is needed.
1130    pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1131        self.skills.iter()
1132    }
1133
1134    /// Number of resolved skills.
1135    pub fn len(&self) -> usize {
1136        self.skills.len()
1137    }
1138
1139    /// Whether the registry contains any skills.
1140    pub fn is_empty(&self) -> bool {
1141        self.skills.is_empty()
1142    }
1143
1144    /// Evaluate the `applies_when:` block on `skill` against this
1145    /// registry's evaluator plus the supplied runtime state. Returns
1146    /// the per-clause outcomes plus whether the skill should be
1147    /// considered active.
1148    ///
1149    /// `registered_tools` and `extensions` carry the runtime state
1150    /// the framework-internal predicates check against.
1151    /// `serve_prompts` calls this for every skill at boot;
1152    /// `skills-list` calls it (with placeholder empty state) for
1153    /// the operator-facing summary.
1154    ///
1155    /// A skill without an `applies_when:` block is always active.
1156    pub fn activation_for(
1157        &self,
1158        skill: &Skill,
1159        registered_tools: &std::collections::HashSet<String>,
1160        extensions: &serde_json::Map<String, serde_json::Value>,
1161    ) -> SkillActivation {
1162        let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1163            return SkillActivation {
1164                active: true,
1165                clauses: Vec::new(),
1166            };
1167        };
1168        let mut clauses = Vec::new();
1169        let mut all_satisfied = true;
1170
1171        if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1172            let clause = PredicateClause::GraphHasNodeType(types);
1173            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1174            if outcome != PredicateOutcome::Satisfied {
1175                all_satisfied = false;
1176            }
1177            clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1178        }
1179        if let Some(prop) = applies_when.graph_has_property.as_ref() {
1180            let clause = PredicateClause::GraphHasProperty {
1181                node_type: &prop.node_type,
1182                prop_name: &prop.prop_name,
1183            };
1184            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1185            if outcome != PredicateOutcome::Satisfied {
1186                all_satisfied = false;
1187            }
1188            clauses.push((
1189                format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1190                outcome,
1191            ));
1192        }
1193        if let Some(tool) = applies_when.tool_registered.as_ref() {
1194            let clause = PredicateClause::ToolRegistered(tool);
1195            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1196            if outcome != PredicateOutcome::Satisfied {
1197                all_satisfied = false;
1198            }
1199            clauses.push((format!("tool_registered: {tool}"), outcome));
1200        }
1201        if let Some(key) = applies_when.extension_enabled.as_ref() {
1202            let clause = PredicateClause::ExtensionEnabled(key);
1203            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1204            if outcome != PredicateOutcome::Satisfied {
1205                all_satisfied = false;
1206            }
1207            clauses.push((format!("extension_enabled: {key}"), outcome));
1208        }
1209
1210        SkillActivation {
1211            active: all_satisfied,
1212            clauses,
1213        }
1214    }
1215
1216    fn dispatch_clause(
1217        &self,
1218        clause: &PredicateClause<'_>,
1219        registered_tools: &std::collections::HashSet<String>,
1220        extensions: &serde_json::Map<String, serde_json::Value>,
1221    ) -> PredicateOutcome {
1222        // Framework-internal predicates are dispatched in-framework
1223        // regardless of the evaluator's preference. This keeps
1224        // tool_registered + extension_enabled working even when no
1225        // evaluator is registered.
1226        match clause {
1227            PredicateClause::ToolRegistered(name) => {
1228                return if registered_tools.contains(*name) {
1229                    PredicateOutcome::Satisfied
1230                } else {
1231                    PredicateOutcome::Unsatisfied
1232                };
1233            }
1234            PredicateClause::ExtensionEnabled(key) => {
1235                let truthy = extensions
1236                    .get(*key)
1237                    .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1238                    .unwrap_or(false);
1239                return if truthy {
1240                    PredicateOutcome::Satisfied
1241                } else {
1242                    PredicateOutcome::Unsatisfied
1243                };
1244            }
1245            _ => {}
1246        }
1247
1248        // Domain predicates: defer to the evaluator. Unknown when no
1249        // evaluator is registered or the evaluator returns None.
1250        match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1251            Some(true) => PredicateOutcome::Satisfied,
1252            Some(false) => PredicateOutcome::Unsatisfied,
1253            None => PredicateOutcome::Unknown,
1254        }
1255    }
1256}
1257
1258// ─── Tests ────────────────────────────────────────────────────────
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263    use std::io::Write;
1264
1265    fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1266        let path = dir.join(format!("{name}.md"));
1267        let mut f = fs::File::create(&path).unwrap();
1268        f.write_all(content.as_bytes()).unwrap();
1269        path
1270    }
1271
1272    fn minimal_skill(name: &str) -> String {
1273        format!(
1274            "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1275        )
1276    }
1277
1278    // ─── Frontmatter parsing ──────────────────────────────────────
1279
1280    #[test]
1281    fn parse_frontmatter_basic() {
1282        let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1283        let path = PathBuf::from("test.md");
1284        let (fm, body) = parse_skill(content, &path).unwrap();
1285        assert_eq!(fm.name, "foo");
1286        assert_eq!(fm.description, "A foo skill.");
1287        assert_eq!(body, "\nBody here.\n");
1288        assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1289    }
1290
1291    #[test]
1292    fn parse_frontmatter_missing_delimiters_rejected() {
1293        let content = "name: foo\ndescription: bar\n";
1294        let path = PathBuf::from("test.md");
1295        let err = parse_skill(content, &path).unwrap_err();
1296        assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1297    }
1298
1299    #[test]
1300    fn parse_frontmatter_invalid_yaml_rejected() {
1301        let content = "---\nname: foo\n  bad: yaml: nesting\n---\nbody\n";
1302        let path = PathBuf::from("test.md");
1303        let err = parse_skill(content, &path).unwrap_err();
1304        assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1305    }
1306
1307    #[test]
1308    fn parse_frontmatter_missing_name_rejected() {
1309        let content = "---\ndescription: bar\n---\nbody\n";
1310        let path = PathBuf::from("test.md");
1311        let err = parse_skill(content, &path).unwrap_err();
1312        assert!(matches!(
1313            err,
1314            SkillError::MissingRequiredField { field: "name", .. }
1315        ));
1316    }
1317
1318    #[test]
1319    fn parse_frontmatter_missing_description_rejected() {
1320        let content = "---\nname: foo\n---\nbody\n";
1321        let path = PathBuf::from("test.md");
1322        let err = parse_skill(content, &path).unwrap_err();
1323        assert!(matches!(
1324            err,
1325            SkillError::MissingRequiredField {
1326                field: "description",
1327                ..
1328            }
1329        ));
1330    }
1331
1332    #[test]
1333    fn parse_frontmatter_all_optional_fields() {
1334        let content = "---\n\
1335name: foo\n\
1336description: Full surface.\n\
1337references_tools: [grep, list_source]\n\
1338references_arguments: [grep.pattern]\n\
1339references_properties: [Function.module]\n\
1340auto_inject_hint: false\n\
1341applies_to:\n  mcp_methods: \">=0.3.35\"\n\
1342---\n\
1343Body.\n";
1344        let path = PathBuf::from("test.md");
1345        let (fm, _) = parse_skill(content, &path).unwrap();
1346        assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1347        assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1348        assert_eq!(fm.references_properties, vec!["Function.module"]);
1349        assert!(!fm.auto_inject_hint);
1350        assert_eq!(
1351            fm.applies_to.unwrap().get("mcp_methods"),
1352            Some(&">=0.3.35".to_string())
1353        );
1354    }
1355
1356    // ─── Loading from files + dirs ────────────────────────────────
1357
1358    #[test]
1359    fn load_skill_from_file_basic() {
1360        let dir = tempfile::tempdir().unwrap();
1361        let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1362        let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1363        assert_eq!(skill.name(), "foo");
1364        assert_eq!(skill.provenance, SkillProvenance::Project);
1365    }
1366
1367    #[test]
1368    fn load_skill_too_large_rejected() {
1369        let dir = tempfile::tempdir().unwrap();
1370        // Build a body just over the hard limit.
1371        let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1372        let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1373        let path = write_skill(dir.path(), "big", &content);
1374        let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1375        assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1376    }
1377
1378    #[test]
1379    fn load_skills_from_dir_walks_markdown_only() {
1380        let dir = tempfile::tempdir().unwrap();
1381        write_skill(dir.path(), "a", &minimal_skill("a"));
1382        write_skill(dir.path(), "b", &minimal_skill("b"));
1383        // Non-markdown file — ignored.
1384        fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1385        // Subdirectory — ignored.
1386        let sub = dir.path().join("sub");
1387        fs::create_dir(&sub).unwrap();
1388        write_skill(&sub, "c", &minimal_skill("c"));
1389
1390        let skills = load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1391        assert_eq!(skills.len(), 2);
1392        let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1393        names.sort();
1394        assert_eq!(names, vec!["a", "b"]);
1395    }
1396
1397    #[test]
1398    fn load_skills_from_dir_missing_returns_empty() {
1399        let dir = tempfile::tempdir().unwrap();
1400        let nonexistent = dir.path().join("does-not-exist");
1401        let skills = load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1402        assert!(skills.is_empty());
1403    }
1404
1405    // ─── Path resolution ──────────────────────────────────────────
1406
1407    #[test]
1408    fn resolve_skill_path_relative() {
1409        let manifest_dir = Path::new("/a/b");
1410        assert_eq!(
1411            resolve_skill_path("./skills", manifest_dir),
1412            PathBuf::from("/a/b/./skills")
1413        );
1414        assert_eq!(
1415            resolve_skill_path("skills", manifest_dir),
1416            PathBuf::from("/a/b/skills")
1417        );
1418    }
1419
1420    #[test]
1421    fn resolve_skill_path_absolute() {
1422        let manifest_dir = Path::new("/a/b");
1423        assert_eq!(
1424            resolve_skill_path("/abs/skills", manifest_dir),
1425            PathBuf::from("/abs/skills")
1426        );
1427    }
1428
1429    #[test]
1430    fn resolve_skill_path_home_relative() {
1431        let manifest_dir = Path::new("/a/b");
1432        // Set HOME explicitly for the test.
1433        // SAFETY: tests run single-threaded for env mutation; this is
1434        // a known stylistic exception in Rust's 1.83+ unsafe-env API.
1435        unsafe {
1436            std::env::set_var("HOME", "/home/test");
1437        }
1438        assert_eq!(
1439            resolve_skill_path("~/skills", manifest_dir),
1440            PathBuf::from("/home/test/skills")
1441        );
1442    }
1443
1444    #[test]
1445    fn project_skills_dir_naming() {
1446        assert_eq!(
1447            project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1448            PathBuf::from("/a/b/legal_mcp.skills")
1449        );
1450        assert_eq!(
1451            project_skills_dir(Path::new("workspace_mcp.yaml")),
1452            PathBuf::from("workspace_mcp.skills")
1453        );
1454    }
1455
1456    // ─── Registry builder ─────────────────────────────────────────
1457
1458    #[test]
1459    fn registry_disabled_resolves_empty() {
1460        let dir = tempfile::tempdir().unwrap();
1461        let yaml = dir.path().join("test_mcp.yaml");
1462        fs::write(&yaml, "name: x\n").unwrap();
1463
1464        let registry = Registry::new()
1465            .layer_dirs(&SkillsSource::Disabled, &yaml)
1466            .unwrap()
1467            .auto_detect_project_layer(&yaml)
1468            .finalise()
1469            .unwrap();
1470        assert!(registry.is_empty());
1471    }
1472
1473    #[test]
1474    fn registry_add_bundled_only_visible_when_opted_in() {
1475        let dir = tempfile::tempdir().unwrap();
1476        let yaml = dir.path().join("test_mcp.yaml");
1477        fs::write(&yaml, "name: x\n").unwrap();
1478
1479        let bundled = BundledSkill {
1480            name: "foo",
1481            // Static body for testing — needs to be 'static, which is
1482            // why BundledSkill uses &'static str. For the test we
1483            // leak. Production code uses include_str!.
1484            body: Box::leak(minimal_skill("foo").into_boxed_str()),
1485        };
1486
1487        // Disabled → bundled is NOT visible, even if added.
1488        let registry = Registry::new()
1489            .add_bundled(bundled.clone())
1490            .layer_dirs(&SkillsSource::Disabled, &yaml)
1491            .unwrap()
1492            .finalise()
1493            .unwrap();
1494        assert!(registry.is_empty(), "disabled must short-circuit bundled");
1495
1496        // skills: [true] → bundled IS visible.
1497        let registry = Registry::new()
1498            .add_bundled(bundled)
1499            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1500            .unwrap()
1501            .finalise()
1502            .unwrap();
1503        assert_eq!(registry.len(), 1);
1504        assert!(registry.get("foo").is_some());
1505        assert_eq!(
1506            registry.get("foo").unwrap().provenance,
1507            SkillProvenance::Bundled
1508        );
1509    }
1510
1511    #[test]
1512    fn registry_three_layer_resolution_project_wins_over_bundled() {
1513        let dir = tempfile::tempdir().unwrap();
1514        let yaml = dir.path().join("test_mcp.yaml");
1515        fs::write(&yaml, "name: x\n").unwrap();
1516
1517        // Bundled `foo`:
1518        let bundled = BundledSkill {
1519            name: "foo",
1520            body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1521        };
1522
1523        // Project layer `foo`:
1524        let project_dir = dir.path().join("test_mcp.skills");
1525        fs::create_dir(&project_dir).unwrap();
1526        fs::write(
1527            project_dir.join("foo.md"),
1528            "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1529        )
1530        .unwrap();
1531
1532        let registry = Registry::new()
1533            .add_bundled(bundled)
1534            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1535            .unwrap()
1536            .auto_detect_project_layer(&yaml)
1537            .finalise()
1538            .unwrap();
1539
1540        assert_eq!(registry.len(), 1);
1541        let skill = registry.get("foo").unwrap();
1542        assert_eq!(skill.description(), "from project.");
1543        assert_eq!(skill.provenance, SkillProvenance::Project);
1544    }
1545
1546    #[test]
1547    fn registry_root_layer_first_declaration_wins() {
1548        let dir = tempfile::tempdir().unwrap();
1549        let yaml = dir.path().join("test_mcp.yaml");
1550        fs::write(&yaml, "name: x\n").unwrap();
1551
1552        // First domain pack: foo (from "primary").
1553        let primary = dir.path().join("primary");
1554        fs::create_dir(&primary).unwrap();
1555        fs::write(
1556            primary.join("foo.md"),
1557            "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1558        )
1559        .unwrap();
1560
1561        // Second domain pack: foo (from "secondary") — should LOSE.
1562        let secondary = dir.path().join("secondary");
1563        fs::create_dir(&secondary).unwrap();
1564        fs::write(
1565            secondary.join("foo.md"),
1566            "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1567        )
1568        .unwrap();
1569
1570        let registry = Registry::new()
1571            .layer_dirs(
1572                &SkillsSource::Sources(vec![
1573                    SkillSource::Path("./primary".into()),
1574                    SkillSource::Path("./secondary".into()),
1575                ]),
1576                &yaml,
1577            )
1578            .unwrap()
1579            .finalise()
1580            .unwrap();
1581
1582        assert_eq!(registry.len(), 1);
1583        assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1584    }
1585
1586    #[test]
1587    fn registry_root_layer_nonexistent_path_rejected() {
1588        let dir = tempfile::tempdir().unwrap();
1589        let yaml = dir.path().join("test_mcp.yaml");
1590        fs::write(&yaml, "name: x\n").unwrap();
1591
1592        let err = Registry::new()
1593            .layer_dirs(
1594                &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1595                &yaml,
1596            )
1597            .unwrap_err();
1598        assert!(matches!(err, SkillError::PathNotFound { .. }));
1599    }
1600
1601    #[test]
1602    fn registry_empty_list_opts_in_without_root_sources() {
1603        let dir = tempfile::tempdir().unwrap();
1604        let yaml = dir.path().join("test_mcp.yaml");
1605        fs::write(&yaml, "name: x\n").unwrap();
1606
1607        // No bundled, no paths — but project layer DOES exist.
1608        let project_dir = dir.path().join("test_mcp.skills");
1609        fs::create_dir(&project_dir).unwrap();
1610        fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1611
1612        let registry = Registry::new()
1613            .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1614            .unwrap()
1615            .auto_detect_project_layer(&yaml)
1616            .finalise()
1617            .unwrap();
1618
1619        assert_eq!(registry.len(), 1);
1620        assert_eq!(
1621            registry.get("only").unwrap().provenance,
1622            SkillProvenance::Project
1623        );
1624    }
1625
1626    #[test]
1627    fn registry_bundled_name_mismatch_rejected_at_finalise() {
1628        let dir = tempfile::tempdir().unwrap();
1629        let yaml = dir.path().join("test_mcp.yaml");
1630        fs::write(&yaml, "name: x\n").unwrap();
1631
1632        // BundledSkill says name="foo" but the frontmatter says name="bar".
1633        let bundled = BundledSkill {
1634            name: "foo",
1635            body: Box::leak(
1636                "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1637                    .to_string()
1638                    .into_boxed_str(),
1639            ),
1640        };
1641
1642        let err = Registry::new()
1643            .add_bundled(bundled)
1644            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1645            .unwrap()
1646            .finalise()
1647            .unwrap_err();
1648        assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1649    }
1650
1651    #[test]
1652    fn registry_library_bundled_skills_returns_vec() {
1653        // Five framework defaults ship from Phase 1d onward. The
1654        // exhaustive shape + uniqueness checks live in
1655        // `bundled_skills_index::tests`; here we just confirm the
1656        // re-export points downstream callers at the populated Vec.
1657        let skills = library_bundled_skills();
1658        assert!(
1659            !skills.is_empty(),
1660            "library_bundled_skills should return framework defaults from Phase 1d onward"
1661        );
1662    }
1663
1664    #[test]
1665    fn registry_skill_names_sorted() {
1666        let dir = tempfile::tempdir().unwrap();
1667        let yaml = dir.path().join("test_mcp.yaml");
1668        fs::write(&yaml, "name: x\n").unwrap();
1669
1670        let pack = dir.path().join("pack");
1671        fs::create_dir(&pack).unwrap();
1672        fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1673        fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1674        fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1675
1676        let registry = Registry::new()
1677            .layer_dirs(
1678                &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1679                &yaml,
1680            )
1681            .unwrap()
1682            .finalise()
1683            .unwrap();
1684
1685        assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1686    }
1687
1688    // ─── Authoring template ───────────────────────────────────────
1689
1690    #[test]
1691    fn render_skill_template_is_parse_valid() {
1692        // Round-trip: a freshly-rendered template must parse cleanly
1693        // through `parse_skill` so the operator's starting point is
1694        // never broken.
1695        let body = render_skill_template("custom_method", "A test description for the skill.");
1696        let (fm, _body) =
1697            parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1698        assert_eq!(fm.name, "custom_method");
1699        assert_eq!(fm.description, "A test description for the skill.");
1700    }
1701
1702    #[test]
1703    fn render_skill_template_substitutes_name_into_body_headings() {
1704        let body = render_skill_template("my_skill", "desc");
1705        assert!(body.contains("# `my_skill` methodology"));
1706        assert!(body.contains("## When `my_skill` is the wrong tool"));
1707    }
1708
1709    #[test]
1710    fn write_skill_template_writes_into_directory() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1713        assert_eq!(dest, dir.path().join("alpha.md"));
1714        let content = fs::read_to_string(&dest).unwrap();
1715        assert!(content.contains("name: alpha"));
1716    }
1717
1718    #[test]
1719    fn write_skill_template_writes_to_explicit_md_path() {
1720        let dir = tempfile::tempdir().unwrap();
1721        let explicit = dir.path().join("renamed.md");
1722        let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1723        assert_eq!(dest, explicit);
1724        assert!(explicit.is_file());
1725    }
1726
1727    #[test]
1728    fn write_skill_template_creates_missing_parents() {
1729        let dir = tempfile::tempdir().unwrap();
1730        let nested = dir.path().join("a/b/c");
1731        let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1732        assert_eq!(dest, nested.join("alpha.md"));
1733        assert!(dest.is_file());
1734    }
1735
1736    #[test]
1737    fn write_skill_template_refuses_to_overwrite() {
1738        let dir = tempfile::tempdir().unwrap();
1739        let path = dir.path().join("alpha.md");
1740        fs::write(&path, "existing").unwrap();
1741        let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1742        assert!(matches!(err, SkillError::Io { .. }));
1743        // Original content preserved.
1744        assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1745    }
1746
1747    #[test]
1748    fn write_skill_template_round_trips_through_registry() {
1749        // End-to-end: write a template, build a registry that
1750        // auto-detects it as a project skill, confirm it resolves.
1751        let dir = tempfile::tempdir().unwrap();
1752        let yaml = dir.path().join("test_mcp.yaml");
1753        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1754        let skills_dir = dir.path().join("test_mcp.skills");
1755        write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1756
1757        let registry = Registry::new()
1758            .auto_detect_project_layer(&yaml)
1759            .finalise()
1760            .unwrap();
1761        let skill = registry
1762            .get("custom_method")
1763            .expect("template should resolve");
1764        assert_eq!(skill.description(), "Project-layer skill body.");
1765    }
1766
1767    // ─── applies_when predicates (Phase 3) ────────────────────────
1768
1769    fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1770        let body = format!(
1771            "---\nname: gated\ndescription: A gated skill.\n\
1772             applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1773        );
1774        let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1775        Skill {
1776            frontmatter,
1777            body,
1778            provenance: SkillProvenance::Bundled,
1779        }
1780    }
1781
1782    #[test]
1783    fn applies_when_parses_map_shape() {
1784        let skill = skill_with_applies_when(
1785            "  graph_has_node_type: [Function, Class]\n\
1786             \x20 tool_registered: cypher_query\n\
1787             \x20 extension_enabled: csv_http_server\n\
1788             \x20 graph_has_property:\n\
1789             \x20   node_type: Function\n\
1790             \x20   prop_name: module",
1791        );
1792        let applies = skill.frontmatter.applies_when.unwrap();
1793        assert_eq!(
1794            applies.graph_has_node_type.as_deref(),
1795            Some(["Function".to_string(), "Class".to_string()].as_slice())
1796        );
1797        assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
1798        assert_eq!(
1799            applies.extension_enabled.as_deref(),
1800            Some("csv_http_server")
1801        );
1802        assert_eq!(
1803            applies.graph_has_property,
1804            Some(GraphPropertyCheck {
1805                node_type: "Function".to_string(),
1806                prop_name: "module".to_string(),
1807            })
1808        );
1809    }
1810
1811    #[test]
1812    fn applies_when_absent_means_always_active() {
1813        let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
1814        let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
1815        let skill = Skill {
1816            frontmatter,
1817            body,
1818            provenance: SkillProvenance::Bundled,
1819        };
1820        let registry = ResolvedRegistry::default();
1821        let activation = registry.activation_for(
1822            &skill,
1823            &std::collections::HashSet::new(),
1824            &serde_json::Map::new(),
1825        );
1826        assert!(activation.active);
1827        assert!(activation.clauses.is_empty());
1828    }
1829
1830    #[test]
1831    fn tool_registered_predicate_dispatches_in_framework() {
1832        let skill = skill_with_applies_when("  tool_registered: cypher_query");
1833        let registry = ResolvedRegistry::default();
1834        let mut tools = std::collections::HashSet::new();
1835
1836        // Tool absent → unsatisfied.
1837        let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1838        assert!(!inactive.active);
1839        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1840
1841        // Tool present → satisfied.
1842        tools.insert("cypher_query".to_string());
1843        let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1844        assert!(active.active);
1845        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1846    }
1847
1848    #[test]
1849    fn extension_enabled_predicate_dispatches_in_framework() {
1850        let skill = skill_with_applies_when("  extension_enabled: csv_http_server");
1851        let registry = ResolvedRegistry::default();
1852        let tools = std::collections::HashSet::new();
1853        let mut extensions = serde_json::Map::new();
1854
1855        // Key absent → unsatisfied.
1856        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1857
1858        // Key with `false` → unsatisfied.
1859        extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
1860        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1861
1862        // Key with `null` → unsatisfied.
1863        extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
1864        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1865
1866        // Key with truthy value → satisfied.
1867        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1868        assert!(registry.activation_for(&skill, &tools, &extensions).active);
1869
1870        // Key with a map → satisfied (truthy).
1871        extensions.insert(
1872            "csv_http_server".to_string(),
1873            serde_json::json!({"enabled": true}),
1874        );
1875        assert!(registry.activation_for(&skill, &tools, &extensions).active);
1876    }
1877
1878    struct StubEvaluator {
1879        has_function: bool,
1880    }
1881    impl SkillPredicateEvaluator for StubEvaluator {
1882        fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
1883            match clause {
1884                PredicateClause::GraphHasNodeType(types) => {
1885                    Some(types.iter().any(|t| t == "Function") && self.has_function)
1886                }
1887                _ => None,
1888            }
1889        }
1890    }
1891
1892    #[test]
1893    fn graph_predicate_dispatches_via_evaluator() {
1894        let skill = skill_with_applies_when("  graph_has_node_type: [Function, Class]");
1895
1896        // With evaluator that says yes → active.
1897        let registry = Registry::new()
1898            .with_predicate_evaluator(StubEvaluator { has_function: true })
1899            .finalise()
1900            .unwrap();
1901        let active = registry.activation_for(
1902            &skill,
1903            &std::collections::HashSet::new(),
1904            &serde_json::Map::new(),
1905        );
1906        assert!(active.active);
1907        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1908
1909        // With evaluator that says no → inactive.
1910        let registry = Registry::new()
1911            .with_predicate_evaluator(StubEvaluator {
1912                has_function: false,
1913            })
1914            .finalise()
1915            .unwrap();
1916        let inactive = registry.activation_for(
1917            &skill,
1918            &std::collections::HashSet::new(),
1919            &serde_json::Map::new(),
1920        );
1921        assert!(!inactive.active);
1922        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1923    }
1924
1925    #[test]
1926    fn graph_predicate_unknown_without_evaluator_means_inactive() {
1927        let skill = skill_with_applies_when("  graph_has_node_type: [Function]");
1928        let registry = ResolvedRegistry::default();
1929        let activation = registry.activation_for(
1930            &skill,
1931            &std::collections::HashSet::new(),
1932            &serde_json::Map::new(),
1933        );
1934        assert!(!activation.active);
1935        assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
1936    }
1937
1938    #[test]
1939    fn multiple_predicates_all_must_be_satisfied() {
1940        let skill = skill_with_applies_when(
1941            "  graph_has_node_type: [Function]\n\
1942             \x20 tool_registered: cypher_query",
1943        );
1944        let registry = Registry::new()
1945            .with_predicate_evaluator(StubEvaluator { has_function: true })
1946            .finalise()
1947            .unwrap();
1948        let mut tools = std::collections::HashSet::new();
1949        let extensions = serde_json::Map::new();
1950
1951        // Graph satisfied but tool absent → inactive.
1952        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1953
1954        // Both satisfied → active.
1955        tools.insert("cypher_query".to_string());
1956        assert!(registry.activation_for(&skill, &tools, &extensions).active);
1957    }
1958}