feat(skills): capacités agent découvrables via idea_list_agents

Ajoute SkillKind (Workflow/Reference) sur Skill, extrait le use case
ResolveAgentCapabilities à partir des SkillRef assignés, et enrichit
idea_list_agents d'un champ additif capabilities (name, description,
kind) — remplace l'exposition de SkillRef opaques par un inventaire de
capacités interrogeable, source commune avec le bloc « Skills
disponibles » injecté au lancement (#119).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:26:56 +02:00
parent 5efb026a80
commit 92b17e9a69
18 changed files with 467 additions and 83 deletions

View File

@ -35,7 +35,7 @@ use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};
use domain::skill::{Skill, SkillKind, SkillScope};
/// Directory (under app-data) holding the global skills store.
const GLOBAL_SKILLS_DIR: &str = "skills";
@ -63,6 +63,9 @@ struct IndexEntry {
/// field existed deserialise with `None` instead of failing.
#[serde(default)]
description: Option<String>,
/// Skill nature. Defaults to workflow for legacy index rows.
#[serde(default)]
kind: SkillKind,
content_hash: String,
}
@ -195,6 +198,7 @@ impl FsSkillStore {
scope,
)
.map(|skill| skill.with_description(entry.description.clone()))
.map(|skill| skill.with_kind(entry.kind))
.map_err(|e| StoreError::Serialization(e.to_string()))
}
}
@ -246,6 +250,7 @@ impl SkillStore for FsSkillStore {
id: skill.id,
name: skill.name.clone(),
description: skill.description.clone(),
kind: skill.kind,
content_hash: content_hash(&skill.content_md),
};
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
@ -340,9 +345,9 @@ mod tests {
}
#[tokio::test]
async fn legacy_index_without_description_loads_with_none() {
// A legacy `index.json` written before the `description` field existed must
// deserialise (thanks to `#[serde(default)]`) and load with `description: None`.
async fn legacy_index_without_description_loads_with_defaults() {
// A legacy `index.json` written before the `description`/`kind` fields
// existed must deserialise through additive defaults.
let fs = MemFs::arc();
let id = uuid::Uuid::from_u128(42);
let legacy_index = format!(
@ -367,14 +372,16 @@ mod tests {
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "legacy");
assert_eq!(listed[0].description, None);
assert_eq!(listed[0].kind, SkillKind::Workflow);
assert_eq!(listed[0].content_md.as_str(), "# legacy body");
}
#[tokio::test]
async fn save_then_load_round_trips_description() {
async fn save_then_load_round_trips_description_and_kind() {
let store = FsSkillStore::new(MemFs::arc(), "/app");
let s = skill(1, "refactor", "# Refactor\n\nbody")
.with_description(Some("Refactors code".to_owned()));
.with_description(Some("Refactors code".to_owned()))
.with_kind(SkillKind::Reference);
store.save(&s, &root()).await.unwrap();
@ -386,6 +393,7 @@ mod tests {
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
assert_eq!(listed[0].kind, SkillKind::Reference);
}
#[tokio::test]