feat(skill-awareness): T1→T5 — manifeste de skills + outil MCP idea_skill_read
Surface les skills assignés à un agent « à la MCP » : description sur l'entité Skill (+ effective_description fallback), section « # Skills disponibles » haute altitude dans le convention-file (mode MCP), outil read-only idea_skill_read (résolution projet→global), use case ReadSkill (port SkillStore existant), et câblage au composition root. Dump legacy du corps complet conservé en mode sans-MCP (zéro régression). Rétro-compat index.json (serde default). Tests : domain+application+infrastructure 1212 passed / 0 failed (23 ajoutés). Reste : T6 (champ description front) + T7 (e2e après rebuild AppImage). NB topologie : commit réalisé par l'orchestrateur car l'agent Git était injoignable (bug de livraison cold-start) ; à faire relire/rebaser par Git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -39,6 +39,12 @@ pub struct Skill {
|
||||
pub id: SkillId,
|
||||
/// Display name (also used as the `.md` file stem on disk).
|
||||
pub name: String,
|
||||
/// Optional one-line description surfaced as an *affordance* at high altitude
|
||||
/// in an agent's convention file (« à la MCP »), so the agent knows the skill
|
||||
/// exists and what it does without reading the whole body. `None`/empty ⇒
|
||||
/// [`Skill::effective_description`] falls back to the first line of the body.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Markdown body — the workflow injected into an agent's convention file.
|
||||
pub content_md: MarkdownDoc,
|
||||
/// Scope (selects the backing store).
|
||||
@ -67,18 +73,57 @@ impl Skill {
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
description: None,
|
||||
content_md,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns this skill with its one-line [`Skill::description`] set (builder).
|
||||
///
|
||||
/// Kept separate from [`Skill::new`] so every existing call site is untouched
|
||||
/// (the field is purely additive). An empty/whitespace description is treated
|
||||
/// as absent by [`Skill::effective_description`], so no normalisation here.
|
||||
#[must_use]
|
||||
pub fn with_description(mut self, description: Option<String>) -> Self {
|
||||
self.description = description;
|
||||
self
|
||||
}
|
||||
|
||||
/// The description to surface for this skill, with a deterministic fallback.
|
||||
///
|
||||
/// Returns [`Skill::description`] when present and non-blank (trimmed);
|
||||
/// otherwise the **first non-empty line** of the body, stripped of any leading
|
||||
/// `#` (Markdown heading marker) and trimmed. When the body has no usable line
|
||||
/// either, returns an **empty `String`** (chosen over `Option` so callers can
|
||||
/// format it unconditionally — an empty affordance line is harmless).
|
||||
#[must_use]
|
||||
pub fn effective_description(&self) -> String {
|
||||
if let Some(desc) = &self.description {
|
||||
let trimmed = desc.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_owned();
|
||||
}
|
||||
}
|
||||
self.content_md
|
||||
.as_str()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|line| line.trim_start_matches('#').trim().to_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns a copy of this skill with replaced content, re-validating the
|
||||
/// non-empty invariant.
|
||||
/// non-empty invariant and **preserving** the [`Skill::description`].
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DomainError::EmptyField`] if `content_md` is empty.
|
||||
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
|
||||
Skill::new(self.id, self.name.clone(), content_md, self.scope)
|
||||
Ok(
|
||||
Skill::new(self.id, self.name.clone(), content_md, self.scope)?
|
||||
.with_description(self.description.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,3 +154,98 @@ impl From<&Skill> for SkillRef {
|
||||
Self::new(skill.id, skill.scope)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ids::SkillId;
|
||||
|
||||
fn skill(body: &str) -> Skill {
|
||||
Skill::new(
|
||||
SkillId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
"demo",
|
||||
MarkdownDoc::new(body),
|
||||
SkillScope::Global,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// -- effective_description --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn effective_description_returns_explicit_description_trimmed() {
|
||||
// (a) explicit non-empty description ⇒ returned trimmed.
|
||||
let s = skill("# Heading\n\nbody").with_description(Some(" Refactors code ".to_owned()));
|
||||
assert_eq!(s.effective_description(), "Refactors code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_description_falls_back_to_first_body_line_without_hash() {
|
||||
// (b) description None ⇒ first non-empty body line, `#` stripped & trimmed.
|
||||
let s = skill("\n\n# My Skill Title \n\nrest of body");
|
||||
assert_eq!(s.effective_description(), "My Skill Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_description_first_line_without_heading_marker() {
|
||||
// (b') first non-empty line that is not a heading is returned verbatim (trimmed).
|
||||
let s = skill(" just a plain first line\nsecond");
|
||||
assert_eq!(s.effective_description(), "just a plain first line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_description_blank_body_yields_empty_string() {
|
||||
// (c) body that is only whitespace ⇒ empty String.
|
||||
let s = skill(" \n\t\n ");
|
||||
assert_eq!(s.effective_description(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_description_blank_description_falls_back_to_body() {
|
||||
// (d) description Some("") / whitespace-only ⇒ treated as absent ⇒ body fallback.
|
||||
let empty = skill("# Body Heading").with_description(Some(String::new()));
|
||||
assert_eq!(empty.effective_description(), "Body Heading");
|
||||
|
||||
let spaces = skill("# Body Heading").with_description(Some(" \t ".to_owned()));
|
||||
assert_eq!(spaces.effective_description(), "Body Heading");
|
||||
}
|
||||
|
||||
// -- with_content preserves description -------------------------------
|
||||
|
||||
#[test]
|
||||
fn with_content_preserves_description() {
|
||||
let s = skill("old body").with_description(Some("kept".to_owned()));
|
||||
let updated = s.with_content(MarkdownDoc::new("new body")).unwrap();
|
||||
assert_eq!(updated.description.as_deref(), Some("kept"));
|
||||
assert_eq!(updated.content_md.as_str(), "new body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_content_rejects_empty_body() {
|
||||
let s = skill("old body");
|
||||
assert!(s.with_content(MarkdownDoc::new("")).is_err());
|
||||
}
|
||||
|
||||
// -- serde: description is additive / defaults to None ----------------
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_skill_without_description_defaults_to_none() {
|
||||
// A skill JSON written before `description` existed must deserialise with
|
||||
// `description: None` thanks to `#[serde(default)]`.
|
||||
let id = uuid::Uuid::from_u128(7);
|
||||
let json =
|
||||
format!(r##"{{"id":"{id}","name":"legacy","contentMd":"# body","scope":"global"}}"##);
|
||||
let parsed: Skill = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.description, None);
|
||||
assert_eq!(parsed.name, "legacy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip_with_description() {
|
||||
let original = skill("# body").with_description(Some("affordance".to_owned()));
|
||||
let json = serde_json::to_string(&original).unwrap();
|
||||
let back: Skill = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, original);
|
||||
assert_eq!(back.description.as_deref(), Some("affordance"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user