diff --git a/Cargo.lock b/Cargo.lock index ba37627..52b10fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,6 +357,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-persona" +version = "0.1.0" +source = "git+https://github.com/block/buzz?rev=22be8bb35177e27efc2dca2534df9a8dd871eae0#22be8bb35177e27efc2dca2534df9a8dd871eae0" +dependencies = [ + "serde", + "serde_json", + "serde_yaml", + "thiserror", +] + [[package]] name = "buzz-sdk" version = "0.1.0" @@ -2033,6 +2044,7 @@ dependencies = [ "sqlx", "studio-api", "studio-buzz", + "studio-registry", "studio-store", "studio-types", "tokio", @@ -2170,6 +2182,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2551,6 +2572,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "studio-registry" +version = "0.1.0" +dependencies = [ + "buzz-persona", + "serde", + "studio-types", + "tempfile", + "thiserror", + "toml", +] + [[package]] name = "studio-store" version = "0.1.0" @@ -2757,6 +2790,45 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -3279,6 +3351,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index eeedd8d..04d0489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] studio-api = { path = "crates/studio-api" } +studio-registry = { path = "crates/studio-registry" } studio-buzz = { path = "crates/studio-buzz" } studio-store = { path = "crates/studio-store" } studio-types = { path = "crates/studio-types" } @@ -39,6 +40,7 @@ members = [ "crates/studio-buzz", "crates/studio-pay", "crates/studio-api", + "crates/studio-registry", ] resolver = "2" @@ -77,6 +79,10 @@ figment = { version = "0.10", features = ["yaml", "env"] } # registry; bump deliberately, in lockstep with the relay we run against. buzz-sdk = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } buzz-ws-client = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } +# Registry: agents/.persona.md is Buzz's persona format, verbatim +# (GUIDELINES.md §3) — same pin as the other buzz crates. +buzz-persona = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } +toml = "1" nostr = "0.44" # WSS needs a process-level rustls CryptoProvider; every binary entry point # installs ring explicitly (same pin/reason as buzz-cli) — relying on feature @@ -96,3 +102,4 @@ studio-store = { path = "crates/studio-store" } studio-buzz = { path = "crates/studio-buzz" } studio-pay = { path = "crates/studio-pay" } studio-api = { path = "crates/studio-api" } +studio-registry = { path = "crates/studio-registry" } diff --git a/README.md b/README.md index 560c751..84581bf 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,29 @@ droppable and rebuildable from the substrates. ## Layout -One binary — `scarced` (axum HTTP surface + orchestrator loop) — over five +One binary — `scarced` (axum HTTP surface + orchestrator loop) — over seven crates: | Crate | Role | |---|---| -| `studio-core` | domain types, schemas, state machine + gate engine (no I/O) | +| `studio-types` | wire types + generated JSON Schemas (the API contract) | +| `studio-core` | state machine + gate engine, pure functions (no I/O) | | `studio-store` | SQLite projections (rebuildable by design) | | `studio-buzz` | `BuzzPort`: Buzz-crate-backed impl + mock (M3) | | `studio-pay` | `PayPort`: stub impl through M4, live MPP session impl in M5 | | `studio-api` | axum routes, auth, SSE | - -Implemented so far: RFQ capture (M1) and quote issuance + gate-policy engine -(M2). The orchestrator loop and Buzz integration arrive in M3. +| `studio-registry` | `agents/*.persona.md` + `roster.toml` + `skills.toml` loader | + +Implemented so far: RFQ capture (M1), quote issuance + gate-policy engine +(M2), the Buzz lifecycle loop (M3: ops mirror, acceptance, private +workrooms), and the agent registry loaded fail-closed at boot. + +The registry (GUIDELINES.md §3–§4) is three in-repo artifacts: one +`agents/.persona.md` per agent (Buzz persona format — YAML frontmatter +for settings, body is the system prompt), `roster.toml` for studio economics +(npub binding, skill tags, day rate) keyed by persona name, and `skills.toml` +pinning the convention skills personas reference by slug. `scarced` refuses +to start on any parse or cross-reference error. ## Install diff --git a/agents/ruben.persona.md b/agents/ruben.persona.md new file mode 100644 index 0000000..f88c177 --- /dev/null +++ b/agents/ruben.persona.md @@ -0,0 +1,26 @@ +--- +name: ruben +display_name: ruben +description: Implementer — Rust backends, Buzz integration, the studio's own build +model: anthropic:claude-fable-5 +runtime: claude +skills: + - sf-rust +subscribe: + - "#scarce-studio" +thread_replies: true +--- + +You are ruben, the studio's implementer — a hard-core Rust engineer. + +Backends are always Rust, built AI-first per GUIDELINES.md: business logic +lives in a pure core crate; every visible surface (ACP, MCP, MPP-gated HTTP +via solana-pay-kit) is an adapter over it. APIs live under /api/v1, parse +their inputs, return actionable per-field errors, and publish schemars-generated +JSON Schemas. + +Verify before claiming: report test results with exact commands and counts, +attribute results to the exact commit that produced them, and never claim +green without running. Work in worktrees, never on main. Conventional +commits. Post at milestone boundaries; when blocked, say so in-channel with +what you need. diff --git a/crates/studio-registry/Cargo.toml b/crates/studio-registry/Cargo.toml new file mode 100644 index 0000000..12e0690 --- /dev/null +++ b/crates/studio-registry/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "studio-registry" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +buzz-persona = { workspace = true } +serde = { workspace = true } +studio-types = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/studio-registry/src/lib.rs b/crates/studio-registry/src/lib.rs new file mode 100644 index 0000000..ecac0ce --- /dev/null +++ b/crates/studio-registry/src/lib.rs @@ -0,0 +1,323 @@ +//! The studio's agent registry and skills configuration, loaded fail-closed +//! at `scarced` boot (GUIDELINES.md §3–§4). +//! +//! Three in-repo artifacts, one loader: +//! - `agents/.persona.md` — Buzz's persona format, verbatim (parsed by +//! the `buzz-persona` crate; strict frontmatter, typos are parse errors) +//! - `roster.toml` — studio economics keyed by persona `name` (npub binding, +//! skill tags, day rate) +//! - `skills.toml` — pinned skill sources the persona `skills:` slugs +//! resolve through +//! +//! A registry that does not parse is a `scarced` that does not start: every +//! error names the file and the fix. The registry *configures* agents; it +//! does not mint them — provisioning an npub stays an owner ceremony. + +mod roster; +mod skills; + +pub use roster::RosterEntry; +pub use skills::{SkillDef, Skills}; + +use std::collections::BTreeMap; +use std::path::Path; + +use buzz_persona::persona::{parse_persona_file, PersonaConfig}; + +/// One staffable agent: runtime settings from the persona file, studio +/// economics from the roster, joined on `name`. +#[derive(Debug, Clone)] +pub struct Agent { + pub persona: PersonaConfig, + pub roster: RosterEntry, +} + +/// The loaded registry the orchestrator staffs workrooms from. +#[derive(Debug, Clone)] +pub struct Registry { + /// Agents keyed by persona `name`. + pub agents: BTreeMap, + pub skills: Skills, +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("agents directory not found at {path} — the registry is one `agents/.persona.md` per agent (GUIDELINES.md §3); create it or point `registry_dir` at the studio repo")] + MissingAgentsDir { path: String }, + + #[error( + "no persona files in {path} — the registry needs at least one `agents/.persona.md`" + )] + EmptyRegistry { path: String }, + + #[error("unexpected file {path} in agents/ — only `.persona.md` files (and README.md) live here; rename or remove it")] + UnexpectedFile { path: String }, + + #[error("persona {path}: {message}")] + Persona { path: String, message: String }, + + #[error("persona {path}: frontmatter name is `{name}` but the file is not named `{name}.persona.md` — rename one so the registry key is unambiguous")] + NameMismatch { path: String, name: String }, + + #[error("{path}: {message}")] + File { path: String, message: String }, + + #[error("roster entry `{name}`: {message}")] + Roster { name: String, message: String }, + + #[error("roster entry `{name}` has no persona file — every roster name must match an `agents/{name}.persona.md`")] + RosterWithoutPersona { name: String }, + + #[error("persona `{name}` has no roster entry — add `[agents.{name}]` with its npub to roster.toml (provision the identity first if it has none)")] + PersonaWithoutRoster { name: String }, + + #[error("skill `{slug}` (referenced by {referenced_by}) is not defined in skills.toml — add a `[skills.{slug}]` entry with a pinned rev")] + UnknownSkill { slug: String, referenced_by: String }, + + #[error("skill `{slug}`: {message}")] + Skill { slug: String, message: String }, +} + +/// Load the registry from a studio repo root: `/agents/*.persona.md`, +/// `/roster.toml`, `/skills.toml`. Fail-closed — the first +/// structural problem aborts the load with an actionable error. +pub fn load(root: &Path) -> Result { + let skills = skills::load(&root.join("skills.toml"))?; + let mut roster = roster::load(&root.join("roster.toml"))?; + + let agents_dir = root.join("agents"); + if !agents_dir.is_dir() { + return Err(RegistryError::MissingAgentsDir { + path: agents_dir.display().to_string(), + }); + } + + let mut entries: Vec<_> = std::fs::read_dir(&agents_dir) + .map_err(|e| RegistryError::File { + path: agents_dir.display().to_string(), + message: e.to_string(), + })? + .collect::>() + .map_err(|e| RegistryError::File { + path: agents_dir.display().to_string(), + message: e.to_string(), + })?; + entries.sort_by_key(|e| e.file_name()); + + let mut agents = BTreeMap::new(); + for entry in entries { + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().into_owned(); + // Dotfiles and the directory's own README are not registry entries. + if file_name.starts_with('.') || file_name == "README.md" { + continue; + } + let Some(stem) = file_name.strip_suffix(".persona.md") else { + // Fail closed: a typo'd extension must not silently drop an agent. + return Err(RegistryError::UnexpectedFile { + path: path.display().to_string(), + }); + }; + + let persona = parse_persona_file(&path).map_err(|e| RegistryError::Persona { + path: path.display().to_string(), + message: e.to_string(), + })?; + if persona.name != stem { + return Err(RegistryError::NameMismatch { + path: path.display().to_string(), + name: persona.name, + }); + } + + for slug in &persona.skills { + if !skills.skills.contains_key(slug) { + return Err(RegistryError::UnknownSkill { + slug: slug.clone(), + referenced_by: format!("persona `{}`", persona.name), + }); + } + } + + let roster_entry = + roster + .remove(&persona.name) + .ok_or_else(|| RegistryError::PersonaWithoutRoster { + name: persona.name.clone(), + })?; + + agents.insert( + persona.name.clone(), + Agent { + persona, + roster: roster_entry, + }, + ); + } + + // Whatever is left in the roster references a persona that does not exist. + if let Some(name) = roster.into_keys().next() { + return Err(RegistryError::RosterWithoutPersona { name }); + } + if agents.is_empty() { + return Err(RegistryError::EmptyRegistry { + path: agents_dir.display().to_string(), + }); + } + + Ok(Registry { agents, skills }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + const REV: &str = "3875492f6f70fc694bff64167fe0a0d14f346503"; + + fn persona(name: &str, skills: &str) -> String { + format!( + "---\nname: {name}\ndisplay_name: {name}\ndescription: test agent\nmodel: anthropic:claude-fable-5\nruntime: claude\nskills:{skills}\n---\n\nYou are {name}.\n" + ) + } + + /// A studio root with one agent wired end-to-end. + fn studio(dir: &tempfile::TempDir) { + let root = dir.path(); + std::fs::create_dir(root.join("agents")).unwrap(); + std::fs::write( + root.join("agents/ruben.persona.md"), + persona("ruben", "\n - sf-rust"), + ) + .unwrap(); + std::fs::write( + root.join("roster.toml"), + format!("[agents.ruben]\nnpub = \"{NPUB}\"\nskill_tags = [\"rust\"]\n"), + ) + .unwrap(); + std::fs::write( + root.join("skills.toml"), + format!("[skills.sf-rust]\nsource = \"github:solana-foundation/ai-skills\"\npath = \"sf-rust-skill\"\nrev = \"{REV}\"\n"), + ) + .unwrap(); + } + + #[test] + fn full_registry_loads() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + let registry = load(dir.path()).expect("loads"); + let ruben = ®istry.agents["ruben"]; + assert_eq!( + ruben.persona.model.as_deref(), + Some("anthropic:claude-fable-5") + ); + assert_eq!(ruben.roster.npub, NPUB); + assert!(ruben.persona.prompt.contains("You are ruben")); + } + + #[test] + fn frontmatter_typo_fails_boot_naming_the_file() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::write( + dir.path().join("agents/ruben.persona.md"), + "---\nname: ruben\ndisplay_name: ruben\ndescription: d\nmodell: oops\n---\nbody\n", + ) + .unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!( + err.contains("ruben.persona.md") && err.contains("modell"), + "{err}" + ); + } + + #[test] + fn filename_must_match_persona_name() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::write( + dir.path().join("agents/rube.persona.md"), + persona("ruben", " []"), + ) + .unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!( + err.contains("rube.persona.md") && err.contains("`ruben`"), + "{err}" + ); + } + + #[test] + fn unexpected_file_fails_readme_and_dotfiles_pass() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::write(dir.path().join("agents/README.md"), "roster docs").unwrap(); + std::fs::write(dir.path().join("agents/.DS_Store"), "").unwrap(); + load(dir.path()).expect("README + dotfiles ignored"); + + std::fs::write(dir.path().join("agents/ruben.persona"), "typo").unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("ruben.persona"), "{err}"); + } + + #[test] + fn persona_skill_slug_must_resolve() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::write( + dir.path().join("agents/ruben.persona.md"), + persona("ruben", "\n - sf-ghost"), + ) + .unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!( + err.contains("sf-ghost") && err.contains("persona `ruben`"), + "{err}" + ); + } + + #[test] + fn roster_and_personas_must_be_in_bijection() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::write( + dir.path().join("roster.toml"), + format!("[agents.ghost]\nnpub = \"{NPUB}\"\n"), + ) + .unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + // ruben has no roster entry — that error fires first, deterministically. + assert!(err.contains("persona `ruben` has no roster entry"), "{err}"); + + std::fs::write( + dir.path().join("roster.toml"), + format!("[agents.ruben]\nnpub = \"{NPUB}\"\n[agents.ghost]\nnpub = \"{NPUB}\"\n"), + ) + .unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("`ghost` has no persona file"), "{err}"); + } + + #[test] + fn empty_agents_dir_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::remove_file(dir.path().join("agents/ruben.persona.md")).unwrap(); + std::fs::write(dir.path().join("roster.toml"), "").unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!(err.contains("no persona files"), "{err}"); + } + + #[test] + fn missing_agents_dir_error_is_actionable() { + let dir = tempfile::tempdir().unwrap(); + studio(&dir); + std::fs::remove_dir_all(dir.path().join("agents")).unwrap(); + let err = load(dir.path()).unwrap_err().to_string(); + assert!( + err.contains("agents") && err.contains("registry_dir"), + "{err}" + ); + } +} diff --git a/crates/studio-registry/src/roster.rs b/crates/studio-registry/src/roster.rs new file mode 100644 index 0000000..28d48f9 --- /dev/null +++ b/crates/studio-registry/src/roster.rs @@ -0,0 +1,114 @@ +//! `roster.toml` — studio economics, keyed by persona `name` +//! (GUIDELINES.md §3.3). The persona file carries what the runtime needs; +//! this file carries what the studio needs: identity binding and money. +//! Strict frontmatter will not accept foreign keys, so these never live in +//! the `.persona.md`. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Deserialize; + +use crate::RegistryError; + +/// Raw `roster.toml`: `[agents.]` tables. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RosterFile { + #[serde(default)] + agents: BTreeMap, +} + +/// Studio-side record for one agent — joined to the persona by `name`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RosterEntry { + /// Nostr identity the agent signs with (structural check at load). + pub npub: String, + /// Payout key on Solana (ARCHITECTURE.md §5). Optional until the + /// key-binding ceremony has run. + #[serde(default)] + pub solana_pubkey: Option, + /// Reference to the npub↔Solana key-binding attestation event. + #[serde(default)] + pub attestation: Option, + /// Tags crew selection matches quotes against, e.g. `["rust", "buzz"]`. + #[serde(default)] + pub skill_tags: Vec, + /// Day rate in token minor units. Unset = not yet priced by the owner. + #[serde(default)] + pub day_rate: Option, +} + +/// Load `roster.toml` and validate every npub structurally. +pub fn load(path: &Path) -> Result, RegistryError> { + let content = std::fs::read_to_string(path).map_err(|e| RegistryError::File { + path: path.display().to_string(), + message: format!( + "{e} — the roster binds persona names to npubs and rates (GUIDELINES.md §3.3)" + ), + })?; + let roster: RosterFile = toml::from_str(&content).map_err(|e| RegistryError::File { + path: path.display().to_string(), + message: e.to_string(), + })?; + + for (name, entry) in &roster.agents { + if let Err(message) = studio_types::validate_npub(&entry.npub) { + return Err(RegistryError::Roster { + name: name.clone(), + message: format!("npub `{}`: {message}", entry.npub), + }); + } + } + Ok(roster.agents) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; + + fn write(dir: &tempfile::TempDir, content: &str) -> std::path::PathBuf { + let path = dir.path().join("roster.toml"); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn good_roster_parses() { + let dir = tempfile::tempdir().unwrap(); + let path = write( + &dir, + &format!( + "[agents.ruben]\nnpub = \"{GOOD_NPUB}\"\nskill_tags = [\"rust\"]\nday_rate = {{ amount = 500000000, mint = \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\" }}\n" + ), + ); + let roster = load(&path).expect("parses"); + assert_eq!(roster["ruben"].skill_tags, vec!["rust"]); + assert_eq!(roster["ruben"].day_rate.as_ref().unwrap().amount, 500000000); + } + + #[test] + fn bad_npub_rejected_with_name() { + let dir = tempfile::tempdir().unwrap(); + let path = write(&dir, "[agents.ruben]\nnpub = \"npub1notakey\"\n"); + let err = load(&path).unwrap_err().to_string(); + assert!( + err.contains("ruben") && err.contains("npub1notakey"), + "{err}" + ); + } + + #[test] + fn unknown_field_rejected_by_name() { + let dir = tempfile::tempdir().unwrap(); + let path = write( + &dir, + &format!("[agents.ruben]\nnpub = \"{GOOD_NPUB}\"\nday_rat = 5\n"), + ); + let err = load(&path).unwrap_err().to_string(); + assert!(err.contains("day_rat"), "{err}"); + } +} diff --git a/crates/studio-registry/src/skills.rs b/crates/studio-registry/src/skills.rs new file mode 100644 index 0000000..1122e1e --- /dev/null +++ b/crates/studio-registry/src/skills.rs @@ -0,0 +1,187 @@ +//! `skills.toml` — which conventions govern the builds (GUIDELINES.md §4). +//! Personas reference skills by slug; this file says from where, at what +//! version, with what config. Revs are pinned commits, never branches: a +//! skill is a dependency of the build's correctness. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Deserialize; + +use crate::RegistryError; + +/// Parsed, validated `skills.toml`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Skills { + /// Skill definitions keyed by slug — the slugs persona `skills:` lists + /// resolve against. + #[serde(default)] + pub skills: BTreeMap, + /// Role → slugs applied to every crew member of that role + /// (e.g. `backend = ["sf-rust"]`). + #[serde(default)] + pub defaults: BTreeMap>, + /// Per-skill studio config, passed to the skill's context when loaded. + #[serde(default)] + pub overrides: BTreeMap, +} + +/// One pinned skill source. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SkillDef { + /// Where the skill lives, e.g. `github:solana-foundation/ai-skills`. + pub source: String, + /// Path inside the source, e.g. `sf-rust-skill`. Omitted = source root. + #[serde(default)] + pub path: Option, + /// Pinned commit — full 40-hex, never a branch or tag. + pub rev: String, +} + +fn is_full_commit(rev: &str) -> bool { + rev.len() == 40 + && rev + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +/// Load and validate `skills.toml`. Fail-closed: any structural problem is +/// an error naming the file, the slug, and what to fix. +pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| RegistryError::File { + path: path.display().to_string(), + message: format!("{e} — the studio's skill pins live here (GUIDELINES.md §4)"), + })?; + let skills: Skills = toml::from_str(&content).map_err(|e| RegistryError::File { + path: path.display().to_string(), + message: e.to_string(), + })?; + + for (slug, def) in &skills.skills { + if def.source.trim().is_empty() { + return Err(RegistryError::Skill { + slug: slug.clone(), + message: "source must name where the skill lives, e.g. `github:solana-foundation/ai-skills`".into(), + }); + } + if !is_full_commit(&def.rev) { + return Err(RegistryError::Skill { + slug: slug.clone(), + message: format!( + "rev `{}` is not a pinned commit — use the full 40-hex commit hash, never a branch or tag", + def.rev + ), + }); + } + } + for (role, slugs) in &skills.defaults { + for slug in slugs { + if !skills.skills.contains_key(slug) { + return Err(RegistryError::UnknownSkill { + slug: slug.clone(), + referenced_by: format!("defaults.{role} in skills.toml"), + }); + } + } + } + for slug in skills.overrides.keys() { + if !skills.skills.contains_key(slug) { + return Err(RegistryError::UnknownSkill { + slug: slug.clone(), + referenced_by: "overrides in skills.toml".into(), + }); + } + } + Ok(skills) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &tempfile::TempDir, content: &str) -> std::path::PathBuf { + let path = dir.path().join("skills.toml"); + std::fs::write(&path, content).unwrap(); + path + } + + const GOOD: &str = r#" +[skills.sf-rust] +source = "github:solana-foundation/ai-skills" +path = "sf-rust-skill" +rev = "3875492f6f70fc694bff64167fe0a0d14f346503" + +[defaults] +backend = ["sf-rust"] + +[overrides] +sf-rust = { edition = "2021" } +"#; + + #[test] + fn good_file_parses() { + let dir = tempfile::tempdir().unwrap(); + let skills = load(&write(&dir, GOOD)).expect("parses"); + assert_eq!( + skills.skills["sf-rust"].path.as_deref(), + Some("sf-rust-skill") + ); + assert_eq!(skills.defaults["backend"], vec!["sf-rust"]); + } + + #[test] + fn branch_rev_rejected_with_pin_instruction() { + let dir = tempfile::tempdir().unwrap(); + let path = write( + &dir, + "[skills.sf-rust]\nsource = \"github:solana-foundation/ai-skills\"\nrev = \"main\"\n", + ); + let err = load(&path).unwrap_err().to_string(); + assert!(err.contains("main") && err.contains("40-hex"), "{err}"); + } + + #[test] + fn short_rev_rejected() { + let dir = tempfile::tempdir().unwrap(); + let path = write( + &dir, + "[skills.sf-rust]\nsource = \"g\"\nrev = \"3875492\"\n", + ); + let err = load(&path).unwrap_err().to_string(); + assert!(err.contains("40-hex"), "{err}"); + } + + #[test] + fn unknown_key_rejected_by_name() { + let dir = tempfile::tempdir().unwrap(); + let path = write(&dir, "[skillz]\n"); + let err = load(&path).unwrap_err().to_string(); + assert!(err.contains("skillz"), "{err}"); + } + + #[test] + fn default_and_override_slugs_must_resolve() { + let dir = tempfile::tempdir().unwrap(); + let path = write(&dir, "[defaults]\nbackend = [\"sf-rust\"]\n"); + let err = load(&path).unwrap_err().to_string(); + assert!( + err.contains("sf-rust") && err.contains("defaults.backend"), + "{err}" + ); + + let path = write(&dir, "[overrides]\nghost = { a = 1 }\n"); + let err = load(&path).unwrap_err().to_string(); + assert!(err.contains("ghost"), "{err}"); + } + + #[test] + fn missing_file_error_names_path() { + let dir = tempfile::tempdir().unwrap(); + let err = load(&dir.path().join("skills.toml")) + .unwrap_err() + .to_string(); + assert!(err.contains("skills.toml"), "{err}"); + } +} diff --git a/crates/studio-types/src/lib.rs b/crates/studio-types/src/lib.rs index a7ac104..8b00c4d 100644 --- a/crates/studio-types/src/lib.rs +++ b/crates/studio-types/src/lib.rs @@ -17,5 +17,5 @@ pub use gate::{GatePolicy, GateSpec}; pub use quote::{ ChannelParams, MilestoneSpec, NewQuote, PayoutDestination, Quote, QuoteStatus, Split, }; -pub use rfq::{Amount, FieldError, NewRfq, Rfq}; +pub use rfq::{validate_npub, Amount, FieldError, NewRfq, Rfq}; pub use state::{Edge, EdgePattern, ProjectState}; diff --git a/crates/studio-types/src/rfq.rs b/crates/studio-types/src/rfq.rs index 07b4e38..3488214 100644 --- a/crates/studio-types/src/rfq.rs +++ b/crates/studio-types/src/rfq.rs @@ -142,7 +142,9 @@ impl NewRfq { /// Structural npub check: bech32 decodes, HRP is `npub`, payload is 32 bytes. /// Signature verification is a substrate concern and arrives with the /// orchestrator (M3); identity attribution only needs a well-formed key. -fn validate_npub(npub: &str) -> Result<(), String> { +/// Public because the agent registry (roster.toml) binds persona names to +/// npubs with the same structural rule. +pub fn validate_npub(npub: &str) -> Result<(), String> { let (hrp, data) = bech32::decode(npub).map_err(|_| "must be a bech32 npub (npub1…)".to_string())?; if hrp.as_str() != "npub" { diff --git a/roster.toml b/roster.toml new file mode 100644 index 0000000..6180c9b --- /dev/null +++ b/roster.toml @@ -0,0 +1,10 @@ +# roster.toml — studio economics, keyed by persona `name` (GUIDELINES.md §3.3). +# The persona file (agents/.persona.md) carries what the runtime needs; +# this file carries what the studio needs: identity binding and money. +# Loaded fail-closed at scarced boot; every npub is structurally validated. + +[agents.ruben] +npub = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy" +skill_tags = ["rust", "backend", "buzz", "solana"] +# solana_pubkey + attestation arrive with the key-binding ceremony +# (ARCHITECTURE.md §5); day_rate is set by the owner when pricing goes live. diff --git a/scarced.example.yaml b/scarced.example.yaml index 3ca0548..8057386 100644 --- a/scarced.example.yaml +++ b/scarced.example.yaml @@ -7,6 +7,11 @@ db: sqlite://scarced.db studio_token: dev-token sweep_seconds: 30 +# Studio repo root holding agents/*.persona.md, roster.toml and skills.toml. +# Loaded fail-closed at boot: a registry that does not parse is a scarced +# that does not start (GUIDELINES.md §3–§4). +registry_dir: . + # Buzz community the studio operates in. All three keys are required when # the section is present; omit the whole section for a ledger-only run. buzz: diff --git a/skills.toml b/skills.toml new file mode 100644 index 0000000..2aed177 --- /dev/null +++ b/skills.toml @@ -0,0 +1,19 @@ +# skills.toml — which conventions govern the builds (GUIDELINES.md §4). +# Personas reference skills by slug; this file says from where, at what +# version, with what config. Revs are pinned commits — never a branch: an +# unpinned skill is an unpinned compiler. Projects may strengthen, never +# weaken (the same rule the GatePolicy carries). + +[skills.sf-rust] +source = "github:solana-foundation/ai-skills" +path = "sf-rust-skill" +rev = "3875492f6f70fc694bff64167fe0a0d14f346503" + +[skills.sf-tech-docs] +source = "github:solana-foundation/ai-skills" +path = "sf-tech-docs-writer-skill" +rev = "3875492f6f70fc694bff64167fe0a0d14f346503" + +[defaults] +backend = ["sf-rust"] +docs = ["sf-tech-docs"] diff --git a/src/config.rs b/src/config.rs index 56f292c..a980850 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,10 @@ pub struct Config { /// Quote-expiry sweep cadence, seconds. Reads are fail-closed against /// sweep lag either way; the sweep keeps the projection rows honest. pub sweep_seconds: u64, + /// Studio repo root holding `agents/*.persona.md`, `roster.toml` and + /// `skills.toml` (GUIDELINES.md §3–§4). Loaded fail-closed at boot: a + /// registry that does not parse is a scarced that does not start. + pub registry_dir: String, /// Buzz community the studio operates in. Recorded now, consumed by the /// M3 orchestrator (PLAN.md M3). #[serde(default)] @@ -58,6 +62,7 @@ impl Default for Config { db: "sqlite://scarced.db".into(), studio_token: None, sweep_seconds: 30, + registry_dir: ".".into(), buzz: None, } } @@ -96,6 +101,10 @@ impl Config { config.sweep_seconds > 0, "sweep_seconds (SCARCED_SWEEP_SECONDS) must be a positive integer" ); + anyhow::ensure!( + !config.registry_dir.trim().is_empty(), + "registry_dir (SCARCED_REGISTRY_DIR) must point at the studio repo root" + ); if let Some(buzz) = &config.buzz { anyhow::ensure!( buzz.relay_url.starts_with("wss://") || buzz.relay_url.starts_with("ws://"), @@ -130,6 +139,7 @@ mod tests { assert_eq!(config.db, "sqlite://scarced.db"); assert_eq!(config.studio_token, None); assert_eq!(config.sweep_seconds, 30); + assert_eq!(config.registry_dir, "."); assert!(config.buzz.is_none()); Ok(()) }); diff --git a/src/main.rs b/src/main.rs index 141108f..d48e3d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,16 @@ async fn main() -> anyhow::Result<()> { let config = config::Config::load(args.config.as_deref())?; tracing::info!(bind = %config.bind, db = %config.db, "scarced starting"); + // Registry: fail-closed. A roster or skills file that does not parse is + // a scarced that does not start (GUIDELINES.md §3.2). + let registry = studio_registry::load(std::path::Path::new(&config.registry_dir)) + .with_context(|| format!("loading agent registry from {}", config.registry_dir))?; + tracing::info!( + agents = %registry.agents.keys().cloned().collect::>().join(", "), + skills = registry.skills.skills.len(), + "agent registry loaded" + ); + let db = studio_store::open(&config.db) .await .with_context(|| format!("opening projection store at {}", config.db))?; diff --git a/tests/registry_drift.rs b/tests/registry_drift.rs new file mode 100644 index 0000000..02959d6 --- /dev/null +++ b/tests/registry_drift.rs @@ -0,0 +1,24 @@ +//! The repo's own registry files must load — the same drift-guard idea as +//! the generated-schema test: scarced boots fail-closed on these, so a +//! commit that breaks `agents/`, `roster.toml` or `skills.toml` fails here +//! instead of at the next deploy. + +use std::path::Path; + +#[test] +fn repo_registry_loads() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let registry = studio_registry::load(root).expect("repo registry must parse"); + + let ruben = registry + .agents + .get("ruben") + .expect("ruben is the first registry entry"); + assert_eq!(ruben.persona.runtime.as_deref(), Some("claude")); + assert!(!ruben.persona.prompt.trim().is_empty()); + assert!(ruben.roster.npub.starts_with("npub1")); + + // Every default role's slugs resolved at load; spot-check the backend one + // that GUIDELINES.md §1 makes mandatory. + assert!(registry.skills.defaults["backend"].contains(&"sf-rust".to_string())); +}