diff --git a/crates/orchestrator/src/manager.rs b/crates/orchestrator/src/manager.rs index 1b95ec93..2cdbe44f 100644 --- a/crates/orchestrator/src/manager.rs +++ b/crates/orchestrator/src/manager.rs @@ -134,6 +134,53 @@ impl AgentManager { } } + // Materialize skills into the agent's .claude/skills/ directory before + // the claude process is launched so that Claude Code discovers them on + // startup. Missing skills produce a warning rather than a hard failure + // so that a typo in the skills list does not prevent the agent from + // starting entirely. + if !agent.config.skills.is_empty() { + let discovered = crate::skills::discover_all_skills(); + match crate::skills::materialize_skills( + std::path::Path::new(&agent.config.working_dir), + &agent.config.skills, + &discovered, + ) + .await + { + Ok(result) => { + if !result.written.is_empty() { + info!( + agent_id = %agent.id, + written = result.written.len(), + "materialized skills into .claude/skills/" + ); + } + if !result.skipped.is_empty() { + info!( + agent_id = %agent.id, + skipped = result.skipped.len(), + "skipped skill materialization (agent-local files take precedence)" + ); + } + if !result.not_found.is_empty() { + warn!( + agent_id = %agent.id, + missing = ?result.not_found, + "some requested skills were not found in discovered skill set" + ); + } + } + Err(e) => { + warn!( + agent_id = %agent.id, + error = %e, + "skill materialization failed; agent will start without skills" + ); + } + } + } + // Subprocess stdio mode takes precedence: agents communicate via // stdin/stdout NDJSON rather than WebSocket. let use_stdio = self.backend.supports_subprocess_stdio(); diff --git a/crates/orchestrator/src/skills.rs b/crates/orchestrator/src/skills.rs index 001b56bc..c82bd5f6 100644 --- a/crates/orchestrator/src/skills.rs +++ b/crates/orchestrator/src/skills.rs @@ -44,6 +44,98 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::{Path, PathBuf}; +// --------------------------------------------------------------------------- +// Materialization +// --------------------------------------------------------------------------- + +/// Result of a [`materialize_skills`] call. +#[derive(Debug, Default, PartialEq)] +pub struct MaterializeResult { + /// Skill names whose `SKILL.md` file was successfully written. + pub written: Vec, + /// Skill names that were skipped because the target file already existed. + /// + /// The agent's own `.claude/skills//SKILL.md` takes precedence over + /// the agentd-managed copy. + pub skipped: Vec, + /// Skill names that were requested but are not in `discovered_skills`. + pub not_found: Vec, +} + +/// Write skill files into the agent's `.claude/skills/` directory. +/// +/// For each skill name in `skill_names`, copies the skill content from +/// `discovered_skills` into `/.claude/skills//SKILL.md`. +/// +/// - Creates the directory structure if it does not exist. +/// - Does **not** overwrite existing skill files; agent-local skills take +/// precedence (reported as [`MaterializeResult::skipped`]). +/// - Skill names not present in `discovered_skills` are reported in +/// [`MaterializeResult::not_found`] rather than returning an error. +/// +/// # Worktree agents +/// +/// When an agent uses `--worktree`, Claude Code creates a temporary git +/// worktree. Skills are written to the source `working_dir` *before* launch. +/// Because `.claude/` is typically in `.gitignore`, the worktree does not +/// inherit those files — the agent's `additional_dirs` (already wired up in +/// `build_claude_command`) point back at the project root where the skills live. +pub async fn materialize_skills( + working_dir: &Path, + skill_names: &[String], + discovered_skills: &[Skill], +) -> Result { + use std::collections::HashMap; + + let index: HashMap<&str, &Skill> = + discovered_skills.iter().map(|s| (s.name.as_str(), s)).collect(); + + let mut result = MaterializeResult::default(); + + for name in skill_names { + match index.get(name.as_str()) { + None => { + result.not_found.push(name.clone()); + } + Some(skill) => { + let target_dir = working_dir.join(".claude").join("skills").join(name); + let target_file = target_dir.join("SKILL.md"); + + if target_file.exists() { + result.skipped.push(name.clone()); + continue; + } + + if let Err(e) = tokio::fs::create_dir_all(&target_dir).await { + tracing::warn!( + skill = %name, + dir = %target_dir.display(), + error = %e, + "Failed to create skill directory; skipping" + ); + result.not_found.push(name.clone()); + continue; + } + + if let Err(e) = tokio::fs::write(&target_file, &skill.content).await { + tracing::warn!( + skill = %name, + file = %target_file.display(), + error = %e, + "Failed to write skill file; skipping" + ); + result.not_found.push(name.clone()); + continue; + } + + result.written.push(name.clone()); + } + } + } + + Ok(result) +} + // --------------------------------------------------------------------------- // Skill model // ---------------------------------------------------------------------------