diff --git a/prompts/en/fragments/skills_branch.md.j2 b/prompts/en/fragments/skills_branch.md.j2
index 009a4e96d..224f1b7e1 100644
--- a/prompts/en/fragments/skills_branch.md.j2
+++ b/prompts/en/fragments/skills_branch.md.j2
@@ -1,14 +1,18 @@
## Available Skills
-These skills are procedures this agent knows. When one is relevant to your reasoning, call `read_skill` to load its full instructions.
+These skills are procedures this agent knows. Scan this index before acting and call `read_skill` for any skill that is even partially relevant — a skill defines how its task class is done here, even when the task looks familiar. Prefer reading an unnecessary skill over missing established procedure.
When you spawn a worker for a task that matches a skill, pass the skill names as `suggested_skills` instead of inlining the skill's content into the task description — the worker reads the skills it needs itself.
-{%- for skill in skills %}
-
- {{ skill.name }}
- {{ skill.description }}
-
+{%- for category in categories %}
+
+ {%- for skill in category.skills %}
+
+ {{ skill.name }}
+ {{ skill.description }}
+
+ {%- endfor %}
+
{%- endfor %}
diff --git a/prompts/en/fragments/skills_channel.md.j2 b/prompts/en/fragments/skills_channel.md.j2
index 1e799c4c4..75c2c3ede 100644
--- a/prompts/en/fragments/skills_channel.md.j2
+++ b/prompts/en/fragments/skills_channel.md.j2
@@ -1,6 +1,6 @@
## Available Skills
-You have access to the following skills. When a user's request matches one or more skills, spawn a worker and pass the relevant skill names as `suggested_skills`. The worker will read the skills it needs automatically.
+You have access to the following skills. Before responding, scan this index for any skill that is even partially relevant to the user's request — prefer reading an unnecessary skill over missing an established procedure. When a request matches one or more skills, spawn a worker and pass the relevant skill names as `suggested_skills`. The worker will read the skills it needs automatically.
**Do not include implementation details, tool invocations, or protocol instructions in the task description.** Describe *what* to do, not *how*. The worker reads the skill for the how.
@@ -9,10 +9,14 @@ Example: `spawn_worker(task="Generate a 30-second downtempo track with these lyr
You may suggest multiple skills if the task spans more than one: `suggested_skills=["github", "coding-agent"]`
-{%- for skill in skills %}
-
- {{ skill.name }}
- {{ skill.description }}
-
+{%- for category in categories %}
+
+ {%- for skill in category.skills %}
+
+ {{ skill.name }}
+ {{ skill.description }}
+
+ {%- endfor %}
+
{%- endfor %}
diff --git a/prompts/en/fragments/skills_worker.md.j2 b/prompts/en/fragments/skills_worker.md.j2
index 691ee4401..63a808c96 100644
--- a/prompts/en/fragments/skills_worker.md.j2
+++ b/prompts/en/fragments/skills_worker.md.j2
@@ -1,14 +1,18 @@
## Available Skills
-You have access to the following skills. Before starting your task, scan the list and call `read_skill` for any skill that is relevant — you may read more than one.
+You have access to the following skills. Before starting your task, scan the list and call `read_skill` for any skill that is even partially relevant — a skill defines how its task class is done here, even when the task looks familiar. Prefer reading an unnecessary skill over missing established procedure. You may read more than one.
Skills marked as **suggested** were recommended by the channel for this specific task. Read those first, then decide if any others apply.
-{%- for skill in skills %}
-
- {{ skill.name }}
- {{ skill.description }}
-
+{%- for category in categories %}
+
+ {%- for skill in category.skills %}
+
+ {{ skill.name }}
+ {{ skill.description }}
+
+ {%- endfor %}
+
{%- endfor %}
diff --git a/src/api/skills.rs b/src/api/skills.rs
index 6c2e4a21c..d50d12617 100644
--- a/src/api/skills.rs
+++ b/src/api/skills.rs
@@ -39,6 +39,7 @@ pub(super) struct SkillInfo {
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
source_repo: Option,
+ category: String,
}
#[derive(Serialize, utoipa::ToSchema)]
@@ -262,6 +263,7 @@ pub(super) async fn list_skills(
crate::skills::SkillSource::Workspace => "workspace".to_string(),
},
source_repo: s.source_repo,
+ category: s.category,
})
.collect();
diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs
index ef9131c77..ad00809cc 100644
--- a/src/prompts/engine.rs
+++ b/src/prompts/engine.rs
@@ -329,11 +329,16 @@ impl PromptEngine {
}
/// Convenience method for rendering skills channel fragment.
- pub fn render_skills_channel(&self, skills: Vec) -> Result {
+ pub fn render_skills_channel(
+ &self,
+ skills: Vec,
+ category_descriptions: &std::collections::HashMap,
+ ) -> Result {
+ let categories = group_skills_by_category(skills, category_descriptions);
self.render(
"fragments/skills_channel",
context! {
- skills => skills,
+ categories => categories,
},
)
}
@@ -361,11 +366,16 @@ impl PromptEngine {
///
/// Branches read skills directly via `read_skill` or pass names to
/// spawned workers as `suggested_skills`.
- pub fn render_skills_branch(&self, skills: Vec) -> Result {
+ pub fn render_skills_branch(
+ &self,
+ skills: Vec,
+ category_descriptions: &std::collections::HashMap,
+ ) -> Result {
+ let categories = group_skills_by_category(skills, category_descriptions);
self.render(
"fragments/skills_branch",
context! {
- skills => skills,
+ categories => categories,
},
)
}
@@ -436,11 +446,16 @@ impl PromptEngine {
///
/// Workers see all available skills with suggestions from the channel flagged.
/// They read whichever skills they need via the read_skill tool.
- pub fn render_skills_worker(&self, skills: Vec) -> Result {
+ pub fn render_skills_worker(
+ &self,
+ skills: Vec,
+ category_descriptions: &std::collections::HashMap,
+ ) -> Result {
+ let categories = group_skills_by_category(skills, category_descriptions);
self.render(
"fragments/skills_worker",
context! {
- skills => skills,
+ categories => categories,
},
)
}
@@ -914,6 +929,47 @@ pub struct SkillInfo {
/// Whether the spawning channel suggested this skill for the current task.
/// Workers should prioritise suggested skills but may read others too.
pub suggested: bool,
+ /// Category derived from the directory path.
+ pub category: String,
+}
+
+/// Group of skills under one category for grouped index rendering.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SkillCategoryGroup {
+ pub name: String,
+ /// Description from the category's `index.md`, if any.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ pub skills: Vec,
+}
+
+/// Group skills by category, sorted by category name then skill name within
+/// each group. Category descriptions come from `index.md` files loaded
+/// during discovery.
+fn group_skills_by_category(
+ skills: Vec,
+ category_descriptions: &std::collections::HashMap,
+) -> Vec {
+ let mut groups: std::collections::BTreeMap> =
+ std::collections::BTreeMap::new();
+ for skill in skills {
+ groups
+ .entry(skill.category.clone())
+ .or_default()
+ .push(skill);
+ }
+ groups
+ .into_iter()
+ .map(|(name, mut skills)| {
+ skills.sort_by(|a, b| a.name.cmp(&b.name));
+ let description = category_descriptions.get(&name).cloned();
+ SkillCategoryGroup {
+ name,
+ description,
+ skills,
+ }
+ })
+ .collect()
}
/// Information about a channel for template rendering.
diff --git a/src/skills.rs b/src/skills.rs
index 4a251ff40..120629757 100644
--- a/src/skills.rs
+++ b/src/skills.rs
@@ -121,6 +121,9 @@ pub struct Skill {
pub related_skills: Vec,
/// Files under the skill's support subdirectories, relative to `base_dir`.
pub linked_files: Vec,
+ /// Category derived from the directory path. Top-level skills get
+ /// `"general"`; categorized skills get their parent directory name.
+ pub category: String,
}
/// Where a skill was loaded from, used for precedence tracking.
@@ -139,6 +142,9 @@ pub enum SkillSource {
pub struct SkillSet {
/// Skills keyed by name (lowercase). Later sources override earlier ones.
skills: HashMap,
+ /// Category descriptions loaded from `index.md` files in category
+ /// directories, keyed by category name.
+ category_descriptions: HashMap,
}
impl SkillSet {
@@ -155,22 +161,28 @@ impl SkillSet {
// Instance skills
if instance_skills_dir.is_dir()
- && let Ok(skills) =
+ && let Ok((skills, descriptions)) =
load_skills_from_dir(instance_skills_dir, SkillSource::Instance).await
{
for skill in skills {
set.skills.insert(skill.name.to_lowercase(), skill);
}
+ for (cat, desc) in descriptions {
+ set.category_descriptions.entry(cat).or_insert(desc);
+ }
}
// Workspace skills (highest precedence, overrides instance)
if workspace_skills_dir.is_dir()
- && let Ok(skills) =
+ && let Ok((skills, descriptions)) =
load_skills_from_dir(workspace_skills_dir, SkillSource::Workspace).await
{
for skill in skills {
set.skills.insert(skill.name.to_lowercase(), skill);
}
+ for (cat, desc) in descriptions {
+ set.category_descriptions.entry(cat).or_insert(desc);
+ }
}
if !set.skills.is_empty() {
@@ -226,10 +238,11 @@ impl SkillSet {
description: index_description(&s.description),
location: s.file_path.display().to_string(),
suggested: false,
+ category: s.category.clone(),
})
.collect();
- prompt_engine.render_skills_channel(skill_infos)
+ prompt_engine.render_skills_channel(skill_infos, &self.category_descriptions)
}
/// Render the skills listing for injection into a branch system prompt.
@@ -254,10 +267,11 @@ impl SkillSet {
description: index_description(&s.description),
location: s.file_path.display().to_string(),
suggested: false,
+ category: s.category.clone(),
})
.collect();
- prompt_engine.render_skills_branch(skill_infos)
+ prompt_engine.render_skills_branch(skill_infos, &self.category_descriptions)
}
/// Render the skills listing for injection into a worker system prompt.
@@ -285,10 +299,11 @@ impl SkillSet {
name: s.name.clone(),
description: index_description(&s.description),
location: s.file_path.display().to_string(),
+ category: s.category.clone(),
})
.collect();
- prompt_engine.render_skills_worker(skill_infos)
+ prompt_engine.render_skills_worker(skill_infos, &self.category_descriptions)
}
/// Remove a skill by name.
@@ -358,6 +373,7 @@ impl SkillSet {
base_dir: s.base_dir.clone(),
source: s.source.clone(),
source_repo: s.source_repo.clone(),
+ category: s.category.clone(),
})
.collect()
}
@@ -389,6 +405,8 @@ pub struct SkillInfo {
pub base_dir: PathBuf,
pub source: SkillSource,
pub source_repo: Option,
+ /// Category derived from the directory path.
+ pub category: String,
}
/// Name of the archive directory under a workspace skills root. Hidden, so
@@ -526,8 +544,15 @@ pub async fn restore_skill_dir(workspace_skills_dir: &Path, name: &str) -> anyho
/// deeper, so both `skills/{name}/SKILL.md` and
/// `skills/{category}/{name}/SKILL.md` load. Hidden directories (`.archive`,
/// `.snapshots`, `.git`, ...) are excluded from discovery.
-async fn load_skills_from_dir(dir: &Path, source: SkillSource) -> anyhow::Result> {
+///
+/// Returns the loaded skills and any category descriptions found in
+/// `index.md` files within category directories.
+async fn load_skills_from_dir(
+ dir: &Path,
+ source: SkillSource,
+) -> anyhow::Result<(Vec, HashMap)> {
let mut skills = Vec::new();
+ let mut category_descriptions = HashMap::new();
let mut entries = tokio::fs::read_dir(dir)
.await
@@ -544,11 +569,18 @@ async fn load_skills_from_dir(dir: &Path, source: SkillSource) -> anyhow::Result
}
if path.join("SKILL.md").exists() {
- load_skill_into(&mut skills, &path, source.clone()).await;
+ load_skill_into(&mut skills, &path, "general", source.clone()).await;
continue;
}
// Category directory: scan its direct subdirectories for skills.
+ let category_name = entry.file_name().to_string_lossy().to_string();
+
+ // Load category description from index.md, if present.
+ if let Ok(Some(desc)) = load_category_description(&path).await {
+ category_descriptions.insert(category_name.clone(), desc);
+ }
+
let Ok(mut category_entries) = tokio::fs::read_dir(&path).await else {
continue;
};
@@ -563,17 +595,35 @@ async fn load_skills_from_dir(dir: &Path, source: SkillSource) -> anyhow::Result
{
continue;
}
- load_skill_into(&mut skills, &skill_dir, source.clone()).await;
+ load_skill_into(&mut skills, &skill_dir, &category_name, source.clone()).await;
}
}
- Ok(skills)
+ Ok((skills, category_descriptions))
+}
+
+/// Load a category description from an `index.md` file inside a category
+/// directory. Returns `None` when the file is absent or has no description.
+async fn load_category_description(category_dir: &Path) -> anyhow::Result