procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! Process-wide skill and persona registries.
//!
//! Discovery walks every skill directory and reads a `SKILL.md` plus an optional `customize.toml`
//! per skill — blocking I/O, and the same result every time within a run. Doing it per tool call
//! meant party mode paid for it once per persona, on the async runtime's worker threads. Here it
//! happens once, off the runtime, and every caller shares the outcome.

use std::path::PathBuf;
use std::sync::Arc;

use tokio::sync::OnceCell;

use crate::personas::PersonaRegistry;
use crate::skills::SkillRegistry;

struct Registries {
    skills: Arc<SkillRegistry>,
    personas: Arc<PersonaRegistry>,
}

static REGISTRIES: OnceCell<Registries> = OnceCell::const_new();

async fn get() -> &'static Registries {
    REGISTRIES
        .get_or_init(|| async {
            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

            // Both registries are built inside the one blocking task: personas borrow the skill
            // registry, and splitting them would either move it across the boundary or read
            // `customize.toml` back on the runtime.
            let built = tokio::task::spawn_blocking(move || {
                let (skills, mut warnings) = SkillRegistry::discover(&cwd);
                let (personas, persona_warnings) = PersonaRegistry::discover(&skills);
                warnings.extend(persona_warnings);
                (skills, personas, warnings)
            })
            .await;

            match built {
                Ok((skills, personas, warnings)) => {
                    // A skill that failed to load is invisible in the registry; recording why is
                    // the only thing that distinguishes it from one that was never installed.
                    for warning in warnings {
                        crate::diag::warn(warning);
                    }
                    Registries {
                        skills: Arc::new(skills),
                        personas: Arc::new(personas),
                    }
                }
                // A panicked or cancelled discovery must not poison every later lookup, but an
                // empty registry looks exactly like "no skills installed" unless it is recorded.
                Err(e) => {
                    crate::diag::warn(format!("Skill discovery failed: {}", e));
                    Registries {
                        skills: Arc::new(SkillRegistry::new()),
                        personas: Arc::new(PersonaRegistry::new()),
                    }
                }
            }
        })
        .await
}

/// Returns the shared skill registry, discovering on first use.
pub async fn skills() -> Arc<SkillRegistry> {
    Arc::clone(&get().await.skills)
}

/// Returns the shared persona registry, discovering on first use.
pub async fn personas() -> Arc<PersonaRegistry> {
    Arc::clone(&get().await.personas)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn discovery_happens_once_and_is_shared() {
        let first = skills().await;
        let second = skills().await;
        assert!(
            Arc::ptr_eq(&first, &second),
            "the registry must be shared, not rebuilt per call"
        );

        let personas_first = personas().await;
        let personas_second = personas().await;
        assert!(Arc::ptr_eq(&personas_first, &personas_second));
    }

    // No longer every persona: the six built-in specialists (`personas::builtin`) ship in the
    // binary and have no `SKILL.md` behind them, precisely so they exist without an install step.
    // What still has to hold is that every persona *discovered from disk* — the ones a customize.toml
    // added — points at a skill that is actually there.
    #[tokio::test]
    async fn every_disk_persona_comes_from_a_discovered_skill() {
        let builtin_names: std::collections::HashSet<String> = crate::personas::builtin::personas()
            .into_iter()
            .map(|p| p.skill_name)
            .collect();
        let skills = skills().await;
        for persona in personas().await.all() {
            if builtin_names.contains(&persona.skill_name) {
                continue;
            }
            assert!(
                skills.get(&persona.skill_name).is_some(),
                "persona {} has no backing skill",
                persona.name
            );
        }
    }
}