Files
IdeA/crates/application/src/skill/usecases.rs
Blomios 11dc8d7ba3 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>
2026-06-21 18:30:47 +02:00

610 lines
20 KiB
Rust

//! Skill use cases (ARCHITECTURE §14.2; L12).
//!
//! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`],
//! [`ListSkills`].
//! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate
//! the project manifest entry's `skills` and announce
//! [`DomainEvent::SkillAssigned`]. Both are idempotent.
use std::sync::Arc;
use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore};
use domain::{
AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId,
SkillRef, SkillScope,
};
use crate::error::AppError;
// ---------------------------------------------------------------------------
// CreateSkill
// ---------------------------------------------------------------------------
/// Input for [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillInput {
/// Display name (also the `.md` stem on disk).
pub name: String,
/// Optional one-line affordance description (feature « skills à la MCP »).
/// `None`/empty ⇒ the skill falls back to the first line of its body when
/// surfaced (see [`domain::Skill::effective_description`]).
pub description: Option<String>,
/// Initial Markdown body.
pub content: String,
/// Scope the skill is created in (selects its backing store).
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`CreateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateSkillOutput {
/// The created skill.
pub skill: Skill,
}
/// Creates a skill in the store of its [`SkillScope`].
pub struct CreateSkill {
skills: Arc<dyn SkillStore>,
ids: Arc<dyn IdGenerator>,
}
impl CreateSkill {
/// Builds the use case from its ports.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>, ids: Arc<dyn IdGenerator>) -> Self {
Self { skills, ids }
}
/// Executes creation.
///
/// # Errors
/// - [`AppError::Invalid`] if `name`/`content` is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: CreateSkillInput) -> Result<CreateSkillOutput, AppError> {
let id = SkillId::from_uuid(self.ids.new_uuid());
let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope)
.map_err(|e| AppError::Invalid(e.to_string()))?
.with_description(input.description);
self.skills.save(&skill, &input.project_root).await?;
Ok(CreateSkillOutput { skill })
}
}
// ---------------------------------------------------------------------------
// UpdateSkill
// ---------------------------------------------------------------------------
/// Input for [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to update.
pub skill_id: SkillId,
/// New Markdown body.
pub content: String,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`UpdateSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateSkillOutput {
/// The updated skill.
pub skill: Skill,
}
/// Replaces a skill's content (re-validating the non-empty invariant).
pub struct UpdateSkill {
skills: Arc<dyn SkillStore>,
}
impl UpdateSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Executes the update.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Invalid`] if the new content is empty,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UpdateSkillInput) -> Result<UpdateSkillOutput, AppError> {
let current = self
.skills
.get(input.scope, &input.project_root, input.skill_id)
.await?;
let updated = current
.with_content(MarkdownDoc::new(input.content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.skills.save(&updated, &input.project_root).await?;
Ok(UpdateSkillOutput { skill: updated })
}
}
// ---------------------------------------------------------------------------
// ListSkills / DeleteSkill
// ---------------------------------------------------------------------------
/// Input for [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsInput {
/// Scope to list.
pub scope: SkillScope,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Output of [`ListSkills::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListSkillsOutput {
/// All skills in the requested scope.
pub skills: Vec<Skill>,
}
/// Lists the skills in one scope.
pub struct ListSkills {
skills: Arc<dyn SkillStore>,
}
impl ListSkills {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Lists skills in `input.scope`.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: ListSkillsInput) -> Result<ListSkillsOutput, AppError> {
Ok(ListSkillsOutput {
skills: self.skills.list(input.scope, &input.project_root).await?,
})
}
}
/// Input for [`DeleteSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteSkillInput {
/// Scope the skill lives in.
pub scope: SkillScope,
/// Skill to delete.
pub skill_id: SkillId,
/// Active project root (used only for [`SkillScope::Project`]).
pub project_root: ProjectPath,
}
/// Deletes a skill from its scope's store.
///
/// Agents that referenced it keep their [`SkillRef`]; the injection step simply
/// finds nothing to resolve for the now-absent skill and skips it.
pub struct DeleteSkill {
skills: Arc<dyn SkillStore>,
}
impl DeleteSkill {
/// Builds the use case.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Deletes the skill.
///
/// # Errors
/// - [`AppError::NotFound`] if the skill is unknown in that scope,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> {
self.skills
.delete(input.scope, &input.project_root, input.skill_id)
.await?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// ReadSkill
// ---------------------------------------------------------------------------
/// Input for [`ReadSkill::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadSkillInput {
/// Skill display name to resolve (case-insensitive).
pub name: String,
/// Active project root (used for the [`SkillScope::Project`] lookup).
pub project_root: ProjectPath,
}
/// Reads a skill's Markdown body **by name** — the application side of the MCP
/// `idea_skill_read` tool (feature « skills à la MCP »).
///
/// Resolution follows the affordance contract: **project scope first, then
/// global** (a project skill shadows a global one of the same name). It composes
/// the **existing** [`SkillStore`] port only — no new port. Read-only.
pub struct ReadSkill {
skills: Arc<dyn SkillStore>,
}
impl ReadSkill {
/// Builds the use case from the existing skill store port.
#[must_use]
pub fn new(skills: Arc<dyn SkillStore>) -> Self {
Self { skills }
}
/// Resolves `name` in `scope`, returning the single match.
///
/// `Ok(None)` ⇒ no skill by that name in this scope; `Err(Invalid)` ⇒ the
/// name is **ambiguous** (more than one skill shares it in this scope).
async fn resolve_in(
&self,
scope: SkillScope,
input: &ReadSkillInput,
) -> Result<Option<Skill>, AppError> {
let all = self.skills.list(scope, &input.project_root).await?;
let mut matches = all
.into_iter()
.filter(|s| s.name.eq_ignore_ascii_case(&input.name));
match (matches.next(), matches.next()) {
(None, _) => Ok(None),
(Some(skill), None) => Ok(Some(skill)),
(Some(_), Some(_)) => Err(AppError::Invalid(format!(
"skill name `{}` is ambiguous in {scope:?} scope (several skills share it)",
input.name
))),
}
}
/// Resolves the skill by name and returns its Markdown body.
///
/// # Errors
/// - [`AppError::Invalid`] if the name is ambiguous within a scope,
/// - [`AppError::NotFound`] if no skill carries that name in either scope,
/// - [`AppError::Store`] on a store failure.
pub async fn execute(&self, input: ReadSkillInput) -> Result<MarkdownDoc, AppError> {
// Project scope shadows global: try it first, then fall back to global.
if let Some(skill) = self.resolve_in(SkillScope::Project, &input).await? {
return Ok(skill.content_md);
}
if let Some(skill) = self.resolve_in(SkillScope::Global, &input).await? {
return Ok(skill.content_md);
}
Err(AppError::NotFound(format!("skill `{}`", input.name)))
}
}
// ---------------------------------------------------------------------------
// AssignSkillToAgent / UnassignSkillFromAgent
// ---------------------------------------------------------------------------
/// Input for [`AssignSkillToAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssignSkillToAgentInput {
/// The owning project.
pub project: Project,
/// The agent receiving the skill.
pub agent_id: AgentId,
/// The skill to assign.
pub skill: SkillRef,
}
/// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry.
/// Idempotent: re-assigning the same skill is a no-op (no duplicate).
pub struct AssignSkillToAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl AssignSkillToAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
// Mutate through the domain entity so the dedup invariant is enforced in
// one place, then fold the result back into the manifest entry.
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.assign_skill(input.skill);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
self.persist_and_announce(
&input.project,
manifest,
input.agent_id,
input.skill.skill_id,
true,
)
.await?;
}
Ok(())
}
/// Saves the manifest and announces the assignment change.
async fn persist_and_announce(
&self,
project: &Project,
manifest: AgentManifest,
agent_id: AgentId,
skill_id: SkillId,
assigned: bool,
) -> Result<(), AppError> {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
});
Ok(())
}
}
/// Input for [`UnassignSkillFromAgent::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnassignSkillFromAgentInput {
/// The owning project.
pub project: Project,
/// The agent losing the skill.
pub agent_id: AgentId,
/// The skill to unassign.
pub skill_id: SkillId,
}
/// Removes a skill assignment from an agent. Idempotent: unassigning a skill the
/// agent does not carry is a no-op.
pub struct UnassignSkillFromAgent {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UnassignSkillFromAgent {
/// Builds the use case from its ports.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Executes the un-assignment.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Invalid`] if the resulting manifest is invalid,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
let entry = manifest
.entries
.iter_mut()
.find(|e| e.agent_id == input.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let mut agent = entry
.to_agent()
.map_err(|e| AppError::Invalid(e.to_string()))?;
let changed = agent.unassign_skill(input.skill_id);
*entry = domain::ManifestEntry::from_agent(&agent);
if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::SkillAssigned {
agent_id: input.agent_id,
skill_id: input.skill_id,
assigned: false,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use async_trait::async_trait;
use domain::ports::{SkillStore, StoreError};
/// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the
/// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only
/// switches scope). Enough to exercise project-first resolution, shadowing,
/// ambiguity and absence without touching disk.
#[derive(Default)]
struct FakeSkillStore {
global: Mutex<Vec<Skill>>,
project: Mutex<Vec<Skill>>,
}
impl FakeSkillStore {
fn bucket(&self, scope: SkillScope) -> &Mutex<Vec<Skill>> {
match scope {
SkillScope::Global => &self.global,
SkillScope::Project => &self.project,
}
}
fn push(&self, skill: Skill) {
self.bucket(skill.scope).lock().unwrap().push(skill);
}
}
#[async_trait]
impl SkillStore for FakeSkillStore {
async fn list(
&self,
scope: SkillScope,
_root: &ProjectPath,
) -> Result<Vec<Skill>, StoreError> {
Ok(self.bucket(scope).lock().unwrap().clone())
}
async fn get(
&self,
scope: SkillScope,
_root: &ProjectPath,
id: SkillId,
) -> Result<Skill, StoreError> {
self.bucket(scope)
.lock()
.unwrap()
.iter()
.find(|s| s.id == id)
.cloned()
.ok_or(StoreError::NotFound)
}
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
self.push(skill.clone());
Ok(())
}
async fn delete(
&self,
_scope: SkillScope,
_root: &ProjectPath,
_id: SkillId,
) -> Result<(), StoreError> {
Ok(())
}
}
fn root() -> ProjectPath {
ProjectPath::new("/proj").unwrap()
}
fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill {
Skill::new(
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
name,
MarkdownDoc::new(body),
scope,
)
.unwrap()
}
fn read_skill_uc(store: Arc<FakeSkillStore>) -> ReadSkill {
ReadSkill::new(store)
}
#[tokio::test]
async fn read_skill_resolves_project_scope() {
// (a) skill present in the project scope ⇒ its body is returned.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_falls_back_to_global_scope() {
// (b) absent from project but present globally ⇒ resolved via global.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "GLOBAL_BODY");
}
#[tokio::test]
async fn read_skill_project_shadows_global() {
// (c) same name in both scopes ⇒ project wins.
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global));
store.push(skill(2, "deploy", "PROJECT_BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "PROJECT_BODY");
}
#[tokio::test]
async fn read_skill_is_case_insensitive() {
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "Deploy", "BODY", SkillScope::Project));
let body = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap();
assert_eq!(body.as_str(), "BODY");
}
#[tokio::test]
async fn read_skill_unknown_is_not_found() {
// (d) unknown in both scopes ⇒ NotFound.
let store = Arc::new(FakeSkillStore::default());
let err = read_skill_uc(store)
.execute(ReadSkillInput {
name: "ghost".to_owned(),
project_root: root(),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "got {err:?}");
}
#[tokio::test]
async fn read_skill_ambiguous_name_is_invalid() {
// (e) two skills share a name within a scope ⇒ Invalid (ambiguous).
let store = Arc::new(FakeSkillStore::default());
store.push(skill(1, "deploy", "ONE", SkillScope::Project));
store.push(skill(2, "Deploy", "TWO", SkillScope::Project));
let err = read_skill_uc(store)
.execute(ReadSkillInput {
name: "deploy".to_owned(),
project_root: root(),
})
.await
.unwrap_err();
assert!(matches!(err, AppError::Invalid(_)), "got {err:?}");
}
}