Merge pull request #51 from jacisjake/task/42-boss-crow-registry

feat(boss): Chief of Staff Boss + spun-crow registry with cap (#42)
This commit was merged in pull request #51.
This commit is contained in:
jacisjake authored and GitHub committed 2026-10-28 09:01:55 -06:00
commit 5edb263656
11 files changed
+657 -9

No files matched your search

+11 -2
View File
@@ -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=<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": "<author> 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 |
|---|---|---|
+16
View File
@@ -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.
+126
View File
@@ -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":"<name>","persona":"<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)
}
}
+335
View File
@@ -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)
}
}
+124
View File
@@ -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})
}
+2
View File
@@ -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,
}
}
+14
View File
@@ -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()
+3 -1
View File
@@ -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:
+13 -1
View File
@@ -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 {
+12 -4
View File
@@ -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]
+1 -1
View File
@@ -82,7 +82,7 @@ export type Persona = { name: string; epithet: string; strength: string; backsto
export async function getCrows(): Promise<Map<string, Persona>> {
const out = new Map<string, Persona>();
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.