feat: zeroclaw agentic agency architecture — agents, skills, Rust crates
- 18 agent specs across Software, Support, Marketing, Cross-cutting (orchestrator + 7 software + 5 support + 3 cross-cutting + leads-finder) - 8 reusable skills (handoff, pr-review, dokploy-deploy, campaign-structure, content-calendar, ticket-triage, license-lifecycle, invoice-draft) - 3 JSON schemas: handoff, deliverable, approval - Policies: autonomy levels, tool risk classification, compliance (DSGVO/GoBD/UWG) - routing.toml: model tiers T1/T2/T3 + provider config (PII gating) - Rust workspace (5 crates, all compile + 16 tests pass): - aos-mcp: tool registry + risk tagging - aos-policy: autonomy levels, budget enforcement, approval requests - aos-license: ed25519 license issue/verify (real crypto + CLI binary) - aos-ledger: integer-cents money math, gapless numbering, VAT, audit chain - aos-sync: frontmatter validator (18/18 specs pass), CLI binary - CLAUDE.md entry point with architecture overview + roadmap
This commit is contained in:
1 parent
d18c463ffd
commit
136379c666
60 files changed
+4776
No files matched your search
@@ -0,0 +1,269 @@
|
||||
//! Agent spec parsing + validation.
|
||||
//!
|
||||
//! An agent spec is a markdown file with YAML frontmatter. The required
|
||||
//! fields mirror the format documented in `CLAUDE.md`. Validation fails fast
|
||||
//! on missing fields so a broken spec never reaches a runtime.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use thiserror::Error;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SpecError {
|
||||
#[error("missing required field '{0}' in {1}")]
|
||||
MissingField(&'static str, String),
|
||||
#[error("could not read file {0}: {1}")]
|
||||
Io(String, String),
|
||||
#[error("could not parse frontmatter in {0}: {1}")]
|
||||
Parse(String, String),
|
||||
}
|
||||
|
||||
/// The frontmatter of an agent spec. Mirrors `agents/<dept>/<name>.md`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentSpec {
|
||||
pub name: String,
|
||||
pub tier: String,
|
||||
pub department: String,
|
||||
pub reports_to: String,
|
||||
pub autonomy: String,
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub forbidden: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub budget: Budget,
|
||||
#[serde(default)]
|
||||
pub language: Language,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Budget {
|
||||
#[serde(default)]
|
||||
pub max_eur_per_task: f64,
|
||||
#[serde(default)]
|
||||
pub max_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Language {
|
||||
#[serde(default = "default_chat")]
|
||||
pub chat: String,
|
||||
#[serde(default = "default_deliverables")]
|
||||
pub deliverables: String,
|
||||
}
|
||||
|
||||
fn default_chat() -> String {
|
||||
"tr".into()
|
||||
}
|
||||
fn default_deliverables() -> String {
|
||||
"en".into()
|
||||
}
|
||||
|
||||
const REQUIRED_FIELDS: &[&str] = &["name", "tier", "department", "reports_to", "autonomy"];
|
||||
|
||||
/// Walks `agents/` and parses every `.md` into an `AgentSpec`.
|
||||
///
|
||||
/// Returns the specs + any validation errors. A spec with errors is excluded
|
||||
/// from the success list and reported in the error vec.
|
||||
pub fn validate_agents(root: &Path) -> Result<(Vec<AgentSpec>, Vec<SpecError>)> {
|
||||
let mut specs = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(root).unwrap_or(path).display().to_string();
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
errors.push(SpecError::Io(rel, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let frontmatter = match extract_frontmatter(&content) {
|
||||
Some(fm) => fm,
|
||||
None => {
|
||||
errors.push(SpecError::Parse(rel, "no --- frontmatter block found".into()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Parse the YAML frontmatter as JSON-ish: convert simple YAML to JSON.
|
||||
// (Agent frontmatter is flat — key: value + list items — so a minimal
|
||||
// conversion suffices without a full YAML parser dep.)
|
||||
let json_str = match yaml_to_json(frontmatter) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
errors.push(SpecError::Parse(rel, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&json_str) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
errors.push(SpecError::Parse(rel, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Check required fields.
|
||||
let obj = match parsed.as_object() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
errors.push(SpecError::Parse(rel, "frontmatter is not an object".into()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
for field in REQUIRED_FIELDS {
|
||||
if !obj.contains_key(*field) {
|
||||
missing.push(*field);
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
errors.push(SpecError::MissingField(missing[0], rel));
|
||||
continue;
|
||||
}
|
||||
// Deserialize into the typed struct (best-effort; ignore unknown fields).
|
||||
let spec: AgentSpec = match serde_json::from_value(parsed) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
errors.push(SpecError::Parse(rel, e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
specs.push(spec);
|
||||
}
|
||||
|
||||
Ok((specs, errors))
|
||||
}
|
||||
|
||||
/// Extracts the content between `---` fences at the top of a markdown file.
|
||||
fn extract_frontmatter(content: &str) -> Option<&str> {
|
||||
let trimmed = content.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
let after_first_fence = &trimmed[3..];
|
||||
let end = after_first_fence.find("\n---")?;
|
||||
Some(after_first_fence[..end].trim())
|
||||
}
|
||||
|
||||
/// Minimal YAML→JSON converter for flat agent-spec frontmatter.
|
||||
///
|
||||
/// Supports only the subset we use: `key: value`, `key: [a, b, c]` inline
|
||||
/// arrays, and block arrays (`- item`). Nested objects (like `budget:` with
|
||||
/// sub-keys) and `language:` are handled with a simple indentation stack.
|
||||
/// This avoids pulling a full YAML parser dep into the scaffold.
|
||||
fn yaml_to_json(yaml: &str) -> Result<String> {
|
||||
let mut out = String::from("{");
|
||||
let lines: Vec<&str> = yaml.lines().collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim_end();
|
||||
if line.trim().is_empty() || line.trim_start().starts_with('#') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (key, rest) = match line.split_once(':') {
|
||||
Some((k, v)) => (k.trim(), v.trim()),
|
||||
None => {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
// Could be a block array (`- item`) or a nested object.
|
||||
// Peek ahead: collect comment + dash lines as array items.
|
||||
let mut items: Vec<String> = Vec::new();
|
||||
let mut j = i + 1;
|
||||
while j < lines.len() {
|
||||
let l = lines[j].trim();
|
||||
if l.is_empty() {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip comment lines inside a block array.
|
||||
if l.starts_with('#') {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
if !l.starts_with('-') {
|
||||
break;
|
||||
}
|
||||
let val = l.trim_start_matches('-').trim();
|
||||
items.push(quote_or_passthrough(val));
|
||||
j += 1;
|
||||
}
|
||||
if !items.is_empty() {
|
||||
out.push_str(&format!("\"{key}\":[{}],", items.join(",")));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// Nested object: collect indented lines until dedent.
|
||||
let mut sub = String::from("{");
|
||||
while j < lines.len() {
|
||||
let l = lines[j];
|
||||
if !l.starts_with(' ') && !l.starts_with('\t') {
|
||||
break;
|
||||
}
|
||||
if let Some((sk, sv)) = l.trim().split_once(':') {
|
||||
sub.push_str(&format!(
|
||||
"\"{}\":{},",
|
||||
sk.trim(),
|
||||
quote_or_passthrough(sv.trim())
|
||||
));
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
sub.pop(); // trailing comma
|
||||
sub.push('}');
|
||||
out.push_str(&format!("\"{key}\":{sub},"));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// Inline array? `[a, b, c]`
|
||||
if rest.starts_with('[') {
|
||||
let inner = rest
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split(',')
|
||||
.map(|s| quote_or_passthrough(s.trim()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
out.push_str(&format!("\"{key}\":[{inner}],"));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"\"{key}\":{},",
|
||||
quote_or_passthrough(rest)
|
||||
));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if out.ends_with(',') {
|
||||
out.pop();
|
||||
}
|
||||
out.push('}');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Quotes a value as a JSON string unless it looks like a number or bool.
|
||||
fn quote_or_passthrough(v: &str) -> String {
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
return "\"\"".into();
|
||||
}
|
||||
if v == "true" || v == "false" || v == "null" {
|
||||
return v.into();
|
||||
}
|
||||
if v.parse::<f64>().is_ok() {
|
||||
return v.into();
|
||||
}
|
||||
// Strip surrounding quotes if present, then re-add as JSON string.
|
||||
let v = v.trim_matches('"');
|
||||
format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
Reference in new issue
Block a user