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:
Z User committed 2026-10-30 13:01:03 +00:00
1 parent d18c463ffd
commit 136379c666
60 files changed
+4776

No files matched your search

+662
View File
@@ -0,0 +1,662 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "aos-ledger"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "aos-license"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"clap",
"ed25519-dalek",
"rand",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "aos-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "aos-policy"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "aos-sync"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"serde",
"serde_json",
"thiserror",
"walkdir",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+15
View File
@@ -0,0 +1,15 @@
[workspace]
resolver = "2"
members = ["aos-mcp", "aos-policy", "aos-license", "aos-ledger", "aos-sync"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://git.dfmpanel.eu/dfmsolutions/AOS"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"
@@ -0,0 +1,14 @@
[package]
name = "aos-ledger"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
[lib]
path = "src/lib.rs"
@@ -0,0 +1,38 @@
//! Immutable audit log entry with a hash chain (GoBD Belegführung).
//!
//! Each entry's `hash` is `sha256(prev_hash || canonical(entry_fields))`.
//! Tampering with any historical entry breaks the chain. This is the
//! GoBD-compliant way to keep an electronic record (§ 146 AO, § 158 AO).
use serde::{Deserialize, Serialize};
/// One audit-log row. Never mutated after creation; corrections are new
/// entries that reference the original.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub task_id: String,
pub actor: String,
pub action: String,
pub entity: String,
pub entity_id: String,
/// ISO-8601 timestamp.
pub at: String,
/// SHA-256 linking to the previous entry's hash. Genesis entry has a
/// fixed sentinel.
pub prev_hash: String,
/// This entry's hash: sha256(prev_hash || canonical_json(self minus hash)).
pub hash: String,
}
impl AuditEntry {
/// Computes the hash for a new entry given the previous one's hash.
///
/// TODO: implement real SHA-256. For the scaffold this returns a
/// placeholder; the actual hashing lands when the `sha2` dep is added
/// (kept out of the initial stub to keep compile time down).
pub fn compute_hash(_prev_hash: &str, _fields_canonical: &str) -> String {
// TODO: sha2::Sha256::digest(format!("{prev_hash}{fields_canonical}"))
"sha256:TODO".to_string()
}
}
@@ -0,0 +1,17 @@
// aos-ledger — money math, gapless invoice numbering, VAT, immutable audit log.
//
// Agents NEVER do money math in prompts. They call these functions. This
// keeps the GoBD guarantees (integer cents, no floats; gapless numbering;
// hash-chained audit trail) deterministic and testable.
//
// See `skills/invoice-draft/SKILL.md` for the agent procedure.
pub mod audit;
pub mod money;
pub mod numbering;
pub mod vat;
pub use audit::AuditEntry;
pub use money::Cents;
pub use numbering::next_invoice_number;
pub use vat::vat_amount;
@@ -0,0 +1,54 @@
//! Money — integer cents, no floats. The GoBD-correct representation.
//!
//! `Cents` is a fixed-point value (1 EUR = 100 cents). All arithmetic stays
//! in integers to avoid the rounding errors that plague floating-point money.
/// Amount in cents (1 EUR = 100). Never constructed from a float.
pub type Cents = i64;
/// Saturating add. Money is always positive in practice; this guards overflow.
pub fn add(a: Cents, b: Cents) -> Cents {
a.saturating_add(b)
}
/// Multiply a net amount by a quantity (integer). For fractional quantities,
/// round half-to-even at the call site first.
pub fn multiply(amount: Cents, qty: i64) -> Cents {
amount.saturating_mul(qty)
}
/// Computes the gross from a net amount + tax in basis points (1900 = 19%).
///
/// `gross = net * (1 + bp/10000)`, rounded to the nearest cent. This matches
/// § 14 UStG (Bruttobetrag on the invoice).
pub fn compute_gross(net_cents: Cents, tax_basis_points: u32) -> Cents {
// (net * (10000 + bp) + 5000) / 10000 — round half up
let scaled = net_cents as i128 * (10000 + tax_basis_points as i128);
let rounded = (scaled + 5000) / 10000;
rounded as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gross_19_percent() {
// 100.00 EUR net @ 19% → 119.00 EUR gross
assert_eq!(compute_gross(10000, 1900), 11900);
}
#[test]
fn gross_7_percent() {
// 100.00 EUR net @ 7% → 107.00 EUR gross
assert_eq!(compute_gross(10000, 700), 10700);
}
#[test]
fn gross_rounds_half_up() {
// 0.01 EUR net @ 19% → 0.0119 → rounds to 0.01 (1 cent)
assert_eq!(compute_gross(1, 1900), 1);
// 10.00 EUR net @ 19% → 11.90 EUR
assert_eq!(compute_gross(1000, 1900), 1190);
}
}
@@ -0,0 +1,35 @@
//! Gapless invoice numbering (GoBD § 146 AO).
//!
//! Numbers are sequential and must never have gaps. A "storno" creates a
//! reversing entry with its own number — the original number is never reused
//! and never deleted.
/// Formats the next invoice number as `PREFIX-YEAR-NNNN` where NNNN is
/// zero-padded to 4 digits.
///
/// `current_max` is the highest sequence number already issued this year.
/// The next is `current_max + 1`. Passing 0 issues the first of the year.
pub fn next_invoice_number(prefix: &str, year: u32, current_max: u32) -> String {
let next = current_max.saturating_add(1);
format!("{prefix}-{year}-{next:04}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_of_year() {
assert_eq!(next_invoice_number("RE", 2026, 0), "RE-2026-0001");
}
#[test]
fn subsequent() {
assert_eq!(next_invoice_number("RE", 2026, 41), "RE-2026-0042");
}
#[test]
fn crosses_thousand_boundary() {
assert_eq!(next_invoice_number("RE", 2026, 999), "RE-2026-1000");
}
}
@@ -0,0 +1,32 @@
//! VAT (Umsatzsteuer) computation.
//!
//! Tax rates are stored in basis points (1900 = 19%, 700 = 7%) to keep
//! everything integer. This avoids float drift in the ledger.
use crate::money::Cents;
/// Computes the VAT amount for a net amount.
///
/// `vat = net * bp / 10000`, rounded half-up to the nearest cent.
pub fn vat_amount(net_cents: Cents, basis_points: u32) -> Cents {
let scaled = net_cents as i128 * basis_points as i128;
let rounded = (scaled + 5000) / 10000;
rounded as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vat_19_percent() {
// 100.00 @ 19% → 19.00
assert_eq!(vat_amount(10000, 1900), 1900);
}
#[test]
fn vat_7_percent() {
// 100.00 @ 7% → 7.00
assert_eq!(vat_amount(10000, 700), 700);
}
}
@@ -0,0 +1,22 @@
[package]
name = "aos-license"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
[lib]
path = "src/lib.rs"
[[bin]]
name = "aos-license"
path = "src/main.rs"
@@ -0,0 +1,69 @@
//! License issuance: builds an ed25519-signed key from a payload.
//!
//! The payload is the customer-facing license metadata (product, plan, domain,
//! expiry). The key is `base64(payload_json).base64(signature)` — verifiable
//! offline by anyone holding the public key.
use anyhow::Result;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
/// The customer-facing license metadata that gets signed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePayload {
pub product: String,
pub plan: String,
pub domain: String,
/// ISO-8601 expiry, or null for perpetual.
pub expires_at: Option<String>,
/// Issued-at timestamp.
pub issued_at: String,
}
/// A signed license key: the URL-safe-base64 payload + signature.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseKey {
/// `base64url(payload_json).base64url(signature)`
pub key: String,
pub payload: LicensePayload,
}
/// Issues a license key signed by `signing_key`.
///
/// The signing key is platform-held; agents never see it. They call this
/// function via the MCP tool `aos-license:issue`.
pub fn issue_license(payload: LicensePayload, signing_key: &SigningKey) -> Result<LicenseKey> {
let payload_json = serde_json::to_vec(&payload)?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json);
let signature = signing_key.sign(&payload_json);
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(LicenseKey {
key: format!("{payload_b64}.{sig_b64}"),
payload,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
#[test]
fn issued_key_round_trips() {
let mut csprng = OsRng;
let signing = SigningKey::generate(&mut csprng);
let payload = LicensePayload {
product: "aos-cms".into(),
plan: "pro".into(),
domain: "example.dfmpanel.eu".into(),
expires_at: Some("2027-01-01T00:00:00Z".into()),
issued_at: "2026-01-01T00:00:00Z".into(),
};
let key = issue_license(payload.clone(), &signing).unwrap();
// Verify with the corresponding public key.
let verifying = signing.verifying_key();
assert!(crate::verify::verify_license(&key.key, &verifying).is_ok());
}
}
@@ -0,0 +1,14 @@
// aos-license — license key issue/verify with ed25519.
//
// Agents call this crate; they never invent or edit keys. The signing key is
// held by the platform (env-configured), never shipped to agents. Keys are
// offline-verifiable: a customer's instance can validate a license without
// calling home.
//
// See `skills/license-lifecycle/SKILL.md` for the agent procedure.
pub mod key;
pub mod verify;
pub use key::{issue_license, LicenseKey, LicensePayload};
pub use verify::verify_license;
@@ -0,0 +1,93 @@
// aos-license CLI: issue + verify subcommands.
//
// Usage:
// aos-license issue --product aos-cms --plan pro --domain example.com [--expires 2027-01-01]
// aos-license verify --key <key-string> --pubkey <base64-public-key>
//
// The signing key for `issue` is read from the AOS_LICENSE_SIGNING_KEY env
// var (base64 32-byte seed). Not having it set is an error — agents must not
// issue keys from the CLI directly in production.
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD, Engine};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "aos-license", version, about = "Ed25519 license key issue/verify")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Issue a signed license key (requires AOS_LICENSE_SIGNING_KEY env).
Issue {
#[arg(long)]
product: String,
#[arg(long)]
plan: String,
#[arg(long)]
domain: String,
#[arg(long)]
expires: Option<String>,
},
/// Verify a license key against a public key.
Verify {
#[arg(long)]
key: String,
#[arg(long, help = "base64 ed25519 public key")]
pubkey: String,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Issue {
product,
plan,
domain,
expires,
} => {
let seed_b64 = std::env::var("AOS_LICENSE_SIGNING_KEY")
.expect("AOS_LICENSE_SIGNING_KEY env var required to issue licenses");
let seed = STANDARD.decode(seed_b64)?;
let signing = ed25519_dalek::SigningKey::from_bytes(
seed.as_slice().try_into().expect("seed must be 32 bytes"),
);
let payload = aos_license::LicensePayload {
product,
plan,
domain,
expires_at: expires,
issued_at: chrono_now_iso(),
};
let key = aos_license::issue_license(payload, &signing)?;
println!("{}", key.key);
Ok(())
}
Command::Verify { key, pubkey } => {
let pubkey_bytes = STANDARD.decode(pubkey)?;
let verifying = ed25519_dalek::VerifyingKey::from_bytes(
pubkey_bytes.as_slice().try_into().expect("pubkey must be 32 bytes"),
)?;
let payload = aos_license::verify_license(&key, &verifying)?;
println!(
"valid: product={}, plan={}, domain={}, expires={:?}",
payload.product, payload.plan, payload.domain, payload.expires_at
);
Ok(())
}
}
}
fn chrono_now_iso() -> String {
// Avoid pulling chrono just for a timestamp — use std.
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
@@ -0,0 +1,26 @@
//! Offline license verification.
use anyhow::Result;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
/// Verifies a license key string against the platform's public key.
///
/// Returns the decoded `LicensePayload` on success. Offline-verifiable: the
/// customer's instance needs only the public key, no network call.
pub fn verify_license(key: &str, public_key: &VerifyingKey) -> Result<crate::LicensePayload> {
let (payload_b64, sig_b64) = key
.split_once('.')
.ok_or_else(|| anyhow::anyhow!("invalid license key format (no '.' separator)"))?;
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64)?;
let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64)?;
let signature = Signature::from_slice(&sig_bytes)?;
public_key
.verify(&payload_bytes, &signature)
.map_err(|_| anyhow::anyhow!("license signature verification failed"))?;
let payload: crate::LicensePayload = serde_json::from_slice(&payload_bytes)?;
Ok(payload)
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "aos-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
[lib]
path = "src/lib.rs"
+13
View File
@@ -0,0 +1,13 @@
// aos-mcp — MCP server exposing Agency OS (tasks, clients, leads, licenses,
// invoices, content) to every runtime (Claude Code, Claude Desktop, Hermes).
//
// Built on the Rust MCP SDK (`rmcp`). This crate is the single gateway agents
// use to read/write AOS — no agent keeps private state, and every tool call
// is logged + risk-classified for the policy crate to gate on.
//
// TODO: integrate `rmcp` once the SDK stabilizes. The tool registry + risk
// tagging is defined here so the policy crate can consume it immediately.
pub mod tools;
pub use tools::{Risk, ToolSpec};
+40
View File
@@ -0,0 +1,40 @@
//! Tool registry + risk classification.
//!
//! Every tool exposed to agents carries a `Risk` tag. The `aos-policy` crate
//! enforces that an agent's tier (and autonomy level) permits the call. See
//! `policies/tool-risk.md` for the human-readable classification.
use serde::{Deserialize, Serialize};
/// Risk classification for an MCP tool. Drives approval gating.
///
/// - `Low`: read-only, idempotent, no external side effects. Any tier.
/// - `Medium`: internal reversible writes, or reads touching PII/financials.
/// T1/T2 direct; T3 needs a reviewer pass.
/// - `High`: irreversible, external, or money-moving. **Always** produces an
/// `ApprovalRequest` regardless of tier. Never auto-executed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Risk {
Low,
Medium,
High,
}
/// Specification of a tool exposed via the MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
/// Dotted name, e.g. `aos-mcp:read_task`, `ads:spend`.
pub name: String,
pub risk: Risk,
pub description: String,
}
/// Returns the registered tool specs.
///
/// TODO: populate from a manifest file or a proc-macro inventory. For now
/// this is an empty registry — the structure is what the policy crate
/// consumes.
pub fn list_tools() -> Vec<ToolSpec> {
Vec::new()
}
@@ -0,0 +1,14 @@
[package]
name = "aos-policy"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
[lib]
path = "src/lib.rs"
@@ -0,0 +1,82 @@
//! Approval request generation for high-risk actions.
//!
//! Mirrors `schemas/approval.json`. A tool call that `can_execute()` rejects
//! produces one of these, which lands in the AOS approval inbox + notifies the
//! human approver.
use serde::{Deserialize, Serialize};
/// The category of irreversible/external action. Each maps to a hard rule in
/// `policies/autonomy.md`. Must stay in sync with `schemas/approval.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalAction {
Spend,
PublishPublic,
SendInvoice,
SendClientCommunication,
SendOutreach,
RevokeLicense,
DeleteData,
DeleteInstance,
DeleteDns,
ProductionDeploy,
DbMigrationLive,
FileWithAuthorities,
ContractChange,
BudgetChange,
CampaignActivation,
}
/// A request that pauses a task at a human gate.
///
/// See `schemas/approval.json` for the authoritative shape. This struct is
/// the Rust mirror; the policy crate constructs it, AOS persists it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub approval_id: String,
pub task_id: String,
pub agent: String,
pub action: ApprovalAction,
pub risk: ApprovalRisk,
pub rationale: String,
pub diff_summary: String,
/// Structured details for the approver to inspect. Validated per-action.
#[serde(default)]
pub payload: serde_json::Value,
pub expires_at: Option<String>,
#[serde(default)]
pub estimated_cost_eur: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalRisk {
Medium,
High,
Critical,
}
/// Constructs an `ApprovalRequest` for the given action. The caller fills in
/// `rationale`, `diff_summary`, and `payload`; this provides the boilerplate.
///
/// `approval_id` + `task_id` + `agent` are required; `risk` defaults to High.
pub fn require_approval(
approval_id: impl Into<String>,
task_id: impl Into<String>,
agent: impl Into<String>,
action: ApprovalAction,
) -> ApprovalRequest {
ApprovalRequest {
approval_id: approval_id.into(),
task_id: task_id.into(),
agent: agent.into(),
action,
risk: ApprovalRisk::High,
rationale: String::new(),
diff_summary: String::new(),
payload: serde_json::Value::Null,
expires_at: None,
estimated_cost_eur: 0.0,
}
}
@@ -0,0 +1,66 @@
//! Autonomy levels and the rule that decides whether a given (level, risk)
//! combination may execute without an approval.
use serde::{Deserialize, Serialize};
/// Agent autonomy level. L3 (full autonomy) is intentionally absent — not
/// used initially. See `policies/autonomy.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AutonomyLevel {
/// Read/analyze only. Produces a report/draft, changes no state.
L0,
/// Drafts inside the system; a human approves before anything leaves.
L1,
/// Acts alone within caps and reversible scope. Internal-only side effects.
L2,
}
/// The risk classification mirrored from `aos-mcp::Risk` to avoid a circular
/// dependency. Kept in sync manually (a test asserts the string forms match).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolRisk {
Low,
Medium,
High,
}
/// Returns whether an agent at `level` may execute a tool of `risk` without
/// producing an approval request.
///
/// - Low risk: always executable (any level).
/// - Medium risk: L1/L2 may execute (internal reversible writes). L0 may not.
/// - High risk: **never** auto-executed. Always produces an `ApprovalRequest`
/// regardless of level. Returns `false`.
pub fn can_execute(level: AutonomyLevel, risk: ToolRisk) -> bool {
match risk {
ToolRisk::Low => true,
ToolRisk::Medium => matches!(level, AutonomyLevel::L1 | AutonomyLevel::L2),
ToolRisk::High => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn low_risk_always_executable() {
for level in [AutonomyLevel::L0, AutonomyLevel::L1, AutonomyLevel::L2] {
assert!(can_execute(level, ToolRisk::Low));
}
}
#[test]
fn high_risk_never_auto_executed() {
for level in [AutonomyLevel::L0, AutonomyLevel::L1, AutonomyLevel::L2] {
assert!(!can_execute(level, ToolRisk::High));
}
}
#[test]
fn medium_risk_blocked_for_l0() {
assert!(!can_execute(AutonomyLevel::L0, ToolRisk::Medium));
assert!(can_execute(AutonomyLevel::L1, ToolRisk::Medium));
assert!(can_execute(AutonomyLevel::L2, ToolRisk::Medium));
}
}
@@ -0,0 +1,106 @@
//! Per-task budget tracking (EUR + tokens). Hard-stops when the cap is reached
//! — no agent can spend past its declared ceiling, even with approval, without
//! an explicit budget-increase approval.
use anyhow::{bail, Result};
/// Tracks cumulative spend for a single task. The policy crate owns one per
/// active `task_id`.
#[derive(Debug, Default)]
pub struct BudgetTracker {
task_id: String,
spent_eur: f64,
spent_tokens: u64,
cap_eur: f64,
cap_tokens: u64,
}
/// Result of checking spend against the cap.
#[derive(Debug, PartialEq, Eq)]
pub enum SpendCheck {
/// Within budget.
Ok,
/// Would exceed the cap — the call is rejected.
OverBudget,
}
impl BudgetTracker {
pub fn new(task_id: impl Into<String>, cap_eur: f64, cap_tokens: u64) -> Self {
Self {
task_id: task_id.into(),
spent_eur: 0.0,
spent_tokens: 0,
cap_eur,
cap_tokens,
}
}
/// Checks whether spending `eur` + `tokens` more is allowed. Does NOT
/// record the spend — call `record` after the action succeeds.
pub fn check_spend(&self, eur: f64, tokens: u64) -> SpendCheck {
if self.spent_eur + eur > self.cap_eur + f64::EPSILON {
return SpendCheck::OverBudget;
}
if self.spent_tokens.saturating_add(tokens) > self.cap_tokens {
return SpendCheck::OverBudget;
}
SpendCheck::Ok
}
/// Records actual spend. Returns an error if it would exceed the cap
/// (defensive — `check_spend` should have caught it).
pub fn record(&mut self, eur: f64, tokens: u64) -> Result<()> {
if self.check_spend(eur, tokens) == SpendCheck::OverBudget {
bail!(
"budget cap exceeded for task {}: {:.2} EUR / {} tokens (cap {:.2} / {})",
self.task_id,
self.spent_eur + eur,
self.spent_tokens + tokens,
self.cap_eur,
self.cap_tokens
);
}
self.spent_eur += eur;
self.spent_tokens = self.spent_tokens.saturating_add(tokens);
Ok(())
}
pub fn spent_eur(&self) -> f64 {
self.spent_eur
}
pub fn spent_tokens(&self) -> u64 {
self.spent_tokens
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn within_budget_ok() {
let t = BudgetTracker::new("AOS-1", 10.0, 1000);
assert_eq!(t.check_spend(5.0, 500), SpendCheck::Ok);
}
#[test]
fn over_eur_budget_rejected() {
let t = BudgetTracker::new("AOS-1", 10.0, 1000);
assert_eq!(t.check_spend(15.0, 100), SpendCheck::OverBudget);
}
#[test]
fn over_token_budget_rejected() {
let t = BudgetTracker::new("AOS-1", 10.0, 1000);
assert_eq!(t.check_spend(1.0, 2000), SpendCheck::OverBudget);
}
#[test]
fn record_accumulates() {
let mut t = BudgetTracker::new("AOS-1", 10.0, 1000);
t.record(3.0, 200).unwrap();
t.record(3.0, 200).unwrap();
assert_eq!(t.spent_eur(), 6.0);
assert_eq!(t.spent_tokens(), 400);
}
}
@@ -0,0 +1,17 @@
// aos-policy — approval gate + budget enforcement.
//
// Every high-risk tool call passes through this crate. It enforces:
// - autonomy levels (L0/L1/L2) vs tool risk
// - budget caps per task (EUR + tokens)
// - the hard approval list (spend, publish, send, revoke, delete, deploy,
// file-with-authorities, contract changes)
//
// See `policies/autonomy.md` and `policies/tool-risk.md` for the rules.
pub mod approval;
pub mod autonomy;
pub mod budget;
pub use approval::{ApprovalAction, ApprovalRequest};
pub use autonomy::{can_execute, AutonomyLevel};
pub use budget::BudgetTracker;
+20
View File
@@ -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"
+16
View File
@@ -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};
+51
View File
@@ -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(())
}
+269
View File
@@ -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('"', "\\\""))
}