shepherd-compiler 6.6.1

Pure, deterministic Shepherd content compiler and prompt-budget engine.
//! Role resolution.
//!
//! Declared roles and their startup skills become the resolved set the
//! emitters consume.

// Tightly coupled to its siblings by construction: this is one compiler
// split by concern, not four independent modules.
use super::*;

pub(super) fn resolve_roles(
    roles: &[&RoleInput],
    skills: &[&SkillInput],
    profile: &HarnessProfile,
) -> Result<Vec<EmittedRole>, CompileError> {
    let resolve_tools = profile.target != TargetHarness::Codex;
    roles
        .iter()
        .map(|role| {
            let resolution = profile
                .model_by_hint
                .get(&role.model_hint)
                .ok_or_else(|| {
                    CompileError::Invalid(format!(
                        "{}: target `{}` has no model mapping for hint `{}`",
                        role.source_path,
                        profile.target.as_str(),
                        role.model_hint
                    ))
                })?;
            validate_model_resolution(role, profile.target, resolution)?;
            let startup_skill = role.startup_skill.as_str();
            let skill = skills
                .iter()
                .find(|skill| skill.name == startup_skill)
                .ok_or_else(|| {
                    CompileError::Invalid(format!(
                        "{}: unknown startup skill `{startup_skill}`",
                        role.source_path
                    ))
                })?;
            if skill.portability == Portability::ClaudeOnly && profile.target != TargetHarness::Claude {
                return Err(CompileError::Invalid(format!(
                    "{}: startup skill `{startup_skill}` is not portable to target `{}`",
                    role.source_path,
                    profile.target.as_str()
                )));
            }

            let mut seen_tools = BTreeSet::new();
            let mut tools = Vec::new();
            let mut unsupported_capabilities = Vec::new();
            if resolve_tools {
                for capability in &role.capabilities {
                    if let Some(mapped) = profile.tools_by_capability.get(capability) {
                        for tool in mapped {
                            validate_tool(tool)?;
                            if seen_tools.insert(tool.as_str()) {
                                tools.push(tool.clone());
                            }
                        }
                    } else if profile.unsupported_capabilities.contains(capability) {
                        unsupported_capabilities.push(capability.clone());
                    } else {
                        return Err(CompileError::Invalid(format!(
                            "{}: target `{}` has no tool or unsupported-capability mapping for `{capability}`",
                            role.source_path,
                            profile.target.as_str()
                        )));
                    }
                }
            }

            Ok(EmittedRole {
                role: role.role.clone(),
                carrier_path: match profile.target {
                    TargetHarness::Claude => format!("agents/{}.md", role.role),
                    TargetHarness::Codex if role.dispatchable => {
                        format!(".codex/agents/{}.toml", role.role)
                    }
                    TargetHarness::Codex => "shepherd.codex.toml".into(),
                    TargetHarness::Pi => format!("prompts/{}.md", role.role),
                },
                description: role.description.clone(),
                model_hint: role.model_hint.clone(),
                model: resolution.model.clone(),
                profile: resolution.profile.clone(),
                reasoning_effort: resolution.reasoning_effort.clone(),
                tools,
                unsupported_capabilities,
                capabilities: role.capabilities.clone(),
                startup_skill: Some(role.startup_skill.clone()),
                startup_skill_sha256: None,
                write_eligible: role.write_eligible,
                dispatchable: role.dispatchable,
                write_scope: role.write_scope.clone(),
            })
        })
        .collect()
}

pub(super) fn required_startup_skill(role: &EmittedRole) -> Result<&str, CompileError> {
    role.startup_skill.as_deref().ok_or_else(|| {
        CompileError::Invalid(format!(
            "compiled role `{}` has no startup skill contract",
            role.role
        ))
    })
}