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,20 @@
|
||||
[package]
|
||||
name = "aos-sync"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
walkdir = "2"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "aos-sync"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,16 @@
|
||||
// aos-sync — compiles canonical agent specs → runtime output.
|
||||
//
|
||||
// One canonical agent spec in `agents/<dept>/<name>.md` is the source of
|
||||
// truth. This crate:
|
||||
// - validates the frontmatter against the required fields
|
||||
// - generates `.claude/agents/*.md` (Claude Code subagents)
|
||||
// - generates Claude Desktop config
|
||||
// - generates Hermes skill files
|
||||
// - diffs generated output so a changed spec goes through PR review
|
||||
//
|
||||
// Run `cargo run -p aos-sync -- validate` to check specs, or
|
||||
// `cargo run -p aos-sync -- sync` to generate runtime output.
|
||||
|
||||
pub mod spec;
|
||||
|
||||
pub use spec::{validate_agents, AgentSpec, SpecError};
|
||||
@@ -0,0 +1,51 @@
|
||||
// aos-sync CLI: validate + sync subcommands.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "aos-sync", version, about = "Compile canonical agent specs → runtime output")]
|
||||
struct Cli {
|
||||
/// Path to the agency-agents root (default: current dir).
|
||||
#[arg(long, default_value = ".")]
|
||||
root: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Validate all agent + skill specs against the required schema.
|
||||
Validate,
|
||||
/// Generate runtime-specific output (claude-code, desktop, hermes).
|
||||
Sync,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let agents_dir = cli.root.join("agents");
|
||||
match cli.command {
|
||||
Command::Validate => {
|
||||
let (specs, errors) = aos_sync::validate_agents(&agents_dir)?;
|
||||
println!("✓ {} valid agent specs", specs.len());
|
||||
for s in &specs {
|
||||
println!(" - {} ({}/{})", s.name, s.tier, s.department);
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
eprintln!("✗ {} validation errors:", errors.len());
|
||||
for e in &errors {
|
||||
eprintln!(" - {e}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Command::Sync => {
|
||||
println!("sync: not yet implemented — would write runimes/claude-code, desktop, hermes");
|
||||
// TODO: generate .claude/agents/*.md, desktop config, hermes skills
|
||||
// by transforming the canonical specs through each runtime's template.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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