@polderlabs/bizar 6.3.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/.claude/hooks/sessionend-recall.mjs +3 -3
  2. package/.claude/hooks/worker-suggest.mjs +110 -0
  3. package/.claude/settings.json +9 -0
  4. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-CW6fWKds.js} +3 -3
  5. package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js.map → EnvVarsSection-CW6fWKds.js.map} +1 -1
  6. package/bizar-dash/dist/assets/MobileChat-D7zRhU9K.js +1 -0
  7. package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-D7zRhU9K.js.map} +1 -1
  8. package/bizar-dash/dist/assets/MobileSettings-K-sNsn_r.js +1 -0
  9. package/bizar-dash/dist/assets/{MobileSettings-CEQNJNLJ.js.map → MobileSettings-K-sNsn_r.js.map} +1 -1
  10. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
  11. package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
  12. package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
  13. package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
  14. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
  15. package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
  16. package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
  17. package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
  18. package/bizar-dash/dist/assets/main-C-Md3DaY.css +1 -0
  19. package/bizar-dash/dist/assets/main-CO8fIxQK.js +18 -0
  20. package/bizar-dash/dist/assets/main-CO8fIxQK.js.map +1 -0
  21. package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
  22. package/bizar-dash/dist/assets/{markdown-tOLaD6nm.js.map → markdown-w2bDDkAH.js.map} +1 -1
  23. package/bizar-dash/dist/assets/mobile-DXYCA0hC.js +1 -0
  24. package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DXYCA0hC.js.map} +1 -1
  25. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js +2 -0
  26. package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js.map +1 -0
  27. package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
  28. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
  29. package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
  30. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js +2 -0
  31. package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js.map +1 -0
  32. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
  33. package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
  34. package/bizar-dash/dist/index.html +12 -10
  35. package/bizar-dash/dist/mobile.html +7 -4
  36. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  37. package/bizar-dash/src/server/api.mjs +11 -0
  38. package/bizar-dash/src/server/memory-consolidator.mjs +218 -0
  39. package/bizar-dash/src/server/memory-distillation.mjs +296 -0
  40. package/bizar-dash/src/server/routes/distill.mjs +129 -0
  41. package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
  42. package/bizar-dash/src/web/App.tsx +4 -0
  43. package/bizar-dash/src/web/components/Tag.tsx +18 -5
  44. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  45. package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
  46. package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
  47. package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
  48. package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
  49. package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
  50. package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
  51. package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
  52. package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
  53. package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
  54. package/bizar-dash/src/web/lib/types.ts +25 -0
  55. package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
  56. package/bizar-dash/src/web/styles/main.css +251 -0
  57. package/bizar-dash/src/web/styles/tasks-redesign.css +231 -0
  58. package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
  59. package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
  60. package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
  61. package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
  62. package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
  63. package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
  64. package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
  65. package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
  66. package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
  67. package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
  68. package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
  69. package/bizar-dash/src/web/ui/controls/controls.css +454 -0
  70. package/bizar-dash/src/web/ui/controls/index.ts +33 -0
  71. package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
  72. package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
  73. package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
  74. package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
  75. package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
  76. package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
  77. package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
  78. package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
  79. package/bizar-dash/src/web/ui/data/data.css +535 -0
  80. package/bizar-dash/src/web/ui/data/index.ts +31 -0
  81. package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
  82. package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
  83. package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
  84. package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
  85. package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
  86. package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
  87. package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
  88. package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
  89. package/bizar-dash/src/web/ui/index.ts +56 -0
  90. package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
  91. package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
  92. package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
  93. package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
  94. package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
  95. package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
  96. package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
  97. package/bizar-dash/src/web/ui/layout/index.ts +33 -0
  98. package/bizar-dash/src/web/ui/layout/layout.css +413 -0
  99. package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
  100. package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
  101. package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
  102. package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
  103. package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
  104. package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
  105. package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
  106. package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
  107. package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
  108. package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
  109. package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
  110. package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
  111. package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
  112. package/bizar-dash/src/web/ui/styles/globals.css +86 -0
  113. package/bizar-dash/src/web/ui/styles/reset.css +125 -0
  114. package/bizar-dash/src/web/ui/styles/tokens.css +195 -0
  115. package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
  116. package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
  117. package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
  118. package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
  119. package/bizar-dash/src/web/views/Agents.tsx +506 -266
  120. package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
  121. package/bizar-dash/src/web/views/Overview.tsx +996 -448
  122. package/bizar-dash/src/web/views/Tasks.tsx +277 -273
  123. package/bizar-dash/tests/communication-log.test.tsx +67 -0
  124. package/bizar-dash/tests/dep-graph.test.tsx +40 -0
  125. package/bizar-dash/tests/event-log.test.tsx +61 -0
  126. package/bizar-dash/tests/goal-input.test.tsx +72 -0
  127. package/bizar-dash/tests/goap-planner.test.ts +68 -0
  128. package/bizar-dash/tests/quality-gates.test.tsx +59 -0
  129. package/bizar-dash/tests/setup.ts +31 -1
  130. package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
  131. package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
  132. package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
  133. package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
  134. package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
  135. package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
  136. package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
  137. package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
  138. package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
  139. package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
  140. package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
  141. package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
  142. package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
  143. package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
  144. package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
  145. package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
  146. package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
  147. package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
  148. package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
  149. package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
  150. package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
  151. package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
  152. package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
  153. package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
  154. package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
  155. package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
  156. package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
  157. package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
  158. package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
  159. package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
  160. package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
  161. package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
  162. package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
  163. package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
  164. package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
  165. package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
  166. package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
  167. package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
  168. package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
  169. package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
  170. package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
  171. package/bizar-dash/tests/views/Agents.test.tsx +359 -0
  172. package/bizar-dash/tests/views/Overview.test.tsx +413 -0
  173. package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
  174. package/bizar-dash/vitest.config.ts +3 -1
  175. package/cli/__tests__/cost-gate.test.mjs +301 -0
  176. package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
  177. package/cli/bin.mjs +38 -0
  178. package/cli/commands/claim.mjs +282 -0
  179. package/cli/commands/cost.mjs +279 -0
  180. package/cli/cost-gate.mjs +501 -0
  181. package/cli/feature-list-bridge.mjs +400 -0
  182. package/cli/memory-constants.mjs +17 -0
  183. package/cli/worker-dispatcher.mjs +259 -0
  184. package/cli/worker-dispatcher.test.mjs +204 -0
  185. package/config/trigger-patterns.json +186 -0
  186. package/package.json +2 -2
  187. package/packages/sdk/package.json +1 -1
  188. package/packages/sdk/src/agent-registry.ts +304 -0
  189. package/packages/sdk/src/consensus/byzantine.ts +587 -0
  190. package/packages/sdk/src/consensus/index.ts +147 -0
  191. package/packages/sdk/src/consensus/queen.ts +173 -0
  192. package/packages/sdk/src/consensus/types.ts +199 -0
  193. package/packages/sdk/src/federation/audit.ts +156 -0
  194. package/packages/sdk/src/federation/budget.ts +269 -0
  195. package/packages/sdk/src/federation/envelope.ts +144 -0
  196. package/packages/sdk/src/federation/hmac.ts +160 -0
  197. package/packages/sdk/src/federation/index.ts +443 -0
  198. package/packages/sdk/src/federation/pii.ts +241 -0
  199. package/packages/sdk/src/federation/policy.ts +112 -0
  200. package/packages/sdk/src/federation/trust.ts +146 -0
  201. package/packages/sdk/src/index.ts +67 -0
  202. package/packages/sdk/src/mcp/server.ts +351 -1
  203. package/packages/sdk/src/router/codemod-intent.ts +125 -0
  204. package/packages/sdk/src/router/index.ts +247 -0
  205. package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
  206. package/packages/sdk/src/router/memory-distillation.ts +249 -0
  207. package/packages/sdk/src/router/model-router.ts +235 -0
  208. package/packages/sdk/src/router/q-learning-router.ts +304 -0
  209. package/packages/sdk/src/swarm-topology.ts +279 -0
  210. package/packages/sdk/tests/agent-registry.test.ts +285 -0
  211. package/packages/sdk/tests/consensus.test.ts +376 -0
  212. package/packages/sdk/tests/federation/audit.test.ts +164 -0
  213. package/packages/sdk/tests/federation/budget.test.ts +189 -0
  214. package/packages/sdk/tests/federation/envelope.test.ts +120 -0
  215. package/packages/sdk/tests/federation/hmac.test.ts +194 -0
  216. package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
  217. package/packages/sdk/tests/federation/pii.test.ts +106 -0
  218. package/packages/sdk/tests/federation/policy.test.ts +140 -0
  219. package/packages/sdk/tests/federation/trust.test.ts +124 -0
  220. package/packages/sdk/tests/mcp-tools.test.ts +204 -0
  221. package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
  222. package/packages/sdk/tests/model-router.test.mjs +108 -0
  223. package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
  224. package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
  225. package/packages/sdk/tests/router.test.mjs +91 -0
  226. package/packages/sdk/tests/sdk.test.mjs +26 -2
  227. package/packages/sdk/tests/swarm-topology.test.ts +258 -0
  228. package/scripts/bh-full-e2e.mjs +11 -15
  229. package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
  230. package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
  231. package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
  232. package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
  233. package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
  234. package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
  235. package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
  236. package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
  237. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
  238. package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
  239. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
  240. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
  241. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
  242. package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
  243. package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
  244. package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
  245. package/packages/sdk/package-lock.json +0 -32
@@ -0,0 +1,269 @@
1
+ /**
2
+ * federation/budget.ts — FederationBudget: per-peer reserved →
3
+ * committed → released state machine for token / USD spend.
4
+ *
5
+ * F-038 — ported from ruflo
6
+ * `v3/@claude-flow/plugin-agent-federation/src/domain/value-objects/federation-budget.ts`
7
+ * (validateBudget + enforceBudget shape) and folded into a class
8
+ * that tracks actual spend per peer across calls.
9
+ *
10
+ * State machine (per peer):
11
+ * reserved: amount is held back (the peer is going to spend ≤ N).
12
+ * committed: amount was actually spent (the call completed and we
13
+ * know the real cost).
14
+ * released: amount returned to the peer (call failed before
15
+ * commit; we give the hold back).
16
+ *
17
+ * Persistence: optional JSON snapshot at
18
+ * `.harness/federation-budget.json` so the budget survives a
19
+ * Claude Code session restart. The file is gitignored.
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
23
+ import { dirname } from "node:path";
24
+
25
+ /** Default budget JSON path. */
26
+ export const DEFAULT_BUDGET_PATH = ".harness/federation-budget.json";
27
+
28
+ /** Hard ceilings — matches ruflo's MAX_*_CEILING values, kept here
29
+ * so callers can't ask for absurdly large budgets. */
30
+ export const MAX_USD_CEILING = 1_000_000;
31
+ export const MAX_TOKENS_CEILING = 1_000_000_000;
32
+
33
+ export type ReserveResult =
34
+ | { ok: true; reservationId: string }
35
+ | { ok: false; reason: "peer_over_budget" | "invalid_amount" };
36
+
37
+ export interface PerPeerBudget {
38
+ readonly nodeId: string;
39
+ readonly maxTokens: number;
40
+ readonly maxUsd: number;
41
+ readonly maxHops: number;
42
+ readonly spentTokens: number;
43
+ readonly spentUsd: number;
44
+ }
45
+
46
+ export interface PersistedBudget {
47
+ readonly version: 1;
48
+ readonly updatedAt: string;
49
+ readonly perPeer: PerPeerBudget[];
50
+ }
51
+
52
+ interface Reservation {
53
+ readonly id: string;
54
+ readonly nodeId: string;
55
+ readonly tokens: number;
56
+ readonly usd: number;
57
+ readonly atMs: number;
58
+ }
59
+
60
+ let reservationCounter = 0;
61
+
62
+ function nextReservationId(): string {
63
+ reservationCounter++;
64
+ // 128-bit random suffix avoids collision across processes.
65
+ return `res-${Date.now().toString(36)}-${reservationCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
66
+ }
67
+
68
+ /** Pure validator — borrowed from ruflo's `validateBudget` and
69
+ * simplified. Returns a discriminated result. */
70
+ export function validateBudgetInput(raw: unknown): { ok: true; maxTokens: number; maxUsd: number; maxHops: number } | { ok: false; error: string } {
71
+ if (raw == null) {
72
+ return {
73
+ ok: true,
74
+ maxTokens: MAX_TOKENS_CEILING,
75
+ maxUsd: MAX_USD_CEILING,
76
+ maxHops: 8,
77
+ };
78
+ }
79
+ if (typeof raw !== "object" || Array.isArray(raw)) {
80
+ return { ok: false, error: "budget must be an object" };
81
+ }
82
+ const obj = raw as Record<string, unknown>;
83
+ const hops = typeof obj.maxHops === "number" && Number.isFinite(obj.maxHops) ? obj.maxHops : 8;
84
+ const tokens = typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) ? obj.maxTokens : MAX_TOKENS_CEILING;
85
+ const usd = typeof obj.maxUsd === "number" && Number.isFinite(obj.maxUsd) ? obj.maxUsd : MAX_USD_CEILING;
86
+ if (hops < 0 || hops > 64) return { ok: false, error: "maxHops out of range" };
87
+ if (tokens < 0 || tokens > MAX_TOKENS_CEILING) return { ok: false, error: "maxTokens out of range" };
88
+ if (usd < 0 || usd > MAX_USD_CEILING) return { ok: false, error: "maxUsd out of range" };
89
+ return { ok: true, maxTokens: tokens, maxUsd: usd, maxHops: hops };
90
+ }
91
+
92
+ export interface FederationBudgetOpts {
93
+ /** Override the JSON persistence path. */
94
+ readonly path?: string;
95
+ /** Default per-peer limits applied when a peer is first seen. */
96
+ readonly defaultMaxTokens?: number;
97
+ readonly defaultMaxUsd?: number;
98
+ readonly defaultMaxHops?: number;
99
+ }
100
+
101
+ export class FederationBudget {
102
+ private readonly path: string;
103
+ private readonly perPeer = new Map<string, PerPeerBudget>();
104
+ private readonly reservations = new Map<string, Reservation>();
105
+ private readonly defaults: { maxTokens: number; maxUsd: number; maxHops: number };
106
+
107
+ constructor(opts: FederationBudgetOpts = {}) {
108
+ this.path = opts.path ?? DEFAULT_BUDGET_PATH;
109
+ this.defaults = {
110
+ maxTokens: opts.defaultMaxTokens ?? MAX_TOKENS_CEILING,
111
+ maxUsd: opts.defaultMaxUsd ?? MAX_USD_CEILING,
112
+ maxHops: opts.defaultMaxHops ?? 8,
113
+ };
114
+ this.loadFromDisk();
115
+ }
116
+
117
+ /** Get or lazily-create the per-peer budget record. */
118
+ getOrCreate(nodeId: string): PerPeerBudget {
119
+ let existing = this.perPeer.get(nodeId);
120
+ if (!existing) {
121
+ existing = {
122
+ nodeId,
123
+ maxTokens: this.defaults.maxTokens,
124
+ maxUsd: this.defaults.maxUsd,
125
+ maxHops: this.defaults.maxHops,
126
+ spentTokens: 0,
127
+ spentUsd: 0,
128
+ };
129
+ this.perPeer.set(nodeId, existing);
130
+ }
131
+ return existing;
132
+ }
133
+
134
+ /** Override the limits for a specific peer. */
135
+ setLimits(nodeId: string, limits: { maxTokens?: number; maxUsd?: number; maxHops?: number }): void {
136
+ const peer = this.getOrCreate(nodeId);
137
+ this.perPeer.set(nodeId, {
138
+ ...peer,
139
+ maxTokens: limits.maxTokens ?? peer.maxTokens,
140
+ maxUsd: limits.maxUsd ?? peer.maxUsd,
141
+ maxHops: limits.maxHops ?? peer.maxHops,
142
+ });
143
+ this.saveToDisk();
144
+ }
145
+
146
+ /** Reserve `tokens` + `usd` against a peer's budget. The caller
147
+ * MUST eventually `commit()` or `release()` — un-released
148
+ * reservations are surfaced via `outstanding()`. */
149
+ reserve(nodeId: string, tokens: number, usd: number): ReserveResult {
150
+ if (!Number.isFinite(tokens) || tokens < 0 || !Number.isFinite(usd) || usd < 0) {
151
+ return { ok: false, reason: "invalid_amount" };
152
+ }
153
+ const peer = this.getOrCreate(nodeId);
154
+ const remainingTokens = peer.maxTokens - peer.spentTokens;
155
+ const remainingUsd = peer.maxUsd - peer.spentUsd;
156
+ if (tokens > remainingTokens || usd > remainingUsd) {
157
+ return { ok: false, reason: "peer_over_budget" };
158
+ }
159
+ const id = nextReservationId();
160
+ this.reservations.set(id, {
161
+ id,
162
+ nodeId,
163
+ tokens,
164
+ usd,
165
+ atMs: Date.now(),
166
+ });
167
+ // Optimistically charge the spend against the budget so a
168
+ // concurrent reserve from the same peer cannot double-spend.
169
+ // If commit() reports less, the diff is refunded. If the call
170
+ // fails and release() is called, the full reservation is
171
+ // refunded.
172
+ this.perPeer.set(nodeId, {
173
+ ...peer,
174
+ spentTokens: peer.spentTokens + tokens,
175
+ spentUsd: peer.spentUsd + usd,
176
+ });
177
+ return { ok: true, reservationId: id };
178
+ }
179
+
180
+ /** Commit a reservation — caller reports the ACTUAL spend which
181
+ * may be less than the reserved amount. Difference is refunded. */
182
+ commit(reservationId: string, actual: { tokens?: number; usd?: number } = {}): boolean {
183
+ const r = this.reservations.get(reservationId);
184
+ if (!r) return false;
185
+ const actualTokens = Math.max(0, actual.tokens ?? r.tokens);
186
+ const actualUsd = Math.max(0, actual.usd ?? r.usd);
187
+ const peer = this.perPeer.get(r.nodeId);
188
+ if (!peer) {
189
+ this.reservations.delete(reservationId);
190
+ return false;
191
+ }
192
+ // Refund the difference between reserved and actual.
193
+ const tokenRefund = r.tokens - actualTokens;
194
+ const usdRefund = r.usd - actualUsd;
195
+ this.perPeer.set(r.nodeId, {
196
+ ...peer,
197
+ spentTokens: Math.max(0, peer.spentTokens - tokenRefund),
198
+ spentUsd: Math.max(0, peer.spentUsd - usdRefund),
199
+ });
200
+ this.reservations.delete(reservationId);
201
+ this.saveToDisk();
202
+ return true;
203
+ }
204
+
205
+ /** Release a reservation — full refund. */
206
+ release(reservationId: string): boolean {
207
+ const r = this.reservations.get(reservationId);
208
+ if (!r) return false;
209
+ const peer = this.perPeer.get(r.nodeId);
210
+ if (peer) {
211
+ this.perPeer.set(r.nodeId, {
212
+ ...peer,
213
+ spentTokens: Math.max(0, peer.spentTokens - r.tokens),
214
+ spentUsd: Math.max(0, peer.spentUsd - r.usd),
215
+ });
216
+ }
217
+ this.reservations.delete(reservationId);
218
+ this.saveToDisk();
219
+ return true;
220
+ }
221
+
222
+ /** List outstanding reservations. */
223
+ outstanding(): Reservation[] {
224
+ return [...this.reservations.values()];
225
+ }
226
+
227
+ /** Snapshot every per-peer budget — for the status MCP tool. */
228
+ snapshot(): PerPeerBudget[] {
229
+ return [...this.perPeer.values()];
230
+ }
231
+
232
+ /** Serialize to the persisted JSON shape. */
233
+ toJSON(): PersistedBudget {
234
+ return {
235
+ version: 1,
236
+ updatedAt: new Date().toISOString(),
237
+ perPeer: this.snapshot(),
238
+ };
239
+ }
240
+
241
+ /** Write to disk (atomic tmp + rename). */
242
+ saveToDisk(): void {
243
+ const dir = dirname(this.path);
244
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
245
+ const tmp = `${this.path}.tmp`;
246
+ try {
247
+ writeFileSync(tmp, JSON.stringify(this.toJSON(), null, 2), "utf-8");
248
+ renameSync(tmp, this.path);
249
+ } catch {
250
+ /* best-effort */
251
+ }
252
+ }
253
+
254
+ /** Re-read from disk, replacing in-memory state. */
255
+ loadFromDisk(): void {
256
+ if (!existsSync(this.path)) return;
257
+ try {
258
+ const raw = readFileSync(this.path, "utf-8");
259
+ const parsed = JSON.parse(raw) as PersistedBudget;
260
+ if (parsed.version !== 1 || !Array.isArray(parsed.perPeer)) return;
261
+ this.perPeer.clear();
262
+ for (const p of parsed.perPeer) {
263
+ if (typeof p.nodeId === "string") this.perPeer.set(p.nodeId, p);
264
+ }
265
+ } catch {
266
+ /* malformed — keep defaults */
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * federation/envelope.ts — Federation envelope type and message kinds.
3
+ *
4
+ * F-038 — ported from ruflo
5
+ * `v3/@claude-flow/plugin-agent-federation/src/domain/entities/federation-envelope.ts:1-20`
6
+ * (the 16-kind `FederationMessageType` union + `FederationEnvelope` shape).
7
+ *
8
+ * This module is intentionally minimal: it exports ONLY types + a
9
+ * tiny `emptyScanResult()` helper. The runtime signing/verification
10
+ * lives in `./hmac.ts`, PII in `./pii.ts`, audit in `./audit.ts`,
11
+ * budget in `./budget.ts`. Keeping the type isolated here avoids a
12
+ * cycle with the signing/policy modules that import from this file.
13
+ *
14
+ * Backward-compat note: Bizar's v6.4.0 release (current `master`)
15
+ * shipped zero federation code, so anything we add is purely
16
+ * additive — no consumers can break. The 16 message kinds are
17
+ * defined as a `readonly` array AND a union type so the validator
18
+ * surface (e.g. PolicyEngine) can iterate without re-listing them.
19
+ */
20
+
21
+ /**
22
+ * Wire-level message kinds carried inside a federation envelope.
23
+ * 16 total — kept identical to the ruflo source so cross-installation
24
+ * pairs can interoperate if both run a Bizar build ≥ v6.5.0.
25
+ */
26
+ export const FEDERATION_MESSAGE_TYPES = [
27
+ "task-assignment",
28
+ "memory-query",
29
+ "memory-response",
30
+ "context-share",
31
+ "status-broadcast",
32
+ "trust-change",
33
+ "topology-change",
34
+ "agent-spawn",
35
+ "heartbeat",
36
+ "challenge",
37
+ "challenge-response",
38
+ "handshake-init",
39
+ "handshake-accept",
40
+ "handshake-reject",
41
+ "session-terminate",
42
+ "claim-event",
43
+ "agent-handoff",
44
+ ] as const;
45
+
46
+ export type FederationMessageType = (typeof FEDERATION_MESSAGE_TYPES)[number];
47
+
48
+ /** Is `t` a recognised federation message kind? Useful for validation. */
49
+ export function isFederationMessageType(t: unknown): t is FederationMessageType {
50
+ return typeof t === "string" && (FEDERATION_MESSAGE_TYPES as readonly string[]).includes(t);
51
+ }
52
+
53
+ /** Subset of message kinds that mutate trust / topology state — those
54
+ * that should require extra policy scrutiny (mirrors ruflo's
55
+ * CONSENSUS_REQUIRED_TYPES set, minus the consensus-vote-specific
56
+ * types that don't apply to the skeleton). */
57
+ export const TRUST_AFFECTING_MESSAGE_TYPES: ReadonlySet<FederationMessageType> = new Set<FederationMessageType>([
58
+ "trust-change",
59
+ "topology-change",
60
+ "agent-spawn",
61
+ "agent-handoff",
62
+ ]);
63
+
64
+ /** Outcome of a PII scan over the envelope payload. Mirrors ruflo's
65
+ * PIIScanResult so audit consumers can read either implementation
66
+ * interchangeably. */
67
+ export type PiiScanAction = "pass" | "redact" | "hash" | "block";
68
+
69
+ export interface PiiScanDetection {
70
+ readonly type: string;
71
+ readonly action: PiiScanAction;
72
+ readonly confidence: number;
73
+ readonly count: number;
74
+ }
75
+
76
+ export interface PiiScanResult {
77
+ readonly scanned: boolean;
78
+ readonly piiFound: boolean;
79
+ readonly detections: readonly PiiScanDetection[];
80
+ readonly actionsApplied: readonly PiiScanAction[];
81
+ readonly scanDurationMs: number;
82
+ }
83
+
84
+ /** Empty / no-op scan result — used by senders that opt out of PII
85
+ * scanning for the given message kind. */
86
+ export function emptyScanResult(): PiiScanResult {
87
+ return {
88
+ scanned: false,
89
+ piiFound: false,
90
+ detections: [],
91
+ actionsApplied: [],
92
+ scanDurationMs: 0,
93
+ };
94
+ }
95
+
96
+ /** Per-peer budget attached to every envelope. `maxTokens` and
97
+ * `maxUsd` are the sender's original limits for the call; the
98
+ * remote decrements them as the message fans out. `maxHops` is
99
+ * the absolute hop ceiling (sender's value). */
100
+ export interface EnvelopeBudget {
101
+ readonly maxTokens: number;
102
+ readonly maxUsd: number;
103
+ readonly maxHops: number;
104
+ }
105
+
106
+ export interface FederationEnvelope<T = unknown> {
107
+ readonly envelopeId: string;
108
+ readonly sourceNodeId: string;
109
+ readonly targetNodeId: string;
110
+ readonly sessionId: string;
111
+ readonly messageType: FederationMessageType;
112
+ readonly payload: T;
113
+ readonly timestamp: string; // ISO-8601, parsed back via new Date()
114
+ readonly nonce: string; // 128-bit random via crypto.randomUUID()
115
+ readonly hmacSignature: string; // hex SHA-256 HMAC over canonical payload
116
+ readonly piiScanResult: PiiScanResult;
117
+ readonly budget?: EnvelopeBudget;
118
+ readonly hopCount?: number; // present on re-emit, defaults to 0 on origin
119
+ readonly hopsRemaining?: number;
120
+ }
121
+
122
+ /** Canonical JSON form signed by HMAC. Field order MUST stay stable
123
+ * across sender + receiver or signatures will mismatch. */
124
+ export function canonicalSignablePayload<T>(e: FederationEnvelope<T>): string {
125
+ const obj = {
126
+ envelopeId: e.envelopeId,
127
+ sourceNodeId: e.sourceNodeId,
128
+ targetNodeId: e.targetNodeId,
129
+ sessionId: e.sessionId,
130
+ messageType: e.messageType,
131
+ payload: e.payload,
132
+ timestamp: e.timestamp,
133
+ nonce: e.nonce,
134
+ hopCount: e.hopCount ?? 0,
135
+ };
136
+ return JSON.stringify(obj);
137
+ }
138
+
139
+ /** `Date.now()`-based envelope age in milliseconds. */
140
+ export function envelopeAgeMs(e: FederationEnvelope, now: number = Date.now()): number {
141
+ const ts = new Date(e.timestamp).getTime();
142
+ if (!Number.isFinite(ts)) return Number.POSITIVE_INFINITY;
143
+ return Math.max(0, now - ts);
144
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * federation/hmac.ts — HMAC-SHA-256 signing + verification for
3
+ * federation envelopes.
4
+ *
5
+ * F-038 — security-critical. Pinned by AGENTS.md hard constraint:
6
+ * "MUST use crypto.timingSafeEqual for HMAC comparison
7
+ * (timing-attack resistant)."
8
+ * "MUST use crypto.randomUUID() for nonces (128-bit random)."
9
+ *
10
+ * Nonce strategy:
11
+ * - Sender generates a fresh `crypto.randomUUID()` per envelope.
12
+ * - Receiver tracks every nonce it has seen in a TTL-bounded Set.
13
+ * - An envelope whose nonce appears twice is a replay → reject.
14
+ * - An envelope whose timestamp is older than `nonceWindowMs`
15
+ * (default 5 min) is also a replay → reject.
16
+ *
17
+ * No native deps. Pure Node `node:crypto`.
18
+ */
19
+
20
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
21
+ import {
22
+ canonicalSignablePayload,
23
+ type FederationEnvelope,
24
+ } from "./envelope.js";
25
+
26
+ /** Default freshness window for HMAC verification (5 minutes). */
27
+ export const DEFAULT_NONCE_WINDOW_MS = 5 * 60 * 1000;
28
+
29
+ /** Cap on the receiver's seen-nonce set so it can't grow unbounded. */
30
+ const SEEN_NONCE_CAP = 10_000;
31
+
32
+ /** Compute a deterministic 64-char hex HMAC-SHA-256 over the envelope's
33
+ * canonical signable payload. */
34
+ export function signEnvelope<T>(
35
+ envelope: FederationEnvelope<T>,
36
+ secret: string,
37
+ ): string {
38
+ if (typeof secret !== "string" || secret.length === 0) {
39
+ throw new Error("signEnvelope: secret must be a non-empty string");
40
+ }
41
+ const payload = canonicalSignablePayload(envelope);
42
+ return createHmac("sha256", secret).update(payload).digest("hex");
43
+ }
44
+
45
+ export interface VerifyOptions {
46
+ /** Freshness window. Envelopes older than this are rejected.
47
+ * Defaults to 5 minutes. */
48
+ readonly nonceWindowMs?: number;
49
+ /** Optional clock-skew offset (ms) to *add* to the freshness
50
+ * window (e.g. for poorly-synced peers). Defaults to 0. */
51
+ readonly clockSkewMs?: number;
52
+ /** Optional explicit timestamp for deterministic tests. */
53
+ readonly now?: number;
54
+ }
55
+
56
+ export type VerifyOutcome =
57
+ | { ok: true }
58
+ | {
59
+ ok: false;
60
+ reason:
61
+ | "signature_mismatch"
62
+ | "nonce_replay"
63
+ | "envelope_expired"
64
+ | "envelope_from_future"
65
+ | "invalid_signature_format";
66
+ };
67
+
68
+ /** Pure verifier — no shared state. The caller owns the seen-nonce
69
+ * cache (see `NonceCache` below) and passes it in via `seen`. */
70
+ export function verifySignature<T>(
71
+ envelope: FederationEnvelope<T>,
72
+ secret: string,
73
+ seen: ReadonlySet<string>,
74
+ opts: VerifyOptions = {},
75
+ ): VerifyOutcome {
76
+ if (typeof secret !== "string" || secret.length === 0) {
77
+ return { ok: false, reason: "invalid_signature_format" };
78
+ }
79
+ if (typeof envelope.hmacSignature !== "string" || envelope.hmacSignature.length === 0) {
80
+ return { ok: false, reason: "invalid_signature_format" };
81
+ }
82
+
83
+ // Freshness — reject before we touch the HMAC code path so we
84
+ // don't burn cycles on ancient replays.
85
+ const now = opts.now ?? Date.now();
86
+ const ts = new Date(envelope.timestamp).getTime();
87
+ if (!Number.isFinite(ts)) return { ok: false, reason: "envelope_expired" };
88
+ const ageMs = now - ts;
89
+ const window = (opts.nonceWindowMs ?? DEFAULT_NONCE_WINDOW_MS) + (opts.clockSkewMs ?? 0);
90
+ if (ageMs > window) return { ok: false, reason: "envelope_expired" };
91
+ if (ageMs < -window) return { ok: false, reason: "envelope_from_future" };
92
+
93
+ // Nonce replay — checked AFTER freshness so a delayed-but-unique
94
+ // envelope doesn't get falsely fingerprinted as a replay.
95
+ if (seen.has(envelope.nonce)) return { ok: false, reason: "nonce_replay" };
96
+
97
+ // Constant-time signature compare. Both buffers must be the same
98
+ // length for timingSafeEqual; if lengths differ, signature_mismatch.
99
+ const expected = signEnvelope(envelope, secret);
100
+ const a = Buffer.from(expected, "hex");
101
+ let b: Buffer;
102
+ try {
103
+ b = Buffer.from(envelope.hmacSignature, "hex");
104
+ } catch {
105
+ return { ok: false, reason: "invalid_signature_format" };
106
+ }
107
+ if (a.length !== b.length) return { ok: false, reason: "signature_mismatch" };
108
+ if (!timingSafeEqual(a, b)) return { ok: false, reason: "signature_mismatch" };
109
+
110
+ return { ok: true };
111
+ }
112
+
113
+ /** Mutable nonce cache with TTL eviction + hard cap. The receiver
114
+ * keeps one per remote node (or a single global one for the skeleton). */
115
+ export class NonceCache {
116
+ private readonly seen = new Map<string, number>(); // nonce → expiryMs
117
+ private readonly windowMs: number;
118
+
119
+ constructor(windowMs: number = DEFAULT_NONCE_WINDOW_MS) {
120
+ this.windowMs = windowMs;
121
+ }
122
+
123
+ /** True if the nonce is fresh (not seen in windowMs). Adds it
124
+ * to the cache as a side effect. */
125
+ check(nonce: string, now: number = Date.now()): boolean {
126
+ this.evict(now);
127
+ if (this.seen.has(nonce)) return false;
128
+ if (this.seen.size >= SEEN_NONCE_CAP) {
129
+ // Hard cap — evict the oldest entry to make room.
130
+ const oldest = this.seen.keys().next().value;
131
+ if (oldest !== undefined) this.seen.delete(oldest);
132
+ }
133
+ this.seen.set(nonce, now + this.windowMs);
134
+ return true;
135
+ }
136
+
137
+ /** True if the nonce is in the cache (read-only). */
138
+ has(nonce: string): boolean {
139
+ return this.seen.has(nonce);
140
+ }
141
+
142
+ size(): number {
143
+ return this.seen.size;
144
+ }
145
+
146
+ clear(): void {
147
+ this.seen.clear();
148
+ }
149
+
150
+ private evict(now: number): void {
151
+ for (const [nonce, expiry] of this.seen) {
152
+ if (expiry <= now) this.seen.delete(nonce);
153
+ }
154
+ }
155
+ }
156
+
157
+ /** Helper — produce a fresh nonce via `crypto.randomUUID()`. */
158
+ export function freshNonce(): string {
159
+ return randomUUID();
160
+ }