//! Compose capability-fragment prompt for an agent invocation. //! //! Flow: //! 1. Parse `task.toml` → `TaskSpec` (caller does this). //! 2. Load `_roles/.toml`. //! 3. For each capability in `role.capabilities.required`, read the //! `_capabilities///text.md` fragment. //! 4. Concatenate fragments with `\n\n---\n\n`. //! 5. Append `task.body.text`. use crate::capability::TaskSpec; use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use std::path::Path; const SEPARATOR: &str = "\n\n---\n\n"; #[derive(Debug, Deserialize)] struct RoleFile { #[serde(default)] capabilities: RoleCapabilities, } #[derive(Debug, Default, Deserialize)] struct RoleCapabilities { #[serde(default)] required: Vec, } /// Compose prompt text. `kit_root` is the repo root that holds `_roles/` /// and `_capabilities/` directories. pub fn compose_prompt(task: &TaskSpec, kit_root: &Path) -> Result { if task.task.role.is_empty() { return Err(anyhow!("task.role is empty")); } let role = load_role(kit_root, &task.task.role)?; let mut fragments: Vec = Vec::with_capacity(role.capabilities.required.len() + 1); for cap_name in &role.capabilities.required { let frag = load_capability_text(kit_root, cap_name) .with_context(|| format!("capability {cap_name}"))?; fragments.push(frag); } if !task.body.text.trim().is_empty() { fragments.push(task.body.text.clone()); } Ok(fragments.join(SEPARATOR)) } fn load_role(kit_root: &Path, role: &str) -> Result { let path = kit_root.join("_roles").join(format!("{role}.toml")); let text = std::fs::read_to_string(&path) .with_context(|| format!("read role file {}", path.display()))?; let parsed: RoleFile = toml::from_str(&text).with_context(|| format!("parse role TOML {}", path.display()))?; Ok(parsed) } fn load_capability_text(kit_root: &Path, cap_name: &str) -> Result { let (category, slug) = split_cap_name(cap_name)?; let path = kit_root .join("_capabilities") .join(category) .join(slug) .join("text.md"); std::fs::read_to_string(&path) .with_context(|| format!("read capability text {}", path.display())) } fn split_cap_name(cap: &str) -> Result<(&str, &str)> { match cap.split_once("::") { Some((cat, slug)) if !cat.is_empty() && !slug.is_empty() => Ok((cat, slug)), _ => Err(anyhow!("malformed capability name '{cap}' — expected ::")), } }