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
Generated
+662
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -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