diff --git a/EVENTS.md b/EVENTS.md index 4d7968a..8904734 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -81,6 +81,8 @@ Reconnect works the same as the list stream: each SSE message carries an `id:` l | POST | `/api/nests/{nest_id}/messages` | `{"author", "text", "to"?, "kind"? (crow header), "refers_to"?, "approval_phrase"? (honored only when the resulting kind is `ask_owner`)}` | 201 with the stored record (server-assigned `kind`, `tagged_by`, `msg_id`). A fresh `ask_owner` reads `resolved: ""`. 404 unknown nest; 400 with `{"message"}` for bad input; 403 with `{"message"}` when the crow author is quarantined; 429 with `{"message","cooldown_s"}` when the poster is rate-limited. | | GET | `/api/events` | `?nest_id=` (parallel to `?list_id=`) | SSE stream of `nest.message` events for that nest. | +Service roles (Elder, Quill, Boss) are addressable in every nest but are never members. A `request` addressed to Boss triggers one `say` back to the requester: the outcome of the order (a new crow joined, or a refusal naming the reason — cap reached, name taken, persona unknown). Boss works through the flock like any crow: a saturated flock means no Boss reply, and no registry write. + ### Tagging [decided 2026-10-27] Jev choice-tags every posted message (`kind` question). Above its high band, Jev's tag stands (`tagged_by: "jev"`). If Jev is unsure, the crow's own `kind` header is used (`"header"`). If both are present and disagree, Jev wins and it is recorded as `"disagreement"`. If neither yields a kind, the message is `say` (`"default"`). Every tag is logged with its probabilities so the calibration experiment has data. @@ -93,6 +95,7 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - Watchdog (E3): every crow `claim` or `deliver` that refers to a request gets one Jev call (`Flock.ClassifyWatch`, strategies 4/8 in `JEV_STRATEGIES.md`) asking three noul questions about it against its request. Any score at or above the band, or `answers_request` at or below `1 − band` (band = `CROWS_JEV_THRESHOLD`), is a breach: the Sentry posts a `flag` naming the scores and the crow is quarantined. At most one flag per crow per referent (later breaches are logged, not posted). Owner messages are never inspected. - A quarantined crow's POSTs (any crow kind) return 403 with `{"message": " is quarantined pending the owner's decision."}`, and it is skipped by the open-, conspiracy- and directed-response selectors. Quarantine is fail-safe: it sticks until the owner lifts it (`ask_owner` escalation lands with #45). - Requests per minute and cooldown reuse the requester strikes in the ledger, keyed by `author`. +- Spin-ups: at most `MaxSpunCrows` (default 8) registry crows; a spun crow's name may not collide with the persona roster or the registry; starting trust is clamped to `regular` at most. ## Settled details @@ -114,10 +117,15 @@ 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. + +### Spun crows + +Crows the Boss creates live in a **crow registry** (`.crows/crows.json`), separate from the persona roster. A registry crow is a name, the backing persona whose habits it runs on, and its starting trust. Once created it is an ordinary crow: the same trust ledger and guardrails apply. Spun trust is capped at `regular` — earned trust can't be minted. Registry size is capped by `MaxSpunCrows` (default 8). | Method | Path | Body | Returns | |---|---|---|---| -| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 working crows (not Sentry, Elder or Quill) | +| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 working crows (not Sentry, Elder, Quill or Boss) | | 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 | @@ -131,7 +139,8 @@ Event changes: | Method | Path | Returns | |---|---|---| -| GET | `/api/crows` | `[{"name", "epithet", "strength", "backstory"}]` for all ten crows | +| GET | `/api/personas` | `[{"name", "epithet", "strength", "backstory"}]` for every persona crow: the 8 workers plus Sentry, Elder, Quill and Boss | +| GET | `/api/crows` | `{"crows": [{"name", "persona", "trust"}]}` — the spun-crow registry only (never persona crows), sorted by name. `persona` is the backing persona it runs on; `trust` is the level it started at (`"stranger"` or `"regular"`). An empty registry, or a server with none, returns `{"crows": []}`. | | Event | Payload (besides list_id, ts) | Meaning | |---|---|---| 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 new file mode 100644 index 0000000..0330e5c --- /dev/null +++ b/boss_test.go @@ -0,0 +1,335 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// bossBrain is a scripted brain that records how often it was called and hands +// back one canned reply per call. +type bossBrain struct { + mu sync.Mutex + calls int + resp []string +} + +func (b *bossBrain) Name() string { return "boss-script" } +func (b *bossBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + defer b.mu.Unlock() + r := "no reply" + if b.calls < len(b.resp) { + r = b.resp[b.calls] + } + b.calls++ + return r, 10, nil +} + +func (b *bossBrain) count() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.calls +} + +// newBossServer builds a whole server by hand (like newDispatchServer) so the +// Boss tests can attach a CrowStore at a temp path. +func newBossServer(t *testing.T, replies ...string) (*Server, *bossBrain) { + t.Helper() + dir := t.TempDir() + ledger, err := LoadLedger(filepath.Join(dir, "ledger.json")) + if err != nil { + t.Fatal(err) + } + flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { flog.Close() }) + nests, err := LoadNests(filepath.Join(dir, "nests.json")) + if err != nil { + t.Fatal(err) + } + msgs, err := LoadMessageStore(filepath.Join(dir, "messages")) + if err != nil { + t.Fatal(err) + } + crows, err := LoadCrowStore(filepath.Join(dir, "crows.json")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + g := DefaultGuardrails() + bb := &bossBrain{resp: replies} + appr := NewWebApprover(time.Minute) + p := &Pipeline{Flock: NewFlock(bb, g, flog), Ledger: ledger, Log: flog, G: g, Approver: appr} + s := NewServer(ctx, p, appr, nests).WithMessages(msgs).WithCrows(crows) + return s, bb +} + +func askBoss(t *testing.T, ts *httptest.Server, nestID, text string) (int, map[string]any) { + t.Helper() + code, out := post(t, ts.URL+"/api/nests/"+nestID+"/messages", + `{"author":"owner","kind":"request","to":"Boss","text":`+jsonString(text)+`}`) + if code != http.StatusCreated { + t.Fatalf("owner POST to Boss: want 201, got %d %v", code, out) + } + return code, out +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string { + t.Helper() + resp, err := http.Get(ts.URL + "/api/crows") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/crows: want 200, got %d", resp.StatusCode) + } + var out struct { + Crows []map[string]string `json:"crows"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out.Crows +} + +// 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() + var last map[string]any + for _, m := range awaitMessages(t, ts, nestID, minTotal) { + if m["author"] == "Boss" { + last = m + } + } + 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 { + for _, c := range crows { + if c["name"] == name { + return c + } + } + return nil +} + +func TestBossSpinUpCreatesCrow(t *testing.T) { + s, bb := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + 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) + } + if !strings.Contains(say["text"].(string), "Ash") { + t.Fatalf("Boss say must name the new crow, got %v", say["text"]) + } + + got := spunByName(registryCrows(t, ts), "Ash") + if got == nil { + t.Fatalf("registry must contain Ash, got %v", registryCrows(t, ts)) + } + if got["persona"] != "Soot" || got["trust"] != "stranger" { + t.Fatalf("want Ash on Soot at stranger, got %v", got) + } + + rec := s.p.Ledger.Crows["Ash|other"] + if rec == nil || rec.Level != Stranger { + t.Fatalf("ledger must carry Ash|other at Stranger, got %+v", rec) + } + if bb.count() != 1 { + t.Fatalf("one spin-up is exactly one model call, got %d", bb.count()) + } +} + +func TestSpinUpCapRefused(t *testing.T) { + s, _ := newBossServer(t, + `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`, + `SPIN {"name":"Beck","persona":"Jet","trust":"stranger"}`) + s.p.G.MaxSpunCrows = 1 + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + 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, 4) + text, _ := say["text"].(string) + if !strings.Contains(text, "cap") { + t.Fatalf("the refusal must name the cap, got %q", text) + } + if n := len(registryCrows(t, ts)); n != 1 { + t.Fatalf("a refused spin-up must not touch the registry, has %d", n) + } + if _, ok := s.p.Ledger.Crows["Beck|other"]; ok { + t.Fatal("a refused spin-up must not touch the ledger") + } +} + +func TestSpinUpDuplicateNameRefused(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Hugin","persona":"Soot","trust":"stranger"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Hugin") + 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) + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("registry must stay empty, has %d", n) + } +} + +func TestSpinUpTrustGated(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"trusted"}`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash, make it trusted") + bossSay(t, ts, id, 2) + + got := spunByName(registryCrows(t, ts), "Ash") + if got == nil { + t.Fatal("Ash must be in the registry") + } + if got["trust"] != "regular" { + t.Fatalf("trusted must clamp to regular, got %q", got["trust"]) + } + rec := s.p.Ledger.Crows["Ash|other"] + if rec == nil || rec.Level > Regular { + t.Fatalf("ledger level must be at most Regular, got %+v", rec) + } +} + +func TestSpinUpGoesThroughFlock(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`) + s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow named Ash") + time.Sleep(300 * time.Millisecond) + + for _, m := range nestMessages(t, ts, id) { + if m["author"] == "Boss" { + t.Fatalf("a saturated flock must not produce a Boss message, got %v", m) + } + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("a saturated flock must not write the registry, has %d", n) + } +} + +func TestBossPlainReply(t *testing.T) { + s, _ := newBossServer(t, "The schedule stands. Nothing needs whip.") + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "how goes the books") + 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) + } + if n := len(registryCrows(t, ts)); n != 0 { + t.Fatalf("a plain reply must not touch the registry, has %d", n) + } +} + +func TestSpinUpGarbledRefused(t *testing.T) { + s, _ := newBossServer(t, `SPIN {"name": broken json`) + ts := serve(s) + defer ts.Close() + id := newNest(t, ts, "Lab", `"Hugin"`) + + askBoss(t, ts, id, "spin up a crow") + say := bossSay(t, ts, id, 2) + text, _ := say["text"].(string) + if text == "" { + 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) + } +} + +func TestPersonasEndpointIncludesBoss(t *testing.T) { + ts, _ := newTestNestServer(t) + defer ts.Close() + resp, err := http.Get(ts.URL + "/api/personas") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/personas: want 200, got %d", resp.StatusCode) + } + var out []map[string]string + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range out { + names[p["name"]] = true + } + for _, want := range []string{"Boss", "Quill", "Hugin"} { + if !names[want] { + t.Fatalf("/api/personas must list every persona crow (missing %s): %v", want, names) + } + } +} + +func TestCrowsRegistryEmptyWithoutStore(t *testing.T) { + ts, _ := newTestNestServer(t) // no WithCrows: the registry reads as empty + defer ts.Close() + resp, err := http.Get(ts.URL + "/api/crows") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("want 200 with an empty list, got %d", resp.StatusCode) + } + var out struct { + Crows []map[string]string `json:"crows"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if len(out.Crows) != 0 { + t.Fatalf("want no crows, got %v", out.Crows) + } +} 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.