From 3e4b591762e6df99cfe04e13d4eacf09bb974b34 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:09:49 -0600 Subject: [PATCH 1/2] =?UTF-8?q?test(boss):=20red=20for=20#43=20=E2=80=94?= =?UTF-8?q?=20Boss=20creates=20nests=20and=20assigns=20registry=20crows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- boss_test.go | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/boss_test.go b/boss_test.go index 0330e5c..7f991e7 100644 --- a/boss_test.go +++ b/boss_test.go @@ -3,9 +3,11 @@ package main import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "path/filepath" + "slices" "strings" "sync" "testing" @@ -333,3 +335,172 @@ func TestCrowsRegistryEmptyWithoutStore(t *testing.T) { t.Fatalf("want no crows, got %v", out.Crows) } } + +// --- #43: Boss creates nests and assigns members --- + +// listNests returns every nest from GET /api/nests. +func listNests(t *testing.T, ts *httptest.Server) []map[string]any { + t.Helper() + resp, err := http.Get(ts.URL + "/api/nests") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/nests: want 200, got %d", resp.StatusCode) + } + var out struct { + Nests []map[string]any `json:"nests"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out.Nests +} + +func findNest(t *testing.T, ts *httptest.Server, name string) map[string]any { + t.Helper() + for _, n := range listNests(t, ts) { + if n["name"] == name { + return n + } + } + return nil +} + +func nestCrowNames(nest map[string]any) []string { + raw, _ := nest["crows"].([]any) + out := make([]string, 0, len(raw)) + for _, c := range raw { + out = append(out, c.(string)) + } + slices.Sort(out) + return out +} + +func TestBossCreatesNest(t *testing.T) { + s, bb := newBossServer(t, + `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`, + `NEST {"name":"Launch","crows":["Hugin","Ash"]}`, + ) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + // Ash must exist as a registry crow: spin it through Boss first. + askBoss(t, ts, labID, "spin up a crow named Ash") + bossSay(t, ts, labID, 2) + + askBoss(t, ts, labID, "make a nest Launch with Hugin and Ash") + + var launch map[string]any + deadline := time.Now().Add(2 * time.Second) + for launch == nil && time.Now().Before(deadline) { + launch = findNest(t, ts, "Launch") + if launch == nil { + time.Sleep(20 * time.Millisecond) + } + } + if launch == nil { + t.Fatalf("Boss must create the nest Launch, nests: %v", listNests(t, ts)) + } + if got := nestCrowNames(launch); !slices.Equal(got, []string{"Ash", "Hugin"}) { + t.Fatalf("Launch crows must be exactly [Ash Hugin], got %v", got) + } + + // Boss announces membership in the NEW nest: a say to the owner. + announced := false + for _, m := range awaitMessages(t, ts, launch["nest_id"].(string), 1) { + if m["author"] == "Boss" && m["kind"] == "say" && m["to"] == "owner" { + announced = true + } + } + if !announced { + t.Fatalf("Boss must post a say to the owner in Launch, got %v", + nestMessages(t, ts, launch["nest_id"].(string))) + } + + // And confirms in the requesting nest. + confirmed := false + for _, m := range awaitMessages(t, ts, labID, 4) { + if m["author"] == "Boss" && m["kind"] == "say" && strings.Contains(m["text"].(string), "Launch") { + confirmed = true + } + } + if !confirmed { + t.Fatalf("Boss must confirm the new nest in Lab, got %v", nestMessages(t, ts, labID)) + } + if bb.count() != 2 { + t.Fatalf("two orders are two model calls, got %d", bb.count()) + } +} + +func TestBossNestUnknownMemberRefused(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Nope","crows":["Gandalf"]}`) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + before := len(listNests(t, ts)) + askBoss(t, ts, labID, "make a nest Nope with Gandalf") + + say := bossSay(t, ts, labID, 2) + text, _ := say["text"].(string) + if strings.HasPrefix(text, "NEST") { + t.Fatalf("Boss must interpret the order, not echo it back: %q", text) + } + if !strings.Contains(text, "Gandalf") { + t.Fatalf("the refusal must name the unknown member, got %q", text) + } + if after := len(listNests(t, ts)); after != before { + t.Fatalf("an unknown member must refuse the whole create: %d before, %d after", before, after) + } +} + +func TestBossNestCapRespected(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Overflow","crows":["Hugin"]}`) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + // Fill the store to its cap: todo + Lab + (maxNests-2) pads. + for i := 0; 2+i < maxNests; i++ { + if _, err := s.nests.Create(fmt.Sprintf("Pad %d", i), []string{"Hugin"}); err != nil { + t.Fatalf("padding nests: %v", err) + } + } + if n := len(listNests(t, ts)); n != maxNests { + t.Fatalf("test setup: want %d nests, got %d", maxNests, n) + } + + askBoss(t, ts, labID, "make a nest Overflow with Hugin") // 201: no 500 at the front door + say := bossSay(t, ts, labID, 2) + text, _ := say["text"].(string) + if !strings.Contains(text, fmt.Sprintf("at most %d nests", maxNests)) { + t.Fatalf("Boss must report the store's cap refusal, got %q", text) + } + if findNest(t, ts, "Overflow") != nil { + t.Fatal("a capped create must not make a nest") + } +} + +func TestBossNestGoesThroughFlock(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Launch","crows":["Hugin","Munin"]}`) + s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + before := len(listNests(t, ts)) + askBoss(t, ts, labID, "make a nest Launch with Hugin and Munin") + time.Sleep(300 * time.Millisecond) + + if n := len(listNests(t, ts)); n != before { + t.Fatalf("a saturated flock must not create a nest, got %v", listNests(t, ts)) + } + for _, m := range nestMessages(t, ts, labID) { + if m["author"] == "Boss" { + t.Fatalf("a saturated flock must not produce a Boss message, got %v", m) + } + } +} -- 2.54.0 From dab245cb737a2f419be9e705a962473056e7b0ca Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:11:57 -0600 Subject: [PATCH 2/2] feat(boss): Boss creates nests and assigns registry crows (#43) --- EVENTS.md | 4 +-- boss.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++--- crowreg.go | 6 +++++ nests.go | 21 ++++++++++++--- server.go | 11 ++++++-- 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index 8904734..1fc60b1 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -117,7 +117,7 @@ A relay (D3, task #40): when the owner's request asks another named member crow - The default nest is `todo`, with an empty core: its crows come from the whole flock, so nothing there is ever "borrowed". - Crows are picked core-first (rotating within the core), then borrowed from outside it (rotating), still capped by the flock guardrails. - Nests persist on the server (`.crows/nests.json`). -- Boss (Chief of Staff crow, CHATROOM_SPEC section 6) is a service role like Elder and Quill: a `request` addressed to it gets one `say` from Boss naming the crow and the outcome. Asked to spin up a crow, Boss answers with a structured order (`SPIN {"name","persona","trust"}`) that the harness executes; everything else gets a plain reply. +- Boss (Chief of Staff crow, CHATROOM_SPEC section 6) is a service role like Elder and Quill: a `request` addressed to it gets one `say` from Boss naming the crow and the outcome. Asked to spin up a crow, Boss answers with a structured order (`SPIN {"name","persona","trust"}`) that the harness executes; asked to create a nest, with a `NEST {"name","crows"}` order the harness executes through the nest store — so the harness may create nests via Boss, and a Boss-staffed nest may hold registry (spun) crows as members. No new HTTP endpoint. Anything else gets a plain reply. ### Spun crows @@ -129,7 +129,7 @@ Crows the Boss creates live in a **crow registry** (`.crows/crows.json`), separa | POST | `/api/nests` | `{"name": "Launch copy", "crows": ["Pepper", "Inky"]}` | 201 with the nest; 400 with `{"message"}` if invalid | | POST | `/api/lists` | adds optional `"nest_id"` (default `todo`) | 404 with `{"message"}` for an unknown nest | -Guardrails: name 1–40 characters after trimming; `nest_id` is a slug of the name (lowercase, `a-z0-9-`), unique (409 with `{"message"}` on a clash); `crows` must be roster names, no duplicates, 1–8 of them; at most 20 nests. +Guardrails: name 1–40 characters after trimming; `nest_id` is a slug of the name (lowercase, `a-z0-9-`), unique (409 with `{"message"}` on a clash); `crows` must be roster or registry (spun) names, no duplicates, 1–8 of them; at most 20 nests. Event changes: - `list.started` adds `nest_id`. diff --git a/boss.go b/boss.go index 6727bdd..639f891 100644 --- a/boss.go +++ b/boss.go @@ -8,12 +8,16 @@ import ( ) // 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. +// SPIN line and a nest order on a NEST line, both read and executed by the +// harness; 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.` +When the owner asks you to create a nest, your ENTIRE reply must start with this first line: +NEST {"name":"","crows":["",""]} +The crows are the working flock members the owner named; the Sentry, Elder, Quill and Boss serve every nest and are never members. +For anything else, reply in plain text. Do not use the SPIN or NEST line unless the owner asked for a new crow or a new nest.` // spinOrder is the JSON after a "SPIN " prefix. type spinOrder struct { @@ -22,9 +26,16 @@ type spinOrder struct { Trust string `json:"trust"` } +// nestOrder is the JSON after a "NEST " prefix. +type nestOrder struct { + Name string `json:"name"` + Crows []string `json:"crows"` +} + // 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. +// registry, and announced; a NEST reply is validated and built through the +// nest store; 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) @@ -34,11 +45,16 @@ func (s *Server) respondBoss(nest Nest, req Message) { return // a flock that cannot answer says and does nothing } order, isSpin, reason := s.parseSpin(answer) + nOrder, isNest, nestReason := s.parseNest(answer) switch { case isSpin && reason == "": s.spinCrow(nest, req, order) case isSpin: s.postBossSay(nest, req, order.Name, reason) + case isNest && nestReason == "": + s.createNest(nest, req, nOrder) + case isNest: + s.postBossSay(nest, req, nOrder.Name, nestReason) default: s.postBossSay(nest, req, "", strings.TrimSpace(answer)) } @@ -82,6 +98,31 @@ func (s *Server) validPersona(name string) bool { return ok } +// parseNest reads a NEST reply. isNest says the reply was a nest order; +// reason, when not "", is why the order never reaches the nest store. Member +// names are resolved by the store itself: persona crows and registry crows +// pass, anything else (service roles included) is refused there. +func (s *Server) parseNest(answer string) (order nestOrder, isNest bool, reason string) { + text := strings.TrimSpace(answer) + if !strings.HasPrefix(text, "NEST ") { + return nestOrder{}, false, "" + } + if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(text, "NEST "))), &order); err != nil { + return nestOrder{}, true, "that order did not parse; try again." + } + order.Name = strings.TrimSpace(order.Name) + for i, c := range order.Crows { + order.Crows[i] = strings.TrimSpace(c) + } + switch { + case order.Name == "": + return order, true, "that order had no nest name in it." + case len(order.Crows) == 0: + return order, true, "that order had no crows in it." + } + return order, true, "" +} + // nameTaken checks the persona roster and the registry alike. func (s *Server) nameTaken(name string) bool { if s.validPersona(name) { @@ -108,6 +149,34 @@ func (s *Server) spinCrow(nest Nest, req Message, order spinOrder) { s.postBossSay(nest, req, order.Name, fmt.Sprintf("%s joined, running on %s's habits at %s.", order.Name, order.Persona, order.Trust)) } +// createNest builds the nest Boss was ordered into. The store has the last +// word on guardrails — name, members (persona roster or registry), duplicates +// and the nest cap are all checked there before anything mutates, so a bad +// order never leaves a partial nest. Success posts a Boss say announcing +// membership in the new nest, plus a confirming say in the requesting nest; a +// refusal says why. +func (s *Server) createNest(reqNest Nest, req Message, order nestOrder) { + created, err := s.nests.Create(order.Name, order.Crows) + if err != nil { + s.postBossSay(reqNest, req, order.Name, err.Error()) + return + } + members := strings.Join(created.Crows, ", ") + open := Message{ + NestID: created.ID, + Kind: "say", + Author: "Boss", + Text: fmt.Sprintf("This nest is open: %s. Its crows: %s.", created.Name, members), + To: req.Author, + TaggedBy: "harness", + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if record, err := s.msgs.Append(open); err == nil { + s.emitNestMessage(record) + } + s.postBossSay(reqNest, req, created.Name, fmt.Sprintf("%s opened with %s.", created.Name, members)) +} + // 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{ diff --git a/crowreg.go b/crowreg.go index 96a7fa9..1f261c4 100644 --- a/crowreg.go +++ b/crowreg.go @@ -86,6 +86,12 @@ func (s *CrowStore) Add(c SpunCrow) error { return nil } +// Has reports whether a crow with that name is in the registry. +func (s *CrowStore) Has(name string) bool { + _, ok := s.Get(name) + return ok +} + // Len is how many crows the registry holds. func (s *CrowStore) Len() int { s.mu.Lock() diff --git a/nests.go b/nests.go index 58dd183..28eb1a9 100644 --- a/nests.go +++ b/nests.go @@ -44,9 +44,22 @@ func invalid(format string, a ...any) error { // NestStore holds the nests and saves the ones people create to disk. type NestStore struct { - path string - mu sync.Mutex - made []Nest // created nests, in creation order; the default nest is not stored + path string + mu sync.Mutex + made []Nest // created nests, in creation order; the default nest is not stored + allowCrow func(name string) bool // extra member names beyond the roster (spun crows) +} + +// AllowCrows extends the names that may be nest members beyond the persona +// roster: the server wires it to the spun-crow registry so the Boss can +// staff a nest with registry crows (#43). It is set once at wiring time, +// before anything is served; Create stays the only mutation. +func (s *NestStore) AllowCrows(ok func(name string) bool) { s.allowCrow = ok } + +// memberKnown reports whether a name may join a nest: a working persona crow +// (the roster excludes the service roles) or an allowed extra name. +func (s *NestStore) memberKnown(name string) bool { + return slices.Contains(roster, name) || (s.allowCrow != nil && s.allowCrow(name)) } func LoadNests(path string) (*NestStore, error) { @@ -104,7 +117,7 @@ func (s *NestStore) Create(name string, crows []string) (Nest, error) { return Nest{}, invalid("A nest has 1 to %d crows.", len(roster)) } for i, c := range crows { - if !slices.Contains(roster, c) { + if !s.memberKnown(c) { return Nest{}, invalid("%q is not in the flock. Choose from %s.", c, strings.Join(roster, ", ")) } if slices.Contains(crows[:i], c) { diff --git a/server.go b/server.go index 9bcc5af..fcc1e5f 100644 --- a/server.go +++ b/server.go @@ -46,8 +46,15 @@ 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 } +// WithCrows attaches the spun-crow registry (the Boss writes through it) and +// lets registry crows be nest members (#43). +func (s *Server) WithCrows(c *CrowStore) *Server { + s.crows = c + if c != nil && s.nests != nil { + s.nests.AllowCrows(c.Has) + } + return s +} func (s *Server) Handler() http.Handler { mux := http.NewServeMux() -- 2.54.0