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:
2026-06-17 15:08:33 +02:00
parent 7db444a31d
commit 11dc8d7ba3
13 changed files with 758 additions and 27 deletions

View File

@ -51,7 +51,11 @@ pub enum ToolMapError {
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(
tool,
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
"idea_ask_agent"
| "idea_list_agents"
| "idea_context_read"
| "idea_memory_read"
| "idea_skill_read"
)
}
@ -201,6 +205,21 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_skill_read",
description: "Read the full body of an IdeA skill assigned to you, by name — call this \
to actually execute/follow a skill listed in your « Skills disponibles » \
section. Resolves project scope first, then global. Returns the skill's \
Markdown inline.",
input_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Skill name (as listed in your context)." }
},
"required": ["name"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
@ -320,6 +339,14 @@ pub fn map_tool_call(
content: s("content"),
..base()
},
"idea_skill_read" => OrchestratorRequest {
request_type: Some("skill.read".to_owned()),
// The requester (handshake identity) is carried for symmetry with the
// other read tools; `validate` derives the requester party from it.
requested_by: Some(requester.to_owned()),
name: s("name"),
..base()
},
"idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()),
name: s("name"),

View File

@ -58,6 +58,11 @@ const INDEX_VERSION: u32 = 1;
struct IndexEntry {
id: SkillId,
name: String,
/// One-line affordance description (feature « skills à la MCP »). `#[serde(default)]`
/// is **mandatory** for backward-compat: legacy `index.json` written before this
/// field existed deserialise with `None` instead of failing.
#[serde(default)]
description: Option<String>,
content_hash: String,
}
@ -189,6 +194,7 @@ impl FsSkillStore {
MarkdownDoc::new(content),
scope,
)
.map(|skill| skill.with_description(entry.description.clone()))
.map_err(|e| StoreError::Serialization(e.to_string()))
}
}
@ -239,6 +245,7 @@ impl SkillStore for FsSkillStore {
let row = IndexEntry {
id: skill.id,
name: skill.name.clone(),
description: skill.description.clone(),
content_hash: content_hash(&skill.content_md),
};
if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
@ -266,3 +273,129 @@ impl SkillStore for FsSkillStore {
self.write_index(scope, root, &index).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use domain::ports::{DirEntry, FsError};
/// In-memory [`FileSystem`] mock (same minimal shape as the memory-store tests).
#[derive(Default)]
struct MemFs {
files: Mutex<HashMap<String, Vec<u8>>>,
}
impl MemFs {
fn arc() -> Arc<Self> {
Arc::new(Self::default())
}
}
#[async_trait]
impl FileSystem for MemFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.files
.lock()
.unwrap()
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files
.lock()
.unwrap()
.insert(path.as_str().to_string(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
}
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
fn root() -> ProjectPath {
ProjectPath::new("/proj").unwrap()
}
fn skill(id: u128, name: &str, body: &str) -> Skill {
Skill::new(
SkillId::from_uuid(uuid::Uuid::from_u128(id)),
name,
MarkdownDoc::new(body),
SkillScope::Global,
)
.unwrap()
}
#[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`.
let fs = MemFs::arc();
let id = uuid::Uuid::from_u128(42);
let legacy_index = format!(
r#"{{"version":1,"skills":[{{"id":"{id}","name":"legacy","contentHash":"abc"}}]}}"#
);
fs.write(
&RemotePath::new("/app/skills/index.json".to_owned()),
legacy_index.as_bytes(),
)
.await
.unwrap();
fs.write(
&RemotePath::new(format!("/app/skills/md/{id}.md")),
b"# legacy body",
)
.await
.unwrap();
let store = FsSkillStore::new(fs, "/app");
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "legacy");
assert_eq!(listed[0].description, None);
assert_eq!(listed[0].content_md.as_str(), "# legacy body");
}
#[tokio::test]
async fn save_then_load_round_trips_description() {
let store = FsSkillStore::new(MemFs::arc(), "/app");
let s = skill(1, "refactor", "# Refactor\n\nbody")
.with_description(Some("Refactors code".to_owned()));
store.save(&s, &root()).await.unwrap();
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
assert_eq!(got.description.as_deref(), Some("Refactors code"));
assert_eq!(got, s);
// Also surfaces through `list`.
let listed = store.list(SkillScope::Global, &root()).await.unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
}
#[tokio::test]
async fn save_without_description_loads_as_none() {
let store = FsSkillStore::new(MemFs::arc(), "/app");
let s = skill(2, "plain", "# Plain\n\nbody");
store.save(&s, &root()).await.unwrap();
let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
assert_eq!(got.description, None);
}
}