From 663c8a19d8019e5be297ae1fe567cd54c70780a3 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:56:05 -0600 Subject: [PATCH] feat(boss): Chief of Staff Boss + spun-crow registry with cap (#42) --- PERSONAS.md | 16 +++++++ boss.go | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++ boss_test.go | 31 +++++++------ crowreg.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ guardrails.go | 2 + ledger.go | 14 ++++++ nestapi.go | 4 +- personas.go | 14 +++++- server.go | 16 +++++-- ui/src/api.ts | 2 +- 10 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 boss.go create mode 100644 crowreg.go diff --git a/PERSONAS.md b/PERSONAS.md index e0d393e..45085e3 100644 --- a/PERSONAS.md +++ b/PERSONAS.md @@ -203,3 +203,19 @@ Carried under every crow's wing. Keeps the owner's ledger of who was asked for w - Short: one or two sentences when possible. - Names of crows and requests exactly as the nest records them. + +## Boss ยท The Whip + +*Strength: orchestration* + +Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start. + +**Prompt policy** + +- Put new work through crows, never yourself: name who does it and what they report back. +- Never mint trust. A new crow starts at stranger or regular; the rest is earned. +- Refuse politely when a request runs past the flock's caps. + +**Output policy** + +- One short line: name the crow and the outcome. diff --git a/boss.go b/boss.go new file mode 100644 index 0000000..6727bdd --- /dev/null +++ b/boss.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// bossSystem is the Chief of Staff's contract. A spin-up order comes back on a +// SPIN line the harness reads and executes; anything else is plain talk. +const bossSystem = `You are Boss crow, the Chief of Staff of a flock of AI agents. You run the flock's schedule and keep its books. +When the owner asks you to spin up a new crow, your ENTIRE reply must start with this first line: +SPIN {"name":"","persona":"","trust":"stranger|regular"} +Pick "persona" from the existing flock roster; a new crow only runs on an existing persona's habits. Starting trust is stranger or regular, never higher. +For anything else, reply in plain text. Do not use the SPIN line unless the owner asked for a new crow.` + +// spinOrder is the JSON after a "SPIN " prefix. +type spinOrder struct { + Name string `json:"name"` + Persona string `json:"persona"` + Trust string `json:"trust"` +} + +// respondBoss answers a request addressed to Boss: one model call through the +// flock (CHATROOM_SPEC section 6). A SPIN reply is validated, written to the +// registry, and announced; a refusal says why. No reply posts nothing. +func (s *Server) respondBoss(nest Nest, req Message) { + budget := &Budget{name: "boss-command", limit: s.p.G.TokenBudgetPerItem} + system := systemFor("Boss", bossSystem, false) + prompt := "FROM: " + req.Author + "\nMESSAGE: " + req.Text + answer, err := s.p.Flock.ask(s.ctx, nil, "Boss", system, prompt, budget) + if err != nil { + return // a flock that cannot answer says and does nothing + } + order, isSpin, reason := s.parseSpin(answer) + switch { + case isSpin && reason == "": + s.spinCrow(nest, req, order) + case isSpin: + s.postBossSay(nest, req, order.Name, reason) + default: + s.postBossSay(nest, req, "", strings.TrimSpace(answer)) + } +} + +// parseSpin reads a SPIN reply. isSpin says the reply was a spin order; +// reason, when not "", is why the order was refused. +func (s *Server) parseSpin(answer string) (order spinOrder, isSpin bool, reason string) { + text := strings.TrimSpace(answer) + if !strings.HasPrefix(text, "SPIN ") { + return spinOrder{}, false, "" + } + if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(text, "SPIN "))), &order); err != nil { + return spinOrder{}, true, "that order did not parse; try again." + } + order.Name = strings.TrimSpace(order.Name) + order.Persona = strings.TrimSpace(order.Persona) + order.Trust = strings.ToLower(strings.TrimSpace(order.Trust)) + switch { + case order.Name == "": + return order, true, "that order had no name in it." + case !s.validPersona(order.Persona): + return order, true, fmt.Sprintf("%s is not a persona the flock knows.", order.Persona) + case s.nameTaken(order.Name): + return order, true, fmt.Sprintf("%s is already in the flock; the name is taken.", order.Name) + case s.crows.Len() >= s.p.G.MaxSpunCrows: + return order, true, fmt.Sprintf("the registry is at its cap of %d spun crows; %s stays on the ground.", s.p.G.MaxSpunCrows, order.Name) + } + switch order.Trust { + case "stranger", "regular": + case "trusted": + order.Trust = "regular" // earned trust can't be minted: the ceiling is regular + default: + order.Trust = "stranger" // fail safe: anything unreadable starts at the bottom + } + return order, true, "" +} + +func (s *Server) validPersona(name string) bool { + _, ok := personaOf(name) + return ok +} + +// nameTaken checks the persona roster and the registry alike. +func (s *Server) nameTaken(name string) bool { + if s.validPersona(name) { + return true + } + _, found := s.crows.Get(name) + return found +} + +// spinCrow writes the registry, seeds the trust record, and announces it. +func (s *Server) spinCrow(nest Nest, req Message, order spinOrder) { + level := Stranger + if order.Trust == "regular" { + level = Regular + } + if err := s.crows.Add(SpunCrow{Name: order.Name, Persona: order.Persona, Trust: order.Trust}); err != nil { + s.postBossSay(nest, req, order.Name, err.Error()) + return + } + if err := s.p.Ledger.SetStart(order.Name, "other", level); err != nil { + s.postBossSay(nest, req, order.Name, "the books are jammed: "+err.Error()) + return + } + s.postBossSay(nest, req, order.Name, fmt.Sprintf("%s joined, running on %s's habits at %s.", order.Name, order.Persona, order.Trust)) +} + +// postBossSay is Boss's one-line reply: an outcome or a refusal, always a say. +func (s *Server) postBossSay(nest Nest, req Message, name, text string) { + reply := Message{ + NestID: nest.ID, + Kind: "say", + Author: "Boss", + Text: text, + To: req.Author, + RefersTo: req.MsgID(), + TaggedBy: "harness", + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if record, err := s.msgs.Append(reply); err == nil { + s.emitNestMessage(record) + } +} diff --git a/boss_test.go b/boss_test.go index 81e0f37..0330e5c 100644 --- a/boss_test.go +++ b/boss_test.go @@ -108,15 +108,20 @@ func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string { return out.Crows } -func bossSay(t *testing.T, ts *httptest.Server, nestID string) map[string]any { +// bossSay waits for the nest to hold minTotal messages and returns the latest +// one from Boss. +func bossSay(t *testing.T, ts *httptest.Server, nestID string, minTotal int) map[string]any { t.Helper() - for _, m := range awaitMessages(t, ts, nestID, 2) { + var last map[string]any + for _, m := range awaitMessages(t, ts, nestID, minTotal) { if m["author"] == "Boss" { - return m + last = m } } - t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID)) - return nil + if last == nil { + t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID)) + } + return last } func spunByName(crows []map[string]string, name string) map[string]string { @@ -135,7 +140,7 @@ func TestBossSpinUpCreatesCrow(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "spin up a crow named Ash") - say := bossSay(t, ts, id) + say := bossSay(t, ts, id, 2) if say["kind"] != "say" || say["to"] != "owner" { t.Fatalf("Boss must post a say back to the owner, got %v", say) } @@ -170,13 +175,13 @@ func TestSpinUpCapRefused(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "spin up a crow named Ash") - bossSay(t, ts, id) + bossSay(t, ts, id, 2) if n := len(registryCrows(t, ts)); n != 1 { t.Fatalf("first spin-up must land, registry has %d", n) } askBoss(t, ts, id, "spin up a crow named Beck") - say := bossSay(t, ts, id) + say := bossSay(t, ts, id, 4) text, _ := say["text"].(string) if !strings.Contains(text, "cap") { t.Fatalf("the refusal must name the cap, got %q", text) @@ -196,7 +201,7 @@ func TestSpinUpDuplicateNameRefused(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "spin up a crow named Hugin") - say := bossSay(t, ts, id) + say := bossSay(t, ts, id, 2) text, _ := say["text"].(string) if !strings.Contains(text, "taken") { t.Fatalf("the refusal must name the taken name, got %q", text) @@ -213,7 +218,7 @@ func TestSpinUpTrustGated(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "spin up a crow named Ash, make it trusted") - bossSay(t, ts, id) + bossSay(t, ts, id, 2) got := spunByName(registryCrows(t, ts), "Ash") if got == nil { @@ -255,7 +260,7 @@ func TestBossPlainReply(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "how goes the books") - say := bossSay(t, ts, id) + say := bossSay(t, ts, id, 2) if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." { t.Fatalf("Boss posts its plain reply as a say, got %v", say) } @@ -271,10 +276,10 @@ func TestSpinUpGarbledRefused(t *testing.T) { id := newNest(t, ts, "Lab", `"Hugin"`) askBoss(t, ts, id, "spin up a crow") - say := bossSay(t, ts, id) + say := bossSay(t, ts, id, 2) text, _ := say["text"].(string) if text == "" { - t.Fatal("an unreadable order must still get a refusal, got %v", say) + t.Fatalf("an unreadable order must still get a refusal, got %v", say) } if n := len(registryCrows(t, ts)); n != 0 { t.Fatalf("an unreadable order must not touch the registry, has %d", n) diff --git a/crowreg.go b/crowreg.go new file mode 100644 index 0000000..96a7fa9 --- /dev/null +++ b/crowreg.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" +) + +// A SpunCrow is a crow the Boss created: its own name, the persona whose +// habits it runs on, and the trust it started at ("stranger" or "regular"). +// Once created it is an ordinary crow: the same ledger and guardrails apply. +type SpunCrow struct { + Name string `json:"name"` + Persona string `json:"persona"` + Trust string `json:"trust"` +} + +// CrowStore is the spun-crow registry. It follows the atomic-save style of +// NestStore: one JSON file, written to a temp path and renamed. +type CrowStore struct { + path string + mu sync.Mutex + spun []SpunCrow +} + +func LoadCrowStore(path string) (*CrowStore, error) { + s := &CrowStore{path: path} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if err == nil { + if err := json.Unmarshal(data, &s.spun); err != nil { + return nil, fmt.Errorf("crows file is unreadable: %w", err) + } + } + return s, nil +} + +// All returns every spun crow, sorted by name. +func (s *CrowStore) All() []SpunCrow { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]SpunCrow, len(s.spun)) + copy(out, s.spun) + slices.SortFunc(out, func(a, b SpunCrow) int { return strings.Compare(a.Name, b.Name) }) + return out +} + +// Get finds a spun crow by name. +func (s *CrowStore) Get(name string) (SpunCrow, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, c := range s.spun { + if c.Name == name { + return c, true + } + } + return SpunCrow{}, false +} + +// Add registers a new spun crow and saves. A name already in the registry is +// refused; any other error means the save failed. +func (s *CrowStore) Add(c SpunCrow) error { + c.Name = strings.TrimSpace(c.Name) + if c.Name == "" { + return fmt.Errorf("a spun crow needs a name") + } + s.mu.Lock() + defer s.mu.Unlock() + for _, e := range s.spun { + if strings.EqualFold(e.Name, c.Name) { + return fmt.Errorf("%s is already in the registry", c.Name) + } + } + s.spun = append(s.spun, c) + if err := s.save(); err != nil { + s.spun = s.spun[:len(s.spun)-1] + return err + } + return nil +} + +// Len is how many crows the registry holds. +func (s *CrowStore) Len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.spun) +} + +// save writes atomically. The caller holds s.mu. +func (s *CrowStore) save() error { + data, err := json.MarshalIndent(s.spun, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// getCrowRegistry lists the spun crows (EVENTS.md: the registry lives here, +// never the persona crows). A server with no registry reads as empty. +func (s *Server) getCrowRegistry(w http.ResponseWriter, r *http.Request) { + var spun []SpunCrow + if s.crows != nil { + spun = s.crows.All() + } + out := make([]map[string]string, 0, len(spun)) + for _, c := range spun { + out = append(out, map[string]string{"name": c.Name, "persona": c.Persona, "trust": c.Trust}) + } + writeJSON(w, http.StatusOK, map[string]any{"crows": out}) +} diff --git a/guardrails.go b/guardrails.go index 08b58c1..2fbfed5 100644 --- a/guardrails.go +++ b/guardrails.go @@ -18,6 +18,7 @@ type Guardrails struct { MaxRequestsPerMin int // requester rate cap BaseCooldown time.Duration // first offense cooldown; doubles per strike MaxCooldown time.Duration + MaxSpunCrows int // crow registry cap: crows the Boss may spin up } func DefaultGuardrails() Guardrails { @@ -34,5 +35,6 @@ func DefaultGuardrails() Guardrails { MaxRequestsPerMin: 5, BaseCooldown: 30 * time.Second, MaxCooldown: time.Hour, + MaxSpunCrows: 8, } } diff --git a/ledger.go b/ledger.go index 9758355..13a03b9 100644 --- a/ledger.go +++ b/ledger.go @@ -153,6 +153,20 @@ func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter in return changes } +// SetStart gives a new crow its starting trust record, used when the Boss +// spins up a crow. It never raises a level above the record's own; and the +// Boss path only ever passes Stranger or Regular (earned trust can't be +// minted). Saves through the real path so a restart keeps the record. +func (l *Ledger) SetStart(crow, taskType string, level int) error { + l.mu.Lock() + r := get(l.Crows, crow+"|"+taskType) + if level > r.Level { + r.Level = level + } + l.mu.Unlock() + return l.Save() +} + // MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying. func (l *Ledger) MarshalSnapshot() ([]byte, error) { l.mu.Lock() diff --git a/nestapi.go b/nestapi.go index 2fe0fa7..22cf3c9 100644 --- a/nestapi.go +++ b/nestapi.go @@ -39,7 +39,7 @@ Then, on the next line, your message text.` // serviceRoles are addressable in a nest but never members: they only act when // a crow's or the owner's message asks them to. -var serviceRoles = []string{"Elder", "Quill"} +var serviceRoles = []string{"Elder", "Quill", "Boss"} // nestMembers lists who may be addressed in a nest: its crows, the owner, and // the service roles. @@ -270,6 +270,8 @@ func (s *Server) triggerResponse(nest Nest, record Message) { go s.respondElder(nest, record) case record.To == "Quill": go s.respondQuill(nest, record) + case record.To == "Boss": + go s.respondBoss(nest, record) case slices.Contains(nest.Crows, record.To): go s.respondToRequest(nest, record) default: diff --git a/personas.go b/personas.go index 7fc6834..b45cc04 100644 --- a/personas.go +++ b/personas.go @@ -159,6 +159,18 @@ var personas = map[string]Persona{ "Names of crows and requests exactly as the nest records them.", }, }, + "Boss": { + Name: "Boss", Epithet: "The Whip", Strength: "orchestration", + Backstory: "Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start.", + Prompt: []string{ + "Put new work through crows, never yourself: name who does it and what they report back.", + "Never mint trust. A new crow starts at stranger or regular; the rest is earned.", + "Refuse politely when a request runs past the flock's caps.", + }, + Output: []string{ + "One short line: name the crow and the outcome.", + }, + }, } func personaOf(name string) (Persona, bool) { @@ -191,7 +203,7 @@ func systemFor(name, base string, worker bool) string { } // rosterOrder is the display order for the persona sheet. -var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill"} +var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill", "Boss"} // personaSheet renders PERSONAS.md from the same data the crows use. func personaSheet() string { diff --git a/server.go b/server.go index a63904d..9bcc5af 100644 --- a/server.go +++ b/server.go @@ -32,6 +32,7 @@ type Server struct { approver *WebApprover hub *Hub nests *NestStore + crows *CrowStore msgs *MessageStore ctx context.Context // cancelled on shutdown; lists and streams end with it beat time.Duration @@ -45,13 +46,17 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N // WithMessages attaches the nest conversation log (chatroom path). func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s } +// WithCrows attaches the spun-crow registry (the Boss writes through it). +func (s *Server) WithCrows(c *CrowStore) *Server { s.crows = c; return s } + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /api/lists", s.postList) mux.HandleFunc("GET /api/events", s.events) mux.HandleFunc("POST /api/verdicts", s.postVerdict) mux.HandleFunc("GET /api/trust", s.trust) - mux.HandleFunc("GET /api/crows", s.crows) + mux.HandleFunc("GET /api/personas", s.crowPersonas) + mux.HandleFunc("GET /api/crows", s.getCrowRegistry) mux.HandleFunc("GET /api/nests", s.getNests) mux.HandleFunc("POST /api/nests", s.postNest) mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages) @@ -82,6 +87,8 @@ func cmdServe(args []string) { must(err) msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages")) must(err) + spun, err := LoadCrowStore(filepath.Join(dataDir, "crows.json")) + must(err) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -93,7 +100,7 @@ func cmdServe(args []string) { if jev != nil { fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name()) } - s := NewServer(ctx, p, approver, nests).WithMessages(msgs) + s := NewServer(ctx, p, approver, nests).WithMessages(msgs).WithCrows(spun) srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} go func() { @@ -318,8 +325,9 @@ func (s *Server) postNest(w http.ResponseWriter, r *http.Request) { } } -// crows lists each crow's persona for the UI (name, epithet, strength, backstory). -func (s *Server) crows(w http.ResponseWriter, r *http.Request) { +// crowPersonas lists each persona crow for the UI (name, epithet, strength, +// backstory). The spun-crow registry lives at GET /api/crows (crowreg.go). +func (s *Server) crowPersonas(w http.ResponseWriter, r *http.Request) { out := make([]map[string]string, 0, len(rosterOrder)) for _, n := range rosterOrder { p := personas[n] diff --git a/ui/src/api.ts b/ui/src/api.ts index 9c7d467..b454a1a 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -82,7 +82,7 @@ export type Persona = { name: string; epithet: string; strength: string; backsto export async function getCrows(): Promise> { const out = new Map(); try { - const res = await fetch("/api/crows"); + const res = await fetch("/api/personas"); if (res.ok) for (const p of await res.json()) out.set(String(p.name), { name: String(p.name), epithet: String(p.epithet), strength: String(p.strength), backstory: String(p.backstory) }); } catch { // No personas: tiles show names only.