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/// A non-fatal warning emitted while loading skills — a single file
532/// failed to parse, but the rest of the directory was loaded
533/// successfully.
534///
535/// Surfaced on [`ResolvedRegistry::parse_warnings`] so downstream
536/// binaries can render warnings in their boot summary. Operators
537/// previously had to set up tracing-subscriber filters to see these;
538/// the structured surface makes them visible without log plumbing.
539///
540/// Lands in 0.3.37 in response to an operator hitting an unquoted
541/// colon in a description (`First clause: second clause`) — PyYAML
542/// raised `mapping values are not allowed here` and the loader
543/// silently skipped the file. 25-minute debug session later, the
544/// operator switched to a folded scalar. The lesson: silent skip is
545/// the worst failure mode for a new authoring surface.
546#[derive(Debug, Clone)]
547pub struct ParseWarning {
548    /// The file that failed to load.
549    pub path: PathBuf,
550    /// Human-readable description of why it failed.
551    pub error: String,
552}
553
554/// Walk a directory for `*.md` files, loading each as a skill.
555///
556/// Files that fail to parse are skipped (one malformed skill in a
557/// domain pack shouldn't take down the rest) but their errors are
558/// **both** logged via `tracing::warn!` AND collected for the caller
559/// to surface via [`ResolvedRegistry::parse_warnings`]. Operators
560/// using stdio transport — where tracing output may not be visible —
561/// can still see the warnings through the structured channel.
562pub fn load_skills_from_dir(
563    dir: &Path,
564    provenance: SkillProvenance,
565) -> Result<(Vec<Skill>, Vec<ParseWarning>), SkillError> {
566    if !dir.is_dir() {
567        return Ok((Vec::new(), Vec::new()));
568    }
569
570    let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
571        path: dir.to_path_buf(),
572        source: e,
573    })?;
574
575    let mut skills = Vec::new();
576    let mut warnings = Vec::new();
577    for entry in entries {
578        let entry = match entry {
579            Ok(e) => e,
580            Err(e) => {
581                tracing::warn!(
582                    dir = %dir.display(),
583                    error = %e,
584                    "failed to read directory entry; skipping"
585                );
586                warnings.push(ParseWarning {
587                    path: dir.to_path_buf(),
588                    error: format!("failed to read directory entry: {e}"),
589                });
590                continue;
591            }
592        };
593        let path = entry.path();
594        // Only `.md` files. Subdirectories and other extensions are
595        // ignored (no recursion — keeps the model simple).
596        if path.extension().map(|e| e == "md").unwrap_or(false) {
597            match load_skill_from_file(&path, provenance.clone()) {
598                Ok(skill) => skills.push(skill),
599                Err(e) => {
600                    tracing::warn!(
601                        path = %path.display(),
602                        error = %e,
603                        "failed to load skill; skipping"
604                    );
605                    warnings.push(ParseWarning {
606                        path: path.clone(),
607                        error: e.to_string(),
608                    });
609                }
610            }
611        }
612    }
613    Ok((skills, warnings))
614}
615
616// ─── Path resolution ──────────────────────────────────────────────
617
618/// Resolve a skill path declaration against the manifest's parent
619/// directory, applying the same conventions used by other manifest
620/// fields:
621///
622/// - `./foo` or `foo` → relative to the manifest's parent dir
623/// - `~/foo` → home-relative (POSIX `$HOME` expansion)
624/// - `/foo` or `C:\foo` → absolute
625///
626/// Public so downstream binaries can resolve paths consistently if
627/// they need to.
628pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
629    let p = Path::new(raw);
630    if p.is_absolute() {
631        return p.to_path_buf();
632    }
633    if let Some(rest) = raw.strip_prefix("~/") {
634        if let Some(home) = std::env::var_os("HOME") {
635            return PathBuf::from(home).join(rest);
636        }
637        // No HOME — fall through to manifest-relative.
638    }
639    manifest_dir.join(raw)
640}
641
642/// Project layer path for a manifest: `<manifest_stem>.skills/` next
643/// to the manifest YAML.
644///
645/// For a manifest at `mcp-servers/legal_mcp.yaml`, the project layer
646/// lives at `mcp-servers/legal_mcp.skills/`.
647pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
648    let stem = yaml_path
649        .file_stem()
650        .map(|s| s.to_string_lossy().into_owned())
651        .unwrap_or_else(|| "manifest".to_string());
652    let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
653    parent.join(format!("{stem}.skills"))
654}
655
656// ─── Library-bundled framework defaults ───────────────────────────
657
658/// Return the framework's own bundled skills.
659///
660/// The five SKILL.md files are embedded at compile time via the
661/// [`bundled_skills_index`](crate::server::bundled_skills_index)
662/// submodule. Downstream binaries call this through
663/// [`Registry::merge_framework_defaults`] when they want the
664/// framework defaults at the bottom of their three-layer stack.
665pub fn library_bundled_skills() -> Vec<BundledSkill> {
666    crate::server::bundled_skills_index::library_bundled_skills()
667}
668
669// ─── Authoring template ───────────────────────────────────────────
670
671/// Render a starter SKILL.md body as a string.
672///
673/// The returned text is a complete, parse-valid SKILL.md file with
674/// the supplied `name` and `description` filled into the frontmatter
675/// and the rest of the optional extension fields commented out.
676/// The body follows the anatomy documented in
677/// `docs/guides/writing-effective-skills.md` — Overview, Quick
678/// Reference table, a placeholder major-topic section, Common
679/// Pitfalls, and a "When wrong" section — all with `<TODO>`-style
680/// placeholders the operator fills in.
681///
682/// Use [`write_skill_template`] for the on-disk version.
683pub fn render_skill_template(name: &str, description: &str) -> String {
684    format!(
685        "---\n\
686         name: {name}\n\
687         description: {description}\n\
688         # Optional mcp-methods extension fields (uncomment as needed):\n\
689         # applies_to:\n\
690         #   mcp_methods: \">=0.3.35\"\n\
691         # references_tools:\n\
692         #   - {name}\n\
693         # references_arguments:\n\
694         #   - {name}.<arg_name>\n\
695         # auto_inject_hint: true\n\
696         ---\n\
697         \n\
698         # `{name}` methodology\n\
699         \n\
700         ## Overview\n\
701         \n\
702         <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
703         what comes before and after it in the typical workflow.>\n\
704         \n\
705         ## Quick Reference\n\
706         \n\
707         | Task | Approach |\n\
708         |---|---|\n\
709         | <TODO: common task A> | <TODO: one-line pattern> |\n\
710         | <TODO: common task B> | <TODO: one-line pattern> |\n\
711         \n\
712         ## <TODO: Major topic>\n\
713         \n\
714         <TODO: concrete prose, code blocks, examples.>\n\
715         \n\
716         ## Common Pitfalls\n\
717         \n\
718         ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
719         \n\
720         ✅ <TODO: positive guidance, often a heuristic>\n\
721         \n\
722         ## When `{name}` is the wrong tool\n\
723         \n\
724         - **<TODO: scenario>** — use <other tool> because <reason>.\n"
725    )
726}
727
728/// Resolve where a template write should land and write it.
729///
730/// `dest` interpretation:
731/// - If `dest` is an existing directory, the file is written to
732///   `dest/<name>.md`.
733/// - If `dest` ends in `.md`, it is used verbatim and its parent
734///   must already exist.
735/// - Otherwise `dest` is treated as a directory that should be
736///   created (and its parents created with `create_dir_all`) before
737///   writing `dest/<name>.md`.
738///
739/// Existing files are never overwritten — if the destination already
740/// exists, returns a `SkillError::Io` wrapping `AlreadyExists`. The
741/// caller should delete first if they really want to replace.
742pub fn write_skill_template(
743    dest: &Path,
744    name: &str,
745    description: &str,
746) -> Result<PathBuf, SkillError> {
747    let path = resolve_template_dest(dest, name);
748
749    if path.exists() {
750        return Err(SkillError::Io {
751            path: path.clone(),
752            source: std::io::Error::new(
753                std::io::ErrorKind::AlreadyExists,
754                "destination already exists; delete it before re-running",
755            ),
756        });
757    }
758
759    if let Some(parent) = path.parent() {
760        if !parent.as_os_str().is_empty() && !parent.exists() {
761            fs::create_dir_all(parent).map_err(|e| SkillError::Io {
762                path: parent.to_path_buf(),
763                source: e,
764            })?;
765        }
766    }
767
768    let body = render_skill_template(name, description);
769    fs::write(&path, body).map_err(|e| SkillError::Io {
770        path: path.clone(),
771        source: e,
772    })?;
773    Ok(path)
774}
775
776fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
777    if dest.is_dir() {
778        return dest.join(format!("{name}.md"));
779    }
780    if dest
781        .extension()
782        .map(|e| e.eq_ignore_ascii_case("md"))
783        .unwrap_or(false)
784    {
785        return dest.to_path_buf();
786    }
787    dest.join(format!("{name}.md"))
788}
789
790// ─── Registry builder ─────────────────────────────────────────────
791
792/// Builder for a skills [`ResolvedRegistry`]. Downstream binaries
793/// (`kglite-mcp-server`, etc.) construct one of these in their
794/// boot path, layer in their bundled + operator-declared skills,
795/// then call [`Registry::finalise`] to get the resolved set
796/// ready for MCP `prompts/list` + `prompts/get` wiring.
797///
798/// See the module docs for the canonical usage pattern.
799#[derive(Default)]
800pub struct Registry {
801    bundled: Vec<BundledSkill>,
802    /// Sources from the manifest's `skills:` list, in declaration
803    /// order. Each entry contributes a layer; later entries within
804    /// the root layer have lower priority than earlier ones.
805    root_dirs: Vec<(PathBuf, String)>, // (resolved_path, raw_decl_string)
806    root_includes_bundled: bool,
807    /// Project layer — auto-detected `<basename>.skills/` adjacent
808    /// to the manifest YAML. Set via `auto_detect_project_layer`.
809    project_dir: Option<PathBuf>,
810    /// Optional consumer-supplied evaluator for domain predicates
811    /// (`graph_has_node_type`, `graph_has_property`). Wired in via
812    /// [`Registry::with_predicate_evaluator`]; framework-internal
813    /// predicates (`tool_registered`, `extension_enabled`) are
814    /// dispatched without consulting the evaluator.
815    evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
816}
817
818impl std::fmt::Debug for Registry {
819    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820        f.debug_struct("Registry")
821            .field("bundled", &self.bundled)
822            .field("root_dirs", &self.root_dirs)
823            .field("root_includes_bundled", &self.root_includes_bundled)
824            .field("project_dir", &self.project_dir)
825            .field(
826                "evaluator",
827                &self
828                    .evaluator
829                    .as_ref()
830                    .map(|_| "<dyn SkillPredicateEvaluator>"),
831            )
832            .finish()
833    }
834}
835
836impl Registry {
837    /// Construct an empty registry. Chain in `add_bundled`,
838    /// `merge_framework_defaults`, `layer_dirs`,
839    /// `auto_detect_project_layer`, and optionally
840    /// `with_predicate_evaluator`, then call `finalise()`.
841    pub fn new() -> Self {
842        Self::default()
843    }
844
845    /// Register a domain-specific predicate evaluator for the
846    /// `applies_when:` machinery. The evaluator only sees domain
847    /// predicates (`graph_has_node_type`, `graph_has_property`);
848    /// framework-internal ones (`tool_registered`,
849    /// `extension_enabled`) are dispatched against the
850    /// [`McpServer`](crate::server::McpServer)'s runtime state at
851    /// [`serve_prompts`](crate::server::serve_prompts) time.
852    ///
853    /// Without an evaluator, skills using domain predicates resolve
854    /// to inactive (predicate `Unknown` → skill suppressed). This
855    /// is the safe default: a typo'd predicate or a missing
856    /// evaluator must not silently activate the wrong-domain skill.
857    pub fn with_predicate_evaluator(
858        mut self,
859        evaluator: impl SkillPredicateEvaluator + 'static,
860    ) -> Self {
861        self.evaluator = Some(Arc::new(evaluator));
862        self
863    }
864
865    /// Add a compile-time bundled skill. Typically called by
866    /// downstream binaries with their own `include_str!`'d skills,
867    /// once per custom tool.
868    ///
869    /// Bundled skills sit at the bottom of the three-layer
870    /// composition; later layers override them when names collide.
871    /// Within the bundled set, the downstream binary's skills win
872    /// over framework defaults (the downstream calls `add_bundled`
873    /// before or after `merge_framework_defaults` — order doesn't
874    /// matter; resolution dedupes by name with downstream-first
875    /// priority).
876    ///
877    /// Malformed bundled skills are reported at `finalise()` time
878    /// via [`SkillError::BundledSkillInvalid`]. The framework's
879    /// own bundled-skill CI test should catch this for the library
880    /// defaults; downstream binaries should write equivalent tests
881    /// for their own bundled set.
882    pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
883        self.bundled.push(skill);
884        self
885    }
886
887    /// Add a batch of compile-time bundled skills.
888    pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
889        self.bundled.extend(skills);
890        self
891    }
892
893    /// Merge in the framework's own bundled defaults (returned by
894    /// [`library_bundled_skills`]). Idempotent — calling twice is
895    /// harmless (later calls add duplicates which the finalise
896    /// deduper drops, downstream-first).
897    pub fn merge_framework_defaults(self) -> Self {
898        let defaults = library_bundled_skills();
899        self.add_bundled_many(defaults)
900    }
901
902    /// Layer in skill directories declared in the manifest's
903    /// `skills:` field, walked in declaration order. Each path
904    /// becomes a domain-pack-layer source; the bundled marker
905    /// `true` is acknowledged but its skills are already in the
906    /// bundled layer via `add_bundled`/`merge_framework_defaults`.
907    ///
908    /// Path resolution uses the same conventions as the rest of the
909    /// manifest (`./foo` relative to YAML dir, `~/foo` home-relative,
910    /// `/foo` absolute). Non-existent paths are reported as
911    /// [`SkillError::PathNotFound`] at this call site so operators
912    /// see typos immediately.
913    pub fn layer_dirs(
914        mut self,
915        source: &SkillsSource,
916        yaml_path: &Path,
917    ) -> Result<Self, SkillError> {
918        let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
919
920        match source {
921            SkillsSource::Disabled => {
922                // Skills disabled entirely — return the registry
923                // unchanged. Downstream may still have called
924                // add_bundled, but those won't be reachable without
925                // a layer telling us skills are enabled.
926                self.root_includes_bundled = false;
927            }
928            SkillsSource::Sources(sources) => {
929                for src in sources {
930                    match src {
931                        SkillSource::Bundled => {
932                            self.root_includes_bundled = true;
933                        }
934                        SkillSource::Path(raw) => {
935                            let resolved = resolve_skill_path(raw, manifest_dir);
936                            if !resolved.is_dir() {
937                                return Err(SkillError::PathNotFound {
938                                    raw: raw.clone(),
939                                    resolved,
940                                });
941                            }
942                            self.root_dirs.push((resolved, raw.clone()));
943                        }
944                    }
945                }
946            }
947        }
948
949        Ok(self)
950    }
951
952    /// Auto-detect the project layer at `<basename>.skills/`
953    /// adjacent to the manifest YAML. Always called; the directory
954    /// is optional — if it doesn't exist, the project layer is
955    /// simply empty.
956    pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
957        let candidate = project_skills_dir(yaml_path);
958        if candidate.is_dir() {
959            self.project_dir = Some(candidate);
960        }
961        self
962    }
963
964    /// Resolve all three layers and return the final registry.
965    ///
966    /// Resolution order per skill name: project > root layer
967    /// (in declaration order) > bundled. The first source that
968    /// contributes a skill with the given name wins; later sources
969    /// are ignored for that name (no merging, no inheritance —
970    /// full-file replacement).
971    ///
972    /// At this point the framework:
973    /// - Parses all skill files (frontmatter validation)
974    /// - Logs collision-resolution info via `tracing::info!` per skill
975    /// - Enforces per-skill hard size limits ([`HARD_SIZE_LIMIT_BYTES`])
976    /// - Warns on per-skill soft size limit ([`SOFT_SIZE_LIMIT_BYTES`])
977    /// - Warns on session total exceeding [`SESSION_TOTAL_LIMIT_BYTES`]
978    pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
979        let Self {
980            bundled,
981            root_dirs,
982            root_includes_bundled,
983            project_dir,
984            evaluator,
985        } = self;
986
987        // Parse bundled skills first. These are the lowest-priority
988        // layer; they get overridden by anything declared above.
989        let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
990        if root_includes_bundled {
991            for b in &bundled {
992                let path = PathBuf::from(format!("<bundled:{}>", b.name));
993                let (frontmatter, body) =
994                    parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
995                        name: b.name,
996                        message: e.to_string(),
997                    })?;
998                if frontmatter.name != b.name {
999                    return Err(SkillError::BundledSkillInvalid {
1000                        name: b.name,
1001                        message: format!(
1002                            "frontmatter name {:?} does not match the bundled key {:?}",
1003                            frontmatter.name, b.name
1004                        ),
1005                    });
1006                }
1007                bundled_skills.push(Skill {
1008                    frontmatter,
1009                    body,
1010                    provenance: SkillProvenance::Bundled,
1011                });
1012            }
1013        }
1014
1015        // Root layer: walk each declared path; first wins per name.
1016        // Accumulate parse warnings across all layers so the resolved
1017        // registry can surface them to downstream binaries.
1018        let mut parse_warnings: Vec<ParseWarning> = Vec::new();
1019        let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
1020        for (resolved, _raw) in &root_dirs {
1021            let provenance = SkillProvenance::DomainPack(resolved.clone());
1022            let (skills, warnings) = load_skills_from_dir(resolved, provenance)?;
1023            parse_warnings.extend(warnings);
1024            root_skills_per_dir.push(skills);
1025        }
1026
1027        // Project layer: auto-detected adjacent dir.
1028        let project_skills: Vec<Skill> = match &project_dir {
1029            Some(dir) => {
1030                let (skills, warnings) = load_skills_from_dir(dir, SkillProvenance::Project)?;
1031                parse_warnings.extend(warnings);
1032                skills
1033            }
1034            None => Vec::new(),
1035        };
1036
1037        // Resolve per skill name. Priority:
1038        //   1. Project layer
1039        //   2. Root layer entries in declaration order
1040        //   3. Bundled (downstream entries first, then framework)
1041        //
1042        // The bundled list is already in downstream-first order
1043        // because downstream binaries call `add_bundled` before
1044        // `merge_framework_defaults` by convention.
1045
1046        let mut resolved: HashMap<String, Skill> = HashMap::new();
1047        let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1048
1049        // Lowest priority first: bundled, then root in reverse
1050        // declaration order, then project. Later inserts overwrite.
1051        // We track collisions for the boot log.
1052        for skill in &bundled_skills {
1053            let name = skill.name().to_string();
1054            collisions
1055                .entry(name.clone())
1056                .or_default()
1057                .push(skill.provenance.clone());
1058            resolved.insert(name, skill.clone());
1059        }
1060        for skills in root_skills_per_dir.iter().rev() {
1061            for skill in skills {
1062                let name = skill.name().to_string();
1063                collisions
1064                    .entry(name.clone())
1065                    .or_default()
1066                    .push(skill.provenance.clone());
1067                resolved.insert(name, skill.clone());
1068            }
1069        }
1070        for skill in &project_skills {
1071            let name = skill.name().to_string();
1072            collisions
1073                .entry(name.clone())
1074                .or_default()
1075                .push(skill.provenance.clone());
1076            resolved.insert(name, skill.clone());
1077        }
1078
1079        // Log collision resolution for skills with more than one
1080        // candidate. Single-candidate skills don't need a log line.
1081        for (name, candidates) in &collisions {
1082            if candidates.len() > 1 {
1083                let winner = resolved
1084                    .get(name)
1085                    .map(|s| format_provenance(&s.provenance))
1086                    .unwrap_or_else(|| "<none>".to_string());
1087                let all_candidates: Vec<String> =
1088                    candidates.iter().map(format_provenance).collect();
1089                tracing::info!(
1090                    skill = %name,
1091                    candidates = ?all_candidates,
1092                    winner = %winner,
1093                    "skill resolved across multiple layers"
1094                );
1095            }
1096        }
1097
1098        // Check session-total size limit.
1099        let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1100        if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1101            tracing::warn!(
1102                total_bytes,
1103                limit = SESSION_TOTAL_LIMIT_BYTES,
1104                skill_count = resolved.len(),
1105                "total resolved skill body size exceeds session limit; \
1106                 consider trimming or splitting skills"
1107            );
1108        }
1109
1110        Ok(ResolvedRegistry {
1111            skills: resolved,
1112            evaluator,
1113            parse_warnings,
1114        })
1115    }
1116}
1117
1118fn format_provenance(p: &SkillProvenance) -> String {
1119    match p {
1120        SkillProvenance::Project => "project".to_string(),
1121        SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1122        SkillProvenance::Bundled => "bundled".to_string(),
1123    }
1124}
1125
1126// ─── ResolvedRegistry ─────────────────────────────────────────────
1127
1128/// The post-resolution skill set. Consumed by `serve_prompts`
1129/// (Phase 1c) to wire `prompts/list` and `prompts/get` on the
1130/// MCP server.
1131#[derive(Default)]
1132pub struct ResolvedRegistry {
1133    skills: HashMap<String, Skill>,
1134    /// Optional domain-predicate evaluator carried from
1135    /// [`Registry::with_predicate_evaluator`]. `serve_prompts`
1136    /// consults this when evaluating `applies_when:` blocks; absent
1137    /// means domain predicates resolve to `Unknown` → skill
1138    /// inactive.
1139    pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1140    /// Non-fatal per-file load failures (silent skips). Empty in the
1141    /// happy path; populated when a SKILL.md fails to parse and the
1142    /// rest of the directory is loaded around it. Downstream binaries
1143    /// render these in their boot summary so operators see what was
1144    /// silently dropped without having to enable tracing.
1145    parse_warnings: Vec<ParseWarning>,
1146}
1147
1148impl std::fmt::Debug for ResolvedRegistry {
1149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150        f.debug_struct("ResolvedRegistry")
1151            .field("skills", &self.skills)
1152            .field(
1153                "evaluator",
1154                &self
1155                    .evaluator
1156                    .as_ref()
1157                    .map(|_| "<dyn SkillPredicateEvaluator>"),
1158            )
1159            .finish()
1160    }
1161}
1162
1163impl ResolvedRegistry {
1164    /// All resolved skill names, sorted alphabetically for stable
1165    /// output in `prompts/list`.
1166    pub fn skill_names(&self) -> Vec<String> {
1167        let mut names: Vec<String> = self.skills.keys().cloned().collect();
1168        names.sort();
1169        names
1170    }
1171
1172    /// Look up a skill by name. Used by `prompts/get` to fetch the
1173    /// full body when the agent requests it.
1174    pub fn get(&self, name: &str) -> Option<&Skill> {
1175        self.skills.get(name)
1176    }
1177
1178    /// Iterate all resolved skills. Order is unspecified — use
1179    /// `skill_names()` first if a deterministic iteration is needed.
1180    pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1181        self.skills.iter()
1182    }
1183
1184    /// Number of resolved skills.
1185    pub fn len(&self) -> usize {
1186        self.skills.len()
1187    }
1188
1189    /// Whether the registry contains any skills.
1190    pub fn is_empty(&self) -> bool {
1191        self.skills.is_empty()
1192    }
1193
1194    /// Per-file load failures that were silently skipped. Empty in
1195    /// the happy path. Each entry names the path and the error so
1196    /// downstream binaries can render them in their boot summary —
1197    /// the durable channel for visibility into "this file was
1198    /// silently dropped" failures that previously cost a 25-minute
1199    /// debug session per operator.
1200    pub fn parse_warnings(&self) -> &[ParseWarning] {
1201        &self.parse_warnings
1202    }
1203
1204    /// Evaluate the `applies_when:` block on `skill` against this
1205    /// registry's evaluator plus the supplied runtime state. Returns
1206    /// the per-clause outcomes plus whether the skill should be
1207    /// considered active.
1208    ///
1209    /// `registered_tools` and `extensions` carry the runtime state
1210    /// the framework-internal predicates check against.
1211    /// `serve_prompts` calls this for every skill at boot;
1212    /// `skills-list` calls it (with placeholder empty state) for
1213    /// the operator-facing summary.
1214    ///
1215    /// A skill without an `applies_when:` block is always active.
1216    pub fn activation_for(
1217        &self,
1218        skill: &Skill,
1219        registered_tools: &std::collections::HashSet<String>,
1220        extensions: &serde_json::Map<String, serde_json::Value>,
1221    ) -> SkillActivation {
1222        let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1223            return SkillActivation {
1224                active: true,
1225                clauses: Vec::new(),
1226            };
1227        };
1228        let mut clauses = Vec::new();
1229        let mut all_satisfied = true;
1230
1231        if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1232            let clause = PredicateClause::GraphHasNodeType(types);
1233            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1234            if outcome != PredicateOutcome::Satisfied {
1235                all_satisfied = false;
1236            }
1237            clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1238        }
1239        if let Some(prop) = applies_when.graph_has_property.as_ref() {
1240            let clause = PredicateClause::GraphHasProperty {
1241                node_type: &prop.node_type,
1242                prop_name: &prop.prop_name,
1243            };
1244            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1245            if outcome != PredicateOutcome::Satisfied {
1246                all_satisfied = false;
1247            }
1248            clauses.push((
1249                format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1250                outcome,
1251            ));
1252        }
1253        if let Some(tool) = applies_when.tool_registered.as_ref() {
1254            let clause = PredicateClause::ToolRegistered(tool);
1255            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1256            if outcome != PredicateOutcome::Satisfied {
1257                all_satisfied = false;
1258            }
1259            clauses.push((format!("tool_registered: {tool}"), outcome));
1260        }
1261        if let Some(key) = applies_when.extension_enabled.as_ref() {
1262            let clause = PredicateClause::ExtensionEnabled(key);
1263            let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1264            if outcome != PredicateOutcome::Satisfied {
1265                all_satisfied = false;
1266            }
1267            clauses.push((format!("extension_enabled: {key}"), outcome));
1268        }
1269
1270        SkillActivation {
1271            active: all_satisfied,
1272            clauses,
1273        }
1274    }
1275
1276    fn dispatch_clause(
1277        &self,
1278        clause: &PredicateClause<'_>,
1279        registered_tools: &std::collections::HashSet<String>,
1280        extensions: &serde_json::Map<String, serde_json::Value>,
1281    ) -> PredicateOutcome {
1282        // Framework-internal predicates are dispatched in-framework
1283        // regardless of the evaluator's preference. This keeps
1284        // tool_registered + extension_enabled working even when no
1285        // evaluator is registered.
1286        match clause {
1287            PredicateClause::ToolRegistered(name) => {
1288                return if registered_tools.contains(*name) {
1289                    PredicateOutcome::Satisfied
1290                } else {
1291                    PredicateOutcome::Unsatisfied
1292                };
1293            }
1294            PredicateClause::ExtensionEnabled(key) => {
1295                let truthy = extensions
1296                    .get(*key)
1297                    .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1298                    .unwrap_or(false);
1299                return if truthy {
1300                    PredicateOutcome::Satisfied
1301                } else {
1302                    PredicateOutcome::Unsatisfied
1303                };
1304            }
1305            _ => {}
1306        }
1307
1308        // Domain predicates: defer to the evaluator. Unknown when no
1309        // evaluator is registered or the evaluator returns None.
1310        match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1311            Some(true) => PredicateOutcome::Satisfied,
1312            Some(false) => PredicateOutcome::Unsatisfied,
1313            None => PredicateOutcome::Unknown,
1314        }
1315    }
1316}
1317
1318// ─── Tests ────────────────────────────────────────────────────────
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::*;
1323    use std::io::Write;
1324
1325    fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1326        let path = dir.join(format!("{name}.md"));
1327        let mut f = fs::File::create(&path).unwrap();
1328        f.write_all(content.as_bytes()).unwrap();
1329        path
1330    }
1331
1332    fn minimal_skill(name: &str) -> String {
1333        format!(
1334            "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1335        )
1336    }
1337
1338    // ─── Frontmatter parsing ──────────────────────────────────────
1339
1340    #[test]
1341    fn parse_frontmatter_basic() {
1342        let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1343        let path = PathBuf::from("test.md");
1344        let (fm, body) = parse_skill(content, &path).unwrap();
1345        assert_eq!(fm.name, "foo");
1346        assert_eq!(fm.description, "A foo skill.");
1347        assert_eq!(body, "\nBody here.\n");
1348        assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1349    }
1350
1351    #[test]
1352    fn parse_frontmatter_missing_delimiters_rejected() {
1353        let content = "name: foo\ndescription: bar\n";
1354        let path = PathBuf::from("test.md");
1355        let err = parse_skill(content, &path).unwrap_err();
1356        assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1357    }
1358
1359    #[test]
1360    fn parse_frontmatter_invalid_yaml_rejected() {
1361        let content = "---\nname: foo\n  bad: yaml: nesting\n---\nbody\n";
1362        let path = PathBuf::from("test.md");
1363        let err = parse_skill(content, &path).unwrap_err();
1364        assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1365    }
1366
1367    #[test]
1368    fn parse_frontmatter_missing_name_rejected() {
1369        let content = "---\ndescription: bar\n---\nbody\n";
1370        let path = PathBuf::from("test.md");
1371        let err = parse_skill(content, &path).unwrap_err();
1372        assert!(matches!(
1373            err,
1374            SkillError::MissingRequiredField { field: "name", .. }
1375        ));
1376    }
1377
1378    #[test]
1379    fn parse_frontmatter_missing_description_rejected() {
1380        let content = "---\nname: foo\n---\nbody\n";
1381        let path = PathBuf::from("test.md");
1382        let err = parse_skill(content, &path).unwrap_err();
1383        assert!(matches!(
1384            err,
1385            SkillError::MissingRequiredField {
1386                field: "description",
1387                ..
1388            }
1389        ));
1390    }
1391
1392    #[test]
1393    fn parse_frontmatter_all_optional_fields() {
1394        let content = "---\n\
1395name: foo\n\
1396description: Full surface.\n\
1397references_tools: [grep, list_source]\n\
1398references_arguments: [grep.pattern]\n\
1399references_properties: [Function.module]\n\
1400auto_inject_hint: false\n\
1401applies_to:\n  mcp_methods: \">=0.3.35\"\n\
1402---\n\
1403Body.\n";
1404        let path = PathBuf::from("test.md");
1405        let (fm, _) = parse_skill(content, &path).unwrap();
1406        assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1407        assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1408        assert_eq!(fm.references_properties, vec!["Function.module"]);
1409        assert!(!fm.auto_inject_hint);
1410        assert_eq!(
1411            fm.applies_to.unwrap().get("mcp_methods"),
1412            Some(&">=0.3.35".to_string())
1413        );
1414    }
1415
1416    // ─── Loading from files + dirs ────────────────────────────────
1417
1418    #[test]
1419    fn load_skill_from_file_basic() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1422        let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1423        assert_eq!(skill.name(), "foo");
1424        assert_eq!(skill.provenance, SkillProvenance::Project);
1425    }
1426
1427    #[test]
1428    fn load_skill_too_large_rejected() {
1429        let dir = tempfile::tempdir().unwrap();
1430        // Build a body just over the hard limit.
1431        let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1432        let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1433        let path = write_skill(dir.path(), "big", &content);
1434        let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1435        assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1436    }
1437
1438    #[test]
1439    fn load_skills_from_dir_walks_markdown_only() {
1440        let dir = tempfile::tempdir().unwrap();
1441        write_skill(dir.path(), "a", &minimal_skill("a"));
1442        write_skill(dir.path(), "b", &minimal_skill("b"));
1443        // Non-markdown file — ignored.
1444        fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1445        // Subdirectory — ignored.
1446        let sub = dir.path().join("sub");
1447        fs::create_dir(&sub).unwrap();
1448        write_skill(&sub, "c", &minimal_skill("c"));
1449
1450        let (skills, warnings) =
1451            load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1452        assert_eq!(skills.len(), 2);
1453        assert!(warnings.is_empty());
1454        let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1455        names.sort();
1456        assert_eq!(names, vec!["a", "b"]);
1457    }
1458
1459    #[test]
1460    fn load_skills_from_dir_missing_returns_empty() {
1461        let dir = tempfile::tempdir().unwrap();
1462        let nonexistent = dir.path().join("does-not-exist");
1463        let (skills, warnings) =
1464            load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1465        assert!(skills.is_empty());
1466        assert!(warnings.is_empty());
1467    }
1468
1469    #[test]
1470    fn load_skills_from_dir_surfaces_yaml_parse_failure_as_warning() {
1471        // The exact scenario the operator hit: unquoted colon in
1472        // description value triggers PyYAML's "mapping values are
1473        // not allowed here" — except ours uses serde_yaml so the
1474        // failure mode is `InvalidFrontmatter`. Either way, the
1475        // file is skipped, the rest of the dir loads, and the
1476        // warning surfaces structurally rather than just via
1477        // tracing::warn!.
1478        let dir = tempfile::tempdir().unwrap();
1479        // Valid skill.
1480        write_skill(dir.path(), "good", &minimal_skill("good"));
1481        // Broken skill: unquoted colon inside description.
1482        write_skill(
1483            dir.path(),
1484            "broken",
1485            "---\nname: broken\ndescription: First clause: second clause\n---\n# body\n",
1486        );
1487
1488        let (skills, warnings) =
1489            load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1490        assert_eq!(skills.len(), 1, "the good skill should still load");
1491        assert_eq!(skills[0].name(), "good");
1492        assert_eq!(
1493            warnings.len(),
1494            1,
1495            "the broken file should surface as a warning"
1496        );
1497        assert!(warnings[0].path.ends_with("broken.md"));
1498        assert!(!warnings[0].error.is_empty());
1499    }
1500
1501    #[test]
1502    fn resolved_registry_parse_warnings_propagated_from_project_layer() {
1503        // End-to-end through `Registry::finalise`: a broken file in
1504        // the project layer shows up on `ResolvedRegistry::parse_warnings`.
1505        let dir = tempfile::tempdir().unwrap();
1506        let yaml = dir.path().join("test_mcp.yaml");
1507        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1508        let skills_dir = dir.path().join("test_mcp.skills");
1509        fs::create_dir(&skills_dir).unwrap();
1510        // Valid skill.
1511        write_skill(&skills_dir, "good", &minimal_skill("good"));
1512        // Broken skill: missing closing `---`.
1513        write_skill(
1514            &skills_dir,
1515            "broken",
1516            "---\nname: broken\ndescription: bad\nstill in frontmatter\n",
1517        );
1518
1519        let registry = Registry::new()
1520            .auto_detect_project_layer(&yaml)
1521            .finalise()
1522            .unwrap();
1523
1524        assert_eq!(registry.len(), 1, "good skill resolved");
1525        assert!(registry.get("good").is_some());
1526        let warnings = registry.parse_warnings();
1527        assert_eq!(warnings.len(), 1);
1528        assert!(warnings[0].path.ends_with("broken.md"));
1529    }
1530
1531    // ─── Path resolution ──────────────────────────────────────────
1532
1533    #[test]
1534    fn resolve_skill_path_relative() {
1535        let manifest_dir = Path::new("/a/b");
1536        assert_eq!(
1537            resolve_skill_path("./skills", manifest_dir),
1538            PathBuf::from("/a/b/./skills")
1539        );
1540        assert_eq!(
1541            resolve_skill_path("skills", manifest_dir),
1542            PathBuf::from("/a/b/skills")
1543        );
1544    }
1545
1546    #[test]
1547    fn resolve_skill_path_absolute() {
1548        let manifest_dir = Path::new("/a/b");
1549        assert_eq!(
1550            resolve_skill_path("/abs/skills", manifest_dir),
1551            PathBuf::from("/abs/skills")
1552        );
1553    }
1554
1555    #[test]
1556    fn resolve_skill_path_home_relative() {
1557        let manifest_dir = Path::new("/a/b");
1558        // Set HOME explicitly for the test.
1559        // SAFETY: tests run single-threaded for env mutation; this is
1560        // a known stylistic exception in Rust's 1.83+ unsafe-env API.
1561        unsafe {
1562            std::env::set_var("HOME", "/home/test");
1563        }
1564        assert_eq!(
1565            resolve_skill_path("~/skills", manifest_dir),
1566            PathBuf::from("/home/test/skills")
1567        );
1568    }
1569
1570    #[test]
1571    fn project_skills_dir_naming() {
1572        assert_eq!(
1573            project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1574            PathBuf::from("/a/b/legal_mcp.skills")
1575        );
1576        assert_eq!(
1577            project_skills_dir(Path::new("workspace_mcp.yaml")),
1578            PathBuf::from("workspace_mcp.skills")
1579        );
1580    }
1581
1582    // ─── Registry builder ─────────────────────────────────────────
1583
1584    #[test]
1585    fn registry_disabled_resolves_empty() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let yaml = dir.path().join("test_mcp.yaml");
1588        fs::write(&yaml, "name: x\n").unwrap();
1589
1590        let registry = Registry::new()
1591            .layer_dirs(&SkillsSource::Disabled, &yaml)
1592            .unwrap()
1593            .auto_detect_project_layer(&yaml)
1594            .finalise()
1595            .unwrap();
1596        assert!(registry.is_empty());
1597    }
1598
1599    #[test]
1600    fn registry_add_bundled_only_visible_when_opted_in() {
1601        let dir = tempfile::tempdir().unwrap();
1602        let yaml = dir.path().join("test_mcp.yaml");
1603        fs::write(&yaml, "name: x\n").unwrap();
1604
1605        let bundled = BundledSkill {
1606            name: "foo",
1607            // Static body for testing — needs to be 'static, which is
1608            // why BundledSkill uses &'static str. For the test we
1609            // leak. Production code uses include_str!.
1610            body: Box::leak(minimal_skill("foo").into_boxed_str()),
1611        };
1612
1613        // Disabled → bundled is NOT visible, even if added.
1614        let registry = Registry::new()
1615            .add_bundled(bundled.clone())
1616            .layer_dirs(&SkillsSource::Disabled, &yaml)
1617            .unwrap()
1618            .finalise()
1619            .unwrap();
1620        assert!(registry.is_empty(), "disabled must short-circuit bundled");
1621
1622        // skills: [true] → bundled IS visible.
1623        let registry = Registry::new()
1624            .add_bundled(bundled)
1625            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1626            .unwrap()
1627            .finalise()
1628            .unwrap();
1629        assert_eq!(registry.len(), 1);
1630        assert!(registry.get("foo").is_some());
1631        assert_eq!(
1632            registry.get("foo").unwrap().provenance,
1633            SkillProvenance::Bundled
1634        );
1635    }
1636
1637    #[test]
1638    fn registry_three_layer_resolution_project_wins_over_bundled() {
1639        let dir = tempfile::tempdir().unwrap();
1640        let yaml = dir.path().join("test_mcp.yaml");
1641        fs::write(&yaml, "name: x\n").unwrap();
1642
1643        // Bundled `foo`:
1644        let bundled = BundledSkill {
1645            name: "foo",
1646            body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1647        };
1648
1649        // Project layer `foo`:
1650        let project_dir = dir.path().join("test_mcp.skills");
1651        fs::create_dir(&project_dir).unwrap();
1652        fs::write(
1653            project_dir.join("foo.md"),
1654            "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1655        )
1656        .unwrap();
1657
1658        let registry = Registry::new()
1659            .add_bundled(bundled)
1660            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1661            .unwrap()
1662            .auto_detect_project_layer(&yaml)
1663            .finalise()
1664            .unwrap();
1665
1666        assert_eq!(registry.len(), 1);
1667        let skill = registry.get("foo").unwrap();
1668        assert_eq!(skill.description(), "from project.");
1669        assert_eq!(skill.provenance, SkillProvenance::Project);
1670    }
1671
1672    #[test]
1673    fn registry_root_layer_first_declaration_wins() {
1674        let dir = tempfile::tempdir().unwrap();
1675        let yaml = dir.path().join("test_mcp.yaml");
1676        fs::write(&yaml, "name: x\n").unwrap();
1677
1678        // First domain pack: foo (from "primary").
1679        let primary = dir.path().join("primary");
1680        fs::create_dir(&primary).unwrap();
1681        fs::write(
1682            primary.join("foo.md"),
1683            "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1684        )
1685        .unwrap();
1686
1687        // Second domain pack: foo (from "secondary") — should LOSE.
1688        let secondary = dir.path().join("secondary");
1689        fs::create_dir(&secondary).unwrap();
1690        fs::write(
1691            secondary.join("foo.md"),
1692            "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1693        )
1694        .unwrap();
1695
1696        let registry = Registry::new()
1697            .layer_dirs(
1698                &SkillsSource::Sources(vec![
1699                    SkillSource::Path("./primary".into()),
1700                    SkillSource::Path("./secondary".into()),
1701                ]),
1702                &yaml,
1703            )
1704            .unwrap()
1705            .finalise()
1706            .unwrap();
1707
1708        assert_eq!(registry.len(), 1);
1709        assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1710    }
1711
1712    #[test]
1713    fn registry_root_layer_nonexistent_path_rejected() {
1714        let dir = tempfile::tempdir().unwrap();
1715        let yaml = dir.path().join("test_mcp.yaml");
1716        fs::write(&yaml, "name: x\n").unwrap();
1717
1718        let err = Registry::new()
1719            .layer_dirs(
1720                &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1721                &yaml,
1722            )
1723            .unwrap_err();
1724        assert!(matches!(err, SkillError::PathNotFound { .. }));
1725    }
1726
1727    #[test]
1728    fn registry_empty_list_opts_in_without_root_sources() {
1729        let dir = tempfile::tempdir().unwrap();
1730        let yaml = dir.path().join("test_mcp.yaml");
1731        fs::write(&yaml, "name: x\n").unwrap();
1732
1733        // No bundled, no paths — but project layer DOES exist.
1734        let project_dir = dir.path().join("test_mcp.skills");
1735        fs::create_dir(&project_dir).unwrap();
1736        fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1737
1738        let registry = Registry::new()
1739            .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1740            .unwrap()
1741            .auto_detect_project_layer(&yaml)
1742            .finalise()
1743            .unwrap();
1744
1745        assert_eq!(registry.len(), 1);
1746        assert_eq!(
1747            registry.get("only").unwrap().provenance,
1748            SkillProvenance::Project
1749        );
1750    }
1751
1752    #[test]
1753    fn registry_bundled_name_mismatch_rejected_at_finalise() {
1754        let dir = tempfile::tempdir().unwrap();
1755        let yaml = dir.path().join("test_mcp.yaml");
1756        fs::write(&yaml, "name: x\n").unwrap();
1757
1758        // BundledSkill says name="foo" but the frontmatter says name="bar".
1759        let bundled = BundledSkill {
1760            name: "foo",
1761            body: Box::leak(
1762                "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1763                    .to_string()
1764                    .into_boxed_str(),
1765            ),
1766        };
1767
1768        let err = Registry::new()
1769            .add_bundled(bundled)
1770            .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1771            .unwrap()
1772            .finalise()
1773            .unwrap_err();
1774        assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1775    }
1776
1777    #[test]
1778    fn registry_library_bundled_skills_returns_vec() {
1779        // Five framework defaults ship from Phase 1d onward. The
1780        // exhaustive shape + uniqueness checks live in
1781        // `bundled_skills_index::tests`; here we just confirm the
1782        // re-export points downstream callers at the populated Vec.
1783        let skills = library_bundled_skills();
1784        assert!(
1785            !skills.is_empty(),
1786            "library_bundled_skills should return framework defaults from Phase 1d onward"
1787        );
1788    }
1789
1790    #[test]
1791    fn registry_skill_names_sorted() {
1792        let dir = tempfile::tempdir().unwrap();
1793        let yaml = dir.path().join("test_mcp.yaml");
1794        fs::write(&yaml, "name: x\n").unwrap();
1795
1796        let pack = dir.path().join("pack");
1797        fs::create_dir(&pack).unwrap();
1798        fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1799        fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1800        fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1801
1802        let registry = Registry::new()
1803            .layer_dirs(
1804                &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1805                &yaml,
1806            )
1807            .unwrap()
1808            .finalise()
1809            .unwrap();
1810
1811        assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1812    }
1813
1814    // ─── Authoring template ───────────────────────────────────────
1815
1816    #[test]
1817    fn render_skill_template_is_parse_valid() {
1818        // Round-trip: a freshly-rendered template must parse cleanly
1819        // through `parse_skill` so the operator's starting point is
1820        // never broken.
1821        let body = render_skill_template("custom_method", "A test description for the skill.");
1822        let (fm, _body) =
1823            parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1824        assert_eq!(fm.name, "custom_method");
1825        assert_eq!(fm.description, "A test description for the skill.");
1826    }
1827
1828    #[test]
1829    fn render_skill_template_substitutes_name_into_body_headings() {
1830        let body = render_skill_template("my_skill", "desc");
1831        assert!(body.contains("# `my_skill` methodology"));
1832        assert!(body.contains("## When `my_skill` is the wrong tool"));
1833    }
1834
1835    #[test]
1836    fn write_skill_template_writes_into_directory() {
1837        let dir = tempfile::tempdir().unwrap();
1838        let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1839        assert_eq!(dest, dir.path().join("alpha.md"));
1840        let content = fs::read_to_string(&dest).unwrap();
1841        assert!(content.contains("name: alpha"));
1842    }
1843
1844    #[test]
1845    fn write_skill_template_writes_to_explicit_md_path() {
1846        let dir = tempfile::tempdir().unwrap();
1847        let explicit = dir.path().join("renamed.md");
1848        let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1849        assert_eq!(dest, explicit);
1850        assert!(explicit.is_file());
1851    }
1852
1853    #[test]
1854    fn write_skill_template_creates_missing_parents() {
1855        let dir = tempfile::tempdir().unwrap();
1856        let nested = dir.path().join("a/b/c");
1857        let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1858        assert_eq!(dest, nested.join("alpha.md"));
1859        assert!(dest.is_file());
1860    }
1861
1862    #[test]
1863    fn write_skill_template_refuses_to_overwrite() {
1864        let dir = tempfile::tempdir().unwrap();
1865        let path = dir.path().join("alpha.md");
1866        fs::write(&path, "existing").unwrap();
1867        let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1868        assert!(matches!(err, SkillError::Io { .. }));
1869        // Original content preserved.
1870        assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1871    }
1872
1873    #[test]
1874    fn write_skill_template_round_trips_through_registry() {
1875        // End-to-end: write a template, build a registry that
1876        // auto-detects it as a project skill, confirm it resolves.
1877        let dir = tempfile::tempdir().unwrap();
1878        let yaml = dir.path().join("test_mcp.yaml");
1879        fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1880        let skills_dir = dir.path().join("test_mcp.skills");
1881        write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1882
1883        let registry = Registry::new()
1884            .auto_detect_project_layer(&yaml)
1885            .finalise()
1886            .unwrap();
1887        let skill = registry
1888            .get("custom_method")
1889            .expect("template should resolve");
1890        assert_eq!(skill.description(), "Project-layer skill body.");
1891    }
1892
1893    // ─── applies_when predicates (Phase 3) ────────────────────────
1894
1895    fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1896        let body = format!(
1897            "---\nname: gated\ndescription: A gated skill.\n\
1898             applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1899        );
1900        let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1901        Skill {
1902            frontmatter,
1903            body,
1904            provenance: SkillProvenance::Bundled,
1905        }
1906    }
1907
1908    #[test]
1909    fn applies_when_parses_map_shape() {
1910        let skill = skill_with_applies_when(
1911            "  graph_has_node_type: [Function, Class]\n\
1912             \x20 tool_registered: cypher_query\n\
1913             \x20 extension_enabled: csv_http_server\n\
1914             \x20 graph_has_property:\n\
1915             \x20   node_type: Function\n\
1916             \x20   prop_name: module",
1917        );
1918        let applies = skill.frontmatter.applies_when.unwrap();
1919        assert_eq!(
1920            applies.graph_has_node_type.as_deref(),
1921            Some(["Function".to_string(), "Class".to_string()].as_slice())
1922        );
1923        assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
1924        assert_eq!(
1925            applies.extension_enabled.as_deref(),
1926            Some("csv_http_server")
1927        );
1928        assert_eq!(
1929            applies.graph_has_property,
1930            Some(GraphPropertyCheck {
1931                node_type: "Function".to_string(),
1932                prop_name: "module".to_string(),
1933            })
1934        );
1935    }
1936
1937    #[test]
1938    fn applies_when_absent_means_always_active() {
1939        let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
1940        let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
1941        let skill = Skill {
1942            frontmatter,
1943            body,
1944            provenance: SkillProvenance::Bundled,
1945        };
1946        let registry = ResolvedRegistry::default();
1947        let activation = registry.activation_for(
1948            &skill,
1949            &std::collections::HashSet::new(),
1950            &serde_json::Map::new(),
1951        );
1952        assert!(activation.active);
1953        assert!(activation.clauses.is_empty());
1954    }
1955
1956    #[test]
1957    fn tool_registered_predicate_dispatches_in_framework() {
1958        let skill = skill_with_applies_when("  tool_registered: cypher_query");
1959        let registry = ResolvedRegistry::default();
1960        let mut tools = std::collections::HashSet::new();
1961
1962        // Tool absent → unsatisfied.
1963        let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1964        assert!(!inactive.active);
1965        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1966
1967        // Tool present → satisfied.
1968        tools.insert("cypher_query".to_string());
1969        let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1970        assert!(active.active);
1971        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1972    }
1973
1974    #[test]
1975    fn extension_enabled_predicate_dispatches_in_framework() {
1976        let skill = skill_with_applies_when("  extension_enabled: csv_http_server");
1977        let registry = ResolvedRegistry::default();
1978        let tools = std::collections::HashSet::new();
1979        let mut extensions = serde_json::Map::new();
1980
1981        // Key absent → unsatisfied.
1982        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1983
1984        // Key with `false` → unsatisfied.
1985        extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
1986        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1987
1988        // Key with `null` → unsatisfied.
1989        extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
1990        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1991
1992        // Key with truthy value → satisfied.
1993        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1994        assert!(registry.activation_for(&skill, &tools, &extensions).active);
1995
1996        // Key with a map → satisfied (truthy).
1997        extensions.insert(
1998            "csv_http_server".to_string(),
1999            serde_json::json!({"enabled": true}),
2000        );
2001        assert!(registry.activation_for(&skill, &tools, &extensions).active);
2002    }
2003
2004    struct StubEvaluator {
2005        has_function: bool,
2006    }
2007    impl SkillPredicateEvaluator for StubEvaluator {
2008        fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
2009            match clause {
2010                PredicateClause::GraphHasNodeType(types) => {
2011                    Some(types.iter().any(|t| t == "Function") && self.has_function)
2012                }
2013                _ => None,
2014            }
2015        }
2016    }
2017
2018    #[test]
2019    fn graph_predicate_dispatches_via_evaluator() {
2020        let skill = skill_with_applies_when("  graph_has_node_type: [Function, Class]");
2021
2022        // With evaluator that says yes → active.
2023        let registry = Registry::new()
2024            .with_predicate_evaluator(StubEvaluator { has_function: true })
2025            .finalise()
2026            .unwrap();
2027        let active = registry.activation_for(
2028            &skill,
2029            &std::collections::HashSet::new(),
2030            &serde_json::Map::new(),
2031        );
2032        assert!(active.active);
2033        assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2034
2035        // With evaluator that says no → inactive.
2036        let registry = Registry::new()
2037            .with_predicate_evaluator(StubEvaluator {
2038                has_function: false,
2039            })
2040            .finalise()
2041            .unwrap();
2042        let inactive = registry.activation_for(
2043            &skill,
2044            &std::collections::HashSet::new(),
2045            &serde_json::Map::new(),
2046        );
2047        assert!(!inactive.active);
2048        assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2049    }
2050
2051    #[test]
2052    fn graph_predicate_unknown_without_evaluator_means_inactive() {
2053        let skill = skill_with_applies_when("  graph_has_node_type: [Function]");
2054        let registry = ResolvedRegistry::default();
2055        let activation = registry.activation_for(
2056            &skill,
2057            &std::collections::HashSet::new(),
2058            &serde_json::Map::new(),
2059        );
2060        assert!(!activation.active);
2061        assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
2062    }
2063
2064    #[test]
2065    fn multiple_predicates_all_must_be_satisfied() {
2066        let skill = skill_with_applies_when(
2067            "  graph_has_node_type: [Function]\n\
2068             \x20 tool_registered: cypher_query",
2069        );
2070        let registry = Registry::new()
2071            .with_predicate_evaluator(StubEvaluator { has_function: true })
2072            .finalise()
2073            .unwrap();
2074        let mut tools = std::collections::HashSet::new();
2075        let extensions = serde_json::Map::new();
2076
2077        // Graph satisfied but tool absent → inactive.
2078        assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2079
2080        // Both satisfied → active.
2081        tools.insert("cypher_query".to_string());
2082        assert!(registry.activation_for(&skill, &tools, &extensions).active);
2083    }
2084}