@topy-ai/maggie 0.8.0 → 0.8.2

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.
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """ONEMAN sprite system — the reusable animated status-sprite basic set.
3
+
4
+ Every ONEMAN agent (Core or Service) shares ONE visual language: a 17x17 flat
5
+ pixel motif, a fixed semantic palette tinted by the agent's accent, a catalog of
6
+ 32 common states, and a vocabulary of animation primitives. Give this generator
7
+ a Motif (the agent's mark) + an accent colour and it emits the whole set in a
8
+ consistent style: an animated PNG frame strip per state, a still SVG per state,
9
+ a combined overview sheet, and states.json (frames + fps) to drive CSS/canvas.
10
+
11
+ The ONEMAN mark (a blocky O ring + central 1 bar, accent #ff1212 = topy.ai) is
12
+ the default motif. A new agent reuses everything here and supplies only its own
13
+ motif grid + accent, so the whole roster animates and reads identically.
14
+
15
+ Usage:
16
+ python3 sprite_system.py # regenerate the ONEMAN set
17
+ from sprite_system import Motif, generate # build another agent's set
18
+ generate(Motif.oneman(), accent="#ff1212", out="oneman", title="ONEMAN")
19
+
20
+ PIL only (no cairosvg).
21
+ """
22
+ from __future__ import annotations
23
+ import json, math, os
24
+ from PIL import Image, ImageDraw
25
+
26
+ HERE = os.path.dirname(os.path.abspath(__file__))
27
+ OUT_BASE = HERE
28
+
29
+ # ---------------------------------------------------------------- palette ----
30
+ def _rgb(h): h=h.lstrip("#"); return (int(h[0:2],16),int(h[2:4],16),int(h[4:6],16))
31
+ def shade(c, amt):
32
+ """Lighten (amt>0) or darken (amt<0) an rgb tuple toward white/black, in %."""
33
+ f=lambda v: max(0,min(255,round(v+(amt/100)*((255-v) if amt>0 else v))))
34
+ return (f(c[0]),f(c[1]),f(c[2]))
35
+
36
+ # fixed semantic colours shared by every agent (danger/success/etc are universal)
37
+ SEM = {"red":_rgb("ff1212"), "green":_rgb("36c76a"), "amber":_rgb("f2b01e"),
38
+ "blue":_rgb("3a9bff"), "grey":_rgb("4a4a58"), "ink":_rgb("14141e"),
39
+ "white":_rgb("f5f5fa")}
40
+
41
+ def palette(accent_hex):
42
+ a=_rgb(accent_hex)
43
+ p=dict(SEM)
44
+ p.update(accent=a, acc_dim=shade(a,-52), acc_deep=shade(a,-66),
45
+ acc_hot=shade(a,34), acc_spark=shade(a,72),
46
+ grey_d=shade(SEM["grey"],-42), green_d=shade(SEM["green"],-70),
47
+ amber_d=shade(SEM["amber"],-78), red_deep=shade(SEM["red"],-66))
48
+ return p
49
+
50
+ # ------------------------------------------------------------------ motif ----
51
+ class Motif:
52
+ """A 17x17 mark + the cell roles the animation vocabulary needs."""
53
+ def __init__(self, grid, core=None, dots=None, sparks=None):
54
+ self.grid=grid; self.N=len(grid); self.cx=self.cy=(self.N-1)/2
55
+ self.filled=[(x,y) for y in range(self.N) for x in range(self.N) if grid[y][x]]
56
+ self.core=core if core is not None else []
57
+ coreset=set(self.core)
58
+ ringc=[(x,y) for (x,y) in self.filled if (x,y) not in coreset]
59
+ # order ring cells by angle from top, clockwise — the animatable path
60
+ self.ring=sorted(ringc, key=lambda p:(math.atan2(p[1]-self.cy,p[0]-self.cx)+math.pi/2)%(2*math.pi))
61
+ self.dots=dots if dots is not None else []
62
+ self.sparks=sparks if sparks is not None else []
63
+ def ang(self,x,y): return (math.atan2(y-self.cy,x-self.cx))%(2*math.pi)
64
+
65
+ @staticmethod
66
+ def oneman():
67
+ g=[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0],
68
+ [0,0,0,0,1,1,0,1,1,1,0,1,1,0,0,0,0],[0,0,0,1,1,1,0,1,1,1,0,1,1,1,0,0,0],
69
+ [0,0,1,1,1,1,0,1,1,1,0,1,1,1,1,0,0],[0,1,1,1,1,0,0,1,1,1,0,0,1,1,1,1,0],
70
+ [0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],[0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],
71
+ [0,1,1,1,0,0,0,1,1,1,0,0,0,1,1,1,0],[0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0],
72
+ [0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,1,0],[0,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,0],
73
+ [0,0,1,1,1,1,0,0,0,0,0,1,1,1,1,0,0],[0,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,0],
74
+ [0,0,0,0,1,1,1,1,1,1,1,1,1,0,0,0,0],[0,0,0,0,0,1,1,1,1,1,1,1,0,0,0,0,0],
75
+ [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]
76
+ core=[(x,y) for y in range(17) for x in range(17) if g[y][x] and x in (7,8,9) and y<=9]
77
+ dots=[(6,10),(8,10),(10,10)]
78
+ sparks_lv=[[],[(8,0),(1,5),(15,5),(0,8),(16,8)],
79
+ [(8,0),(7,0),(9,0),(1,4),(15,4),(0,8),(16,8),(1,12),(15,12)]]
80
+ m=Motif(g, core=core, dots=dots, sparks=sparks_lv); return m
81
+
82
+ # --------------------------------------------------------------- badges ------
83
+ GLYPH = {
84
+ "dot":["00000","00000","00100","00000","00000"],"check":["00000","00001","00010","10100","01000"],
85
+ "x":["10001","01010","00100","01010","10001"],"pause":["01010","01010","01010","01010","01010"],
86
+ "slash":["00001","00010","00100","01000","10000"],"clock":["01110","10101","10111","10001","01110"],
87
+ "lock":["01110","01010","11111","11011","11111"],"shield":["11111","11111","01110","01110","00100"],
88
+ "up":["00100","01110","11111","00100","00100"],"gear":["01010","11111","01110","11111","01010"],
89
+ }
90
+ def put_badge(px, name, bg, fg, N):
91
+ for yy in range(0,6):
92
+ for xx in range(11, min(17,N)): px[(xx,yy)]=None
93
+ g=GLYPH[name]
94
+ for r in range(5):
95
+ for c in range(5): px[(12+c,r)] = fg if g[r][c]=="1" else bg
96
+
97
+ # ------------------------------------------------- animation primitives ------
98
+ # Each returns {(x,y): rgb|None} for frame t of nf. `col` is the state's base.
99
+ def _fillall(m,col): return {p:col for p in m.filled}
100
+ def tri(t,nf): x=(2*t/nf); return x if x<=1 else 2-x # 0..1..0 triangle
101
+
102
+ def mo_breathe(m,col,t,nf,P,amt=16):
103
+ return _fillall(m, shade(col, amt*tri(t,nf)))
104
+ def mo_breathe_slow(m,col,t,nf,P): return mo_breathe(m,col,t,nf,P,amt=10)
105
+ def mo_pulse(m,col,t,nf,P):
106
+ k=tri(t,nf); return _fillall(m, tuple(round(col[i]+(P["acc_hot"][i] if col==P["accent"] else shade(col,55)[i]-col[i])*0 + (shade(col,42)[i]-col[i])*k) for i in range(3)))
107
+ def mo_blink(m,col,t,nf,P): return _fillall(m, col if t%2==0 else shade(col,-60))
108
+ def mo_arc(m,col,t,nf,P,win=80,hot=None):
109
+ px=_fillall(m,P["acc_dim"]); hot=hot or P["acc_hot"]; ang0=(t/nf)*2*math.pi; w=math.radians(win)
110
+ for (x,y) in m.ring:
111
+ a=m.ang(x,y); da=min((a-ang0)%(2*math.pi),(ang0-a)%(2*math.pi))
112
+ if da<=w/2: px[(x,y)]=hot
113
+ for p in m.core: px[p]=col
114
+ return px
115
+ def mo_scan(m,col,t,nf,P): return mo_arc(m,col,t,nf,P,win=34,hot=P["acc_spark"])
116
+ def mo_spin(m,col,t,nf,P): return mo_arc(m,col,t,nf,P,win=54)
117
+ def mo_dualarc(m,col,t,nf,P):
118
+ px=_fillall(m,P["acc_dim"]); ang0=(t/nf)*2*math.pi; w=math.radians(70)
119
+ for (x,y) in m.ring:
120
+ a=m.ang(x,y)
121
+ for b in (ang0,ang0+math.pi):
122
+ if min((a-b)%(2*math.pi),(b-a)%(2*math.pi))<=w/2: px[(x,y)]=P["acc_hot"]
123
+ for p in m.core: px[p]=col
124
+ return px
125
+ def mo_fill(m,col,t,nf,P,core_from=0.0):
126
+ px=_fillall(m,P["acc_dim"]); prog=(t+1)/nf
127
+ for (x,y) in m.ring:
128
+ a=(m.ang(x,y)+math.pi/2)%(2*math.pi)
129
+ if a/(2*math.pi)<=prog: px[(x,y)]=col
130
+ for p in m.core:
131
+ if prog>=core_from: px[p]=col
132
+ return px
133
+ def mo_boot(m,col,t,nf,P): return mo_fill(m,col,t,nf,P,core_from=0.7)
134
+ def mo_dots(m,col,t,nf,P):
135
+ px=_fillall(m,col); lit=t%max(1,len(m.dots))
136
+ for i,p in enumerate(m.dots): px[p]=P["acc_spark"] if i==lit else P["acc_deep"]
137
+ return px
138
+ def mo_sparkout(m,col,t,nf,P):
139
+ px=_fillall(m,col); step=[0,1,2,1][t%4]
140
+ for p in m.sparks[step]: px[p]=P["acc_spark"] if step==2 else P["acc_hot"]
141
+ return px
142
+ def mo_sparkin(m,col,t,nf,P):
143
+ px=_fillall(m,col); step=[2,1,0,1][t%4]
144
+ for p in m.sparks[step]: px[p]=P["acc_spark"]
145
+ return px
146
+ def mo_celebrate(m,col,t,nf,P):
147
+ cyc=[P["green"],P["amber"],P["accent"],P["acc_spark"]]
148
+ px=_fillall(m,cyc[t%len(cyc)]); step=[0,1,2,2][t%4]
149
+ for p in m.sparks[step]: px[p]=P["acc_spark"] if t%2 else P["white"]
150
+ return px
151
+
152
+ MOTION = {"breathe":mo_breathe,"breathe_slow":mo_breathe_slow,"pulse":mo_pulse,"blink":mo_blink,
153
+ "arc":mo_arc,"scan":mo_scan,"spin":mo_spin,"dualarc":mo_dualarc,"fill":mo_fill,"boot":mo_boot,
154
+ "dots":mo_dots,"sparkout":mo_sparkout,"sparkin":mo_sparkin,"celebrate":mo_celebrate}
155
+
156
+ # ------------------------------------------------- state catalog (32) --------
157
+ # state: (group, colour-role, motion, badge|None, fps)
158
+ A="accent"
159
+ CATALOG = {
160
+ # power / lifecycle
161
+ "off":("power","grey","breathe_slow",None,6), "booting":("power",A,"boot",None,8),
162
+ "idle":("power","acc_dim","breathe",None,6), "active":("power",A,"breathe",None,7),
163
+ "paused":("power","amber","breathe",("pause","amber_d","white"),6),
164
+ "offline":("power","grey","breathe_slow",("slash","grey_d","white"),4),
165
+ # work / process
166
+ "thinking":("work",A,"arc",None,11), "loading":("work",A,"fill",None,9),
167
+ "syncing":("work",A,"dualarc",None,10), "writing":("work",A,"dots",None,5),
168
+ "streaming":("work",A,"pulse",None,6), "queued":("work","amber","breathe",("clock","amber_d","white"),6),
169
+ "scanning":("work",A,"scan",None,16), "deploying":("work",A,"fill",("up","ink","white"),9),
170
+ # communication
171
+ "speaking":("comms",A,"sparkout",None,5), "listening":("comms","blue","sparkin",None,5),
172
+ "notification":("comms",A,"pulse",("dot","white","accent"),6),
173
+ "mention":("comms","blue","pulse",("dot","white","blue"),6),
174
+ "recording":("comms","red","pulse",("dot","white","red"),7),
175
+ # outcomes
176
+ "success":("outcome","green","breathe",None,7), "warning":("outcome","amber","pulse",None,6),
177
+ "critical":("outcome","red","blink",None,2), "blocked":("outcome","red","blink",("lock","ink","white"),2),
178
+ "error":("outcome","red","blink",("x","ink","white"),2),
179
+ "retrying":("outcome",A,"spin",None,12),
180
+ # governance / domain
181
+ "awaiting":("gov","amber","breathe",("clock","amber_d","white"),5),
182
+ "approved":("gov","green","breathe",("check","white","green"),7),
183
+ "rejected":("gov","red","breathe",("x","ink","white"),6),
184
+ "secure":("gov","green","breathe",("shield","green_d","white"),7),
185
+ "vulnerable":("gov","red","pulse",("shield","ink","white"),6),
186
+ "maintenance":("gov","grey","breathe",("gear","grey_d","white"),5),
187
+ "celebrating":("gov","green","celebrate",None,7),
188
+ }
189
+ GROUPS=["power","work","comms","outcome","gov"]
190
+
191
+ def _color(role,P): return P[role] if role in P else P.get(role, P["accent"])
192
+
193
+ def frames_for(m,state,P):
194
+ grp,role,motion,badge,fps=CATALOG[state]
195
+ col=_color(role,P); fn=MOTION[motion]
196
+ nf={"breathe":10,"breathe_slow":10,"pulse":8,"blink":2,"arc":12,"scan":12,"spin":10,
197
+ "dualarc":12,"fill":12,"boot":10,"dots":max(2,len(m.dots)*2),"sparkout":4,"sparkin":4,"celebrate":8}[motion]
198
+ out=[]
199
+ for t in range(nf):
200
+ px=fn(m,col,t,nf,P)
201
+ if badge:
202
+ nm,bg,fg=badge
203
+ put_badge(px, nm, P[bg], P[fg], m.N)
204
+ out.append(px)
205
+ return out, fps
206
+
207
+ # ------------------------------------------------------------- renderers -----
208
+ def render(px,N,scale):
209
+ img=Image.new("RGBA",(N*scale,N*scale),(0,0,0,0)); d=ImageDraw.Draw(img)
210
+ for (x,y),c in px.items():
211
+ if c is not None: d.rectangle([x*scale,y*scale,(x+1)*scale-1,(y+1)*scale-1],fill=c+(255,))
212
+ return img
213
+ def svg_of(px,N):
214
+ from collections import defaultdict
215
+ by=defaultdict(list)
216
+ for (x,y),c in px.items():
217
+ if c is not None: by[c].append((x,y))
218
+ parts=[]
219
+ for c,cells in by.items():
220
+ rs="".join(f'<rect x="{x}" y="{y}" width="1" height="1"/>' for x,y in sorted(cells))
221
+ parts.append(f'<g fill="#%02x%02x%02x">{rs}</g>'%c)
222
+ return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {N} {N}" '
223
+ f'shape-rendering="crispEdges" role="img">{"".join(parts)}</svg>\n')
224
+
225
+ def generate(m:Motif, accent="#ff1212", out="oneman", title="ONEMAN", scale=9):
226
+ P=palette(accent); base=os.path.join(OUT_BASE, out); os.makedirs(base, exist_ok=True)
227
+ meta={"title":title,"accent":accent,"N":m.N,"scale":scale,"groups":GROUPS,"states":{}}
228
+ for s in CATALOG:
229
+ frames,fps=frames_for(m,s,P)
230
+ imgs=[render(px,m.N,scale) for px in frames]
231
+ w=sum(i.width for i in imgs); strip=Image.new("RGBA",(w,imgs[0].height),(0,0,0,0)); x=0
232
+ for i in imgs: strip.paste(i,(x,0)); x+=i.width
233
+ strip.save(os.path.join(base,f"anim-{s}.png"))
234
+ open(os.path.join(base,f"mark-{s}.svg"),"w").write(svg_of(frames[0],m.N)) # still fallback
235
+ grp,role,motion,badge,_=CATALOG[s]
236
+ meta["states"][s]={"group":grp,"frames":len(frames),"fps":fps,"motion":motion,
237
+ "badge":badge[0] if badge else None}
238
+ # overview sheet (representative frame 2 of each)
239
+ cols,rows,cell=8,4,7; tile=m.N*cell; pad=cell
240
+ sheet=Image.new("RGBA",(cols*tile+(cols+1)*pad,rows*tile+(rows+1)*pad),(16,16,24,255))
241
+ order=[s for g in GROUPS for s in CATALOG if CATALOG[s][0]==g]
242
+ def rep_idx(motion,n):
243
+ return {"blink":0,"fill":int(n*0.62),"boot":int(n*0.62),"arc":n//4,"scan":n//4,
244
+ "spin":n//4,"dualarc":n//4,"dots":1}.get(motion, n//2)
245
+ for i,s in enumerate(order):
246
+ fr,_=frames_for(m,s,P); im=render(fr[rep_idx(CATALOG[s][2],len(fr))],m.N,cell)
247
+ cx=pad+(i%cols)*(tile+pad); cy=pad+(i//cols)*(tile+pad); sheet.paste(im,(cx,cy),im)
248
+ sheet.save(os.path.join(base,"sprite-sheet.png"))
249
+ json.dump(meta, open(os.path.join(base,"states.json"),"w"), indent=1)
250
+ return meta
251
+
252
+ if __name__=="__main__":
253
+ meta=generate(Motif.oneman(), accent="#ff1212", out="oneman", title="ONEMAN")
254
+ print(f"{title if (title:=meta['title']) else ''}: {len(meta['states'])} states, all animated")
255
+ print("groups:", ", ".join(GROUPS))
256
+ print("states:", ", ".join(meta["states"]))
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ """ONEMAN Core-Agent faces — the O mark IS the face.
3
+ Thin accent rim (bigger face) -> larger, varied eyes. Mixed gender; women wear
4
+ long hair. Distinct hair silhouette + one personality prop per agent."""
5
+ import math
6
+ from PIL import Image, ImageDraw
7
+ W=H=28
8
+ def _rgb(h):h=h.lstrip('#');return(int(h[0:2],16),int(h[2:4],16),int(h[4:6],16))
9
+ def shade(c,a):
10
+ f=lambda v:max(0,min(255,round(v+(a/100)*((255-v)if a>0 else v))));return(f(c[0]),f(c[1]),f(c[2]))
11
+ OUT=_rgb('14141e');WHT=_rgb('f7f7fb');EYE=_rgb('1b1b26')
12
+ cx,cy=14,13;R_OUT=11.0;R_IN=8.6 # thin rim -> big face
13
+ SKINS={"light":_rgb('f0c49a'),"mid":_rgb('d89c70'),"tan":_rgb('c6895a'),"deep":_rgb('9c6a44')}
14
+ def rect(px,x0,y0,x1,y1,c):
15
+ for y in range(max(0,y0),min(H,y1+1)):
16
+ for x in range(max(0,x0),min(W,x1+1)):px[(x,y)]=c
17
+ def ytop(x):
18
+ dx=x-cx;return (cy-math.sqrt(R_OUT**2-dx*dx)) if abs(dx)<=R_OUT else None
19
+
20
+ def draw_eyes(px,style,SK):
21
+ """Bigger eyes, varied. Left cols cx-5..-3, right cx+3..+5."""
22
+ def one(side,lash=False,shut=False,small=False,wide=False):
23
+ s=side
24
+ L=[cx+ s*3,cx+s*4,cx+s*5]
25
+ if shut:
26
+ for x in L:px[(x,cy-1)]=OUT
27
+ if lash:px[(cx+s*6,cy-2)]=OUT
28
+ return
29
+ for x in L: # white block 3x2
30
+ px[(x,cy-2)]=WHT;px[(x,cy-1)]=WHT
31
+ pupx=cx+s*4
32
+ if wide:
33
+ px[(pupx,cy-1)]=EYE # small pupil, lots of white
34
+ elif small:
35
+ px[(pupx,cy-1)]=EYE
36
+ else:
37
+ px[(pupx,cy-1)]=EYE;px[(pupx,cy-2)]=EYE # big pupil
38
+ px[(cx+s*3 if s<0 else cx+s*5,cy-2)]=WHT # keep a white glint corner
39
+ if lash:
40
+ px[(cx+s*6,cy-2)]=OUT;px[(cx+s*5,cy-3)]=OUT;px[(cx+s*3,cy-2)]=OUT
41
+ if style=="wink":
42
+ one(-1);one(1,shut=True)
43
+ elif style=="lash":
44
+ one(-1,lash=True);one(1,lash=True)
45
+ elif style=="wide":
46
+ one(-1,wide=True);one(1,wide=True)
47
+ elif style=="small":
48
+ one(-1,small=True);one(1,small=True)
49
+ elif style=="happy":
50
+ for s in(-1,1): px[(cx+s*5,cy-1)]=OUT;px[(cx+s*4,cy-2)]=OUT;px[(cx+s*3,cy-1)]=OUT
51
+ else: # round
52
+ one(-1);one(1)
53
+
54
+ def hair_crown(px,HA,HAs,HAh,style):
55
+ def up(dx):
56
+ a=abs(dx)
57
+ return {"buzz":0,"short":1,"slick":1,"spiky":(3 if dx%2==0 else 1),
58
+ "longstraight":1,"longwavy":1,"ponytail":1,"longbob":1}.get(style,1)
59
+ def down(dx):
60
+ return {"buzz":1,"short":2,"slick":(3 if dx>0 else 1)}.get(style,2)
61
+ for x in range(cx-11,cx+12):
62
+ yt=ytop(x)
63
+ if yt is None:continue
64
+ base=round(yt)
65
+ if base>cy-2:continue
66
+ u=up(x-cx)
67
+ topy=base-u;boty=min(base+down(x-cx),cy-3)
68
+ for y in range(topy,boty+1):
69
+ if 0<=y<H:px[(x,y)]=HA
70
+ if 0<=topy<H:px[(x,topy)]=HAh
71
+ if 0<=boty<H and px.get((x,boty))==HA:px[(x,boty)]=HAs
72
+
73
+ def hair_sides(px,HA,HAs,style):
74
+ # women's long hair: side panels of different length/shape
75
+ for x in range(cx-11,cx+12):
76
+ dx=x-cx;a=abs(dx)
77
+ if a<6 or a>10:continue
78
+ yt=ytop(x)
79
+ if yt is None:continue
80
+ top=round(yt)
81
+ if style=="longstraight":L=cy+7
82
+ elif style=="longwavy":L=cy+6+(1 if (x%2==0) else -1)
83
+ elif style=="longbob":L=cy+2
84
+ elif style=="ponytail":L=cy
85
+ else:L=cy-1
86
+ for y in range(top,min(H,L+1)):px[(x,y)]=HA
87
+ if L<H:px[(x,min(H-1,L))]=HAs
88
+ if style=="ponytail":
89
+ for yy in range(cy-4,cy+5): # tail on the right
90
+ for xx in range(cx+9,cx+12):
91
+ if abs((yy-cy))<=4-(xx-(cx+9)):px[(xx,yy)]=HA
92
+ px[(cx+9,cy+4)]=HAs
93
+ px[(cx+8,cy-5)]=HA # tie base
94
+
95
+ def face(accent,hair,skin="mid",*,gender="m",hairstyle="short",eyes="round",
96
+ mouth="smile",prop=None,propcol="#caa472",hoodie=False,band=None,mask=None,specs=False):
97
+ AC=_rgb(accent);ACd=shade(AC,-30);SK=SKINS[skin];SKs=shade(SK,-20)
98
+ HA=_rgb(hair);HAs=shade(HA,-28);HAh=shade(HA,22)
99
+ px={}
100
+ for y in range(H):
101
+ for x in range(W):
102
+ d=math.hypot(x-cx,y-cy)
103
+ if d<=R_OUT:px[(x,y)]=(AC if d<R_OUT-0.9 else ACd) if d>=R_IN else SK
104
+ for y in range(0,cy-3):
105
+ for x in range(cx-2,cx+3):
106
+ if px.get((x,y)) in(AC,ACd):del px[(x,y)]
107
+ for x in range(cx-5,cx+6):
108
+ if px.get((x,cy+5))==SK:px[(x,cy+5)]=SKs
109
+ if gender=="f":hair_sides(px,HA,HAs,hairstyle) # draw long hair behind/around first
110
+ if hairstyle!="bald":hair_crown(px,HA,HAs,HAh,hairstyle)
111
+ draw_eyes(px,eyes,SK)
112
+ px[(cx,cy+2)]=SKs # nose
113
+ if mouth=="smile":
114
+ px[(cx-2,cy+4)]=OUT;px[(cx-1,cy+5)]=OUT;px[(cx,cy+5)]=OUT;px[(cx+1,cy+5)]=OUT;px[(cx+2,cy+4)]=OUT
115
+ elif mouth=="flat":rect(px,cx-2,cy+4,cx+1,cy+4,OUT)
116
+ elif mouth=="smirk":
117
+ px[(cx-1,cy+5)]=OUT;px[(cx,cy+5)]=OUT;px[(cx+1,cy+4)]=OUT;px[(cx+2,cy+3)]=OUT
118
+ elif mouth=="open":rect(px,cx-2,cy+4,cx+1,cy+5,OUT);px[(cx-1,cy+5)]=_rgb('7a1a12')
119
+ elif mouth=="lips":rect(px,cx-2,cy+4,cx+1,cy+4,_rgb('c0392b')) # lipstick
120
+ # props
121
+ if prop=="cigar":
122
+ PC=_rgb(propcol);rect(px,cx+2,cy+4,cx+8,cy+4,PC);px[(cx+2,cy+4)]=shade(PC,-25)
123
+ px[(cx+8,cy+4)]=_rgb('ff4422');px[(cx+9,cy+4)]=_rgb('ffb08a')
124
+ px[(cx+9,cy+2)]=_rgb('8a8a9a');px[(cx+10,cy+1)]=_rgb('6a6a7a')
125
+ elif prop=="shades":
126
+ rect(px,cx-6,cy-2,cx-2,cy,OUT);rect(px,cx+2,cy-2,cx+6,cy,OUT)
127
+ rect(px,cx-1,cy-2,cx+1,cy-2,OUT);px[(cx-7,cy-2)]=OUT;px[(cx+7,cy-2)]=OUT
128
+ px[(cx-5,cy-1)]=_rgb('3a3a4a');px[(cx+3,cy-1)]=_rgb('3a3a4a')
129
+ elif prop=="glasses":
130
+ for x in range(cx-6,cx-1):px[(x,cy-2)]=OUT;px[(x,cy)]=OUT
131
+ px[(cx-6,cy-1)]=OUT;px[(cx-2,cy-1)]=OUT
132
+ for x in range(cx+2,cx+7):px[(x,cy-2)]=OUT;px[(x,cy)]=OUT
133
+ px[(cx+1,cy-1)]=OUT;px[(cx+6,cy-1)]=OUT;px[(cx,cy-1)]=OUT
134
+ elif prop=="monocle":
135
+ for x in range(cx+2,cx+7):px[(x,cy-2)]=OUT;px[(x,cy)]=OUT
136
+ px[(cx+1,cy-1)]=OUT;px[(cx+6,cy-1)]=OUT
137
+ px[(cx+4,cy+1)]=_rgb('d4af37');px[(cx+4,cy+2)]=_rgb('d4af37')
138
+ elif prop=="pen":
139
+ PC=_rgb(propcol)
140
+ for i in range(5):
141
+ x=cx+8+i;y=cy-2-i
142
+ if 0<=x<W and 0<=y<H:px[(x,y)]=PC
143
+ px[(cx+8,cy-2)]=_rgb('2a2a34');px[(cx+12,cy-6)]=shade(PC,30)
144
+ elif prop=="headset":
145
+ rect(px,cx-12,cy-6,cx-12,cy+1,OUT);px[(cx-12,cy+2)]=OUT
146
+ rect(px,cx-13,cy+1,cx-12,cy+3,EYE);rect(px,cx-12,cy+3,cx-6,cy+4,OUT);px[(cx-6,cy+4)]=_rgb('ff4422')
147
+ elif prop=="earbuds":
148
+ px[(cx-9,cy)]=WHT;px[(cx-9,cy+1)]=WHT;px[(cx+9,cy)]=WHT;px[(cx+9,cy+1)]=WHT
149
+ elif prop=="bandana":
150
+ BC=_rgb(propcol)
151
+ for x in range(cx-11,cx+12):
152
+ yt=ytop(x)
153
+ if yt is None:continue
154
+ base=round(yt)
155
+ for y in range(base-1,base+3):
156
+ if 0<=y<H and y<=cy-3 and px.get((x,y)) not in(None,SK,SKs):px[(x,y)]=BC
157
+ px[(cx-10,cy-2)]=BC;px[(cx-11,cy-1)]=BC;px[(cx-11,cy)]=shade(BC,-22)
158
+ for x in range(cx-6,cx+7,3):px[(x,cy-5)]=shade(BC,-22)
159
+ # ---- combinable wearables (Founder) ----
160
+ if hoodie:
161
+ HC=_rgb('161b22')
162
+ for y in range(cy-2,H):
163
+ for x in range(W):
164
+ d=math.hypot(x-cx,y-cy)
165
+ if R_OUT<d<=R_OUT+2.4 and px.get((x,y)) is None: px[(x,y)]=HC
166
+ if band:
167
+ BC=_rgb(band); BCd=shade(BC,-24)
168
+ for y in range(0,cy-6): # hair crown above the (raised) band
169
+ for x in range(cx-9,cx+10):
170
+ if px.get((x,y))==SK: px[(x,y)]=HA
171
+ for y in (cy-6,cy-5,cy-4): # headband, raised a touch
172
+ for x in range(cx-10,cx+11):
173
+ yt=ytop(x)
174
+ if yt is None: continue
175
+ if y>=round(yt)-1 and px.get((x,y)) is not None: px[(x,y)]=BC
176
+ for strand in (0,1): # two tails, both streaming up-right (wind-blown)
177
+ for k in range(6):
178
+ xx=cx+8+k; yy=(cy-2-strand*2)-(k*2)//3
179
+ if 0<=xx<W and 0<=yy<H: px[(xx,yy)]=BC if k<3 else BCd
180
+ if mask:
181
+ MC=_rgb(mask);MCs=shade(MC,34);GL=_rgb('39d353')
182
+ for y in range(cy+1,cy+10): # dark mask over nose+mouth+chin (follows the face)
183
+ for x in range(cx-8,cx+9):
184
+ if px.get((x,y)) in(SK,SKs): px[(x,y)]=MC
185
+ for x in range(cx-5,cx+6):
186
+ if px.get((x,cy+1))==MC: px[(x,cy+1)]=MCs # ridge over the nose
187
+ for y in range(cy+2,cy+8):
188
+ if px.get((cx,y))==MC: px[(cx,y)]=MCs # centre seam
189
+ for k in range(3): px[(cx+3,cy+4+k)]=GL;px[(cx+4,cy+4+k)]=GL # green vent stripes
190
+ if specs:
191
+ LENS=_rgb('1f8a3a')
192
+ rect(px,cx-6,cy-2,cx-2,cy,OUT); rect(px,cx-5,cy-2,cx-3,cy-1,LENS); px[(cx-4,cy-1)]=_rgb('0c1a10')
193
+ rect(px,cx+2,cy-2,cx+6,cy,OUT); rect(px,cx+3,cy-2,cx+5,cy-1,LENS); px[(cx+4,cy-1)]=_rgb('0c1a10')
194
+ px[(cx-1,cy-1)]=OUT;px[(cx,cy-1)]=OUT;px[(cx+1,cy-1)]=OUT # bridge
195
+ return px
196
+
197
+ FOUNDER=("FOUNDER","#2fbf52","m",dict(hair="#141419",skin="light",hairstyle="short",eyes="happy",mouth="flat",band="#e23b3b",mask="#12201a",hoodie=True,specs=True))
198
+
199
+ def render(px,scale,bg=None):
200
+ img=Image.new("RGBA",(W*scale,H*scale),(bg or(0,0,0,0)));d=ImageDraw.Draw(img)
201
+ for (x,y),c in px.items():
202
+ if c is not None:d.rectangle([x*scale,y*scale,(x+1)*scale-1,(y+1)*scale-1],fill=c+(255,))
203
+ return img
204
+
205
+ CAST=[
206
+ ("CEO","#f59e0b","m",dict(hair="#2a2018",skin="mid", hairstyle="slick",eyes="round",mouth="smirk",prop="cigar")),
207
+ ("CPO","#8b5cf6","f",dict(hair="#3a2418",skin="tan", hairstyle="longwavy",eyes="lash",mouth="smile",prop="pen",propcol="#f2c75a")),
208
+ ("CMO","#ec4899","f",dict(hair="#4a2a18",skin="light",hairstyle="longstraight",eyes="lash",mouth="lips",prop="shades")),
209
+ ("CRO","#84cc16","m",dict(hair="#1c1c22",skin="mid", hairstyle="spiky",eyes="wide",mouth="open",prop="headset")),
210
+ ("CTO","#3b82f6","m",dict(hair="#1c1c22",skin="tan", hairstyle="buzz",eyes="round",mouth="flat",prop="glasses")),
211
+ ("COO","#06b6d4","f",dict(hair="#15151c",skin="deep", hairstyle="ponytail",eyes="lash",mouth="smile",prop="earbuds")),
212
+ ("CFO","#2dd4bf","f",dict(hair="#6a4a2a",skin="light",hairstyle="longbob",eyes="lash",mouth="flat",prop="monocle")),
213
+ ("CSO","#6366f1","m",dict(hair="#1c1c22",skin="mid", hairstyle="short",eyes="wide",mouth="flat",prop="bandana",propcol="#ef4444")),
214
+ ]
215
+ if __name__=="__main__":
216
+ scale=10;gap=14;lblh=22;cols=4;rows=2;cw=W*scale;ch=H*scale
217
+ sheet=Image.new("RGBA",(cols*cw+(cols+1)*gap, rows*(ch+lblh)+gap),(13,13,20,255));d=ImageDraw.Draw(sheet)
218
+ for i,(nm,acc,g,p) in enumerate(CAST):
219
+ im=render(face(acc,gender=g,**p),scale)
220
+ x=gap+(i%cols)*(cw+gap);y=lblh+(i//cols)*(ch+lblh)
221
+ sheet.paste(im,(x,y),im);d.text((x+4,y-16),f"{nm} {'F' if g=='f' else 'M'} {p['hairstyle']}/{p['prop']}",fill=(220,220,235))
222
+ sheet.save("/tmp/face_preview.png");print("wrote",sheet.size)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,92 @@
1
+ # Goal-led AI agent design
2
+
3
+ A reusable method for defining the agents in a multi-agent product so a single
4
+ human can run a company by stating intent, not by operating tools. Distilled
5
+ from the ONEMAN.APP cofounder build; pairs with the `maggie-spirit` skill (give
6
+ each designed agent a spirit/avatar) and `maggie-assistant-tools` (expose an
7
+ agent's capabilities safely).
8
+
9
+ ## Principle
10
+
11
+ The human sets a **goal**; the agents turn it into work and keep the human in
12
+ the loop only for judgement and approval. Each agent owns one lane, declares the
13
+ **capability classes** it may use, and runs under a **risk ceiling** with
14
+ explicit approval gates. Nothing that is risky, public, or spends money happens
15
+ without a human yes.
16
+
17
+ ## Define each agent with a role contract
18
+
19
+ One `role.yaml` per agent. Recommended fields:
20
+
21
+ ```yaml
22
+ role: CTO # the lane
23
+ name: AI CTO # display name
24
+ persona: Turing # inspiration, not impersonation
25
+ spirit: Engineering # one-word identity (drives the spirit accent/motif)
26
+ summary: > # what it owns, in one or two sentences
27
+ Technical design, build, integrate, test and deploy. Production deployment is
28
+ gated behind human approval.
29
+ responsibilities: [design, build, integrate, test, deploy, observe]
30
+ constraints: [no prod deploy without approval, no secrets in source]
31
+ allowed_capability_classes: [app.scaffold, app.build, app.test, code.review,
32
+ deploy.release, integration.connect, ops.observe, job.execute]
33
+ default_risk_ceiling: L1 # L0 read -> L3 irreversible/public/paid
34
+ business_system: Execution # which paired system (e.g. with COO)
35
+ raci: { new_service_app: "A", security_verdict: I }
36
+ interfaces: { mcp: planned, cli: planned, api: planned }
37
+ ```
38
+
39
+ - **Capability classes**, not freeform skills, are the unit of authority. They
40
+ are stable `area.verb` identifiers (`content.publish`, `revenue.close`,
41
+ `security.scan`) that a UI can list and a gate can check. These are the
42
+ "skills" a product surfaces per agent.
43
+ - **One CEO orchestrates.** The CEO owns `goal.decompose`, `mission.manage`,
44
+ `plan.compose`, `priority.rank`, `outcome.accept`: it turns intent into
45
+ missions with acceptance criteria, delegates each to the right agent, and
46
+ signs off - it never bypasses security or budget approval.
47
+ - **Security holds a non-overridable veto.** A high-risk security verdict cannot
48
+ be overruled by another agent.
49
+ - **Finance is explain-only.** Financial facts come from deterministic data
50
+ (billing/usage ledger, Stripe); the CFO reconciles and advises, it is not the
51
+ source of truth.
52
+
53
+ ## Risk ceilings and approval gates
54
+
55
+ | Level | Meaning | Gate |
56
+ |---|---|---|
57
+ | L0 | read-only | none |
58
+ | L1 | internal writes (drafts, plans, workflows) | logged |
59
+ | L2 | customer-facing or public (publish, close a deal) | human approval |
60
+ | L3 | irreversible / paid / production (deploy, transfer) | human approval + audit |
61
+
62
+ Every agent declares a `default_risk_ceiling`; an action above it stops for an
63
+ explicit human yes. Log every action; make L2/L3 produce an approval card.
64
+
65
+ ## The goal loop
66
+
67
+ 1. The human states what the business should do.
68
+ 2. The CEO writes missions with acceptance criteria.
69
+ 3. Each mission is delegated to the agent whose lane and capability classes fit.
70
+ 4. Agents work their lane; risky/public/paid steps wait for approval.
71
+ 5. The CEO reviews outcomes against the acceptance criteria and signs off, or
72
+ sends the mission back.
73
+ 6. The human stays the only human in the loop - approve, steer, ship.
74
+
75
+ ## Surfacing agents to users
76
+
77
+ Each agent reads cleanly as a profile: persona name, spirit, its capability
78
+ classes (its "skills"), and the service apps it manages. Give it a spirit/avatar
79
+ with `maggie-spirit` (shared 32-state motion vocabulary, per-agent accent) so
80
+ status (idle, working, deploying, success, blocked, secure) reads the same
81
+ across the whole team. Mark an agent "shipped" only when its product is actually
82
+ live.
83
+
84
+ ## Checklist
85
+
86
+ - [ ] One lane per agent; no two agents own the same decision.
87
+ - [ ] Capability classes are `area.verb`, stable, and enumerable.
88
+ - [ ] A single orchestrator (CEO) owns decomposition and sign-off.
89
+ - [ ] Security veto and finance-is-explain-only are encoded, not assumed.
90
+ - [ ] Every agent has a risk ceiling; L2/L3 actions gate on human approval.
91
+ - [ ] Persona is inspiration, not impersonation of a real person.
92
+ - [ ] Each agent has a spirit/avatar from the shared 32-state base set.