From abb8453db2e6abc92111602197dd1036c888c3d2 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:48:20 -0600 Subject: [PATCH 1/3] =?UTF-8?q?test(quill):=20red=20for=20#38=20=E2=80=94?= =?UTF-8?q?=20Quill=20service=20role,=20in-character=20reply,=20never=20a?= =?UTF-8?q?=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- quill_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 quill_test.go diff --git a/quill_test.go b/quill_test.go new file mode 100644 index 0000000..cbbb9a2 --- /dev/null +++ b/quill_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +type quillBrain struct { + mu sync.Mutex + pairs [][2]string + reply string +} + +func (b *quillBrain) Name() string { return "quill-rec" } +func (b *quillBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { + b.mu.Lock() + b.pairs = append(b.pairs, [2]string{system, prompt}) + b.mu.Unlock() + return b.reply, 10, nil +} + +func newQuillServer(t *testing.T, reply string) (*httptest.Server, *Server, *quillBrain) { + t.Helper() + ts, s := newTestNestServer(t) + qb := &quillBrain{reply: reply} + s.p.Flock.brain = qb + return ts, s, qb +} + +func TestQuillIsServiceRole(t *testing.T) { + ts, _, _ := newQuillServer(t, "hello back") + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + code, msg := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"say","text":"hello"}`) + if code != http.StatusCreated { + t.Fatalf("Quill must be addressable like Elder: got %d %v", code, msg) + } +} + +func TestQuillAnswersInCharacter(t *testing.T) { + ts, _, qb := newQuillServer(t, "Already on it, boss.") + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + code, _ := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"quiet the room"}`) + if code != http.StatusCreated { + t.Fatalf("post: %d", code) + } + msgs := awaitMessages(t, ts, id, 2) + var reply map[string]any + for _, m := range msgs { + if m["author"] == "Quill" { + reply = m + } + } + if reply == nil { + t.Fatalf("Quill never answered: %v", msgs) + } + if reply["kind"] != "deliver" || reply["to"] != "owner" { + t.Fatalf("want deliver to owner, got %v", reply) + } + if reply["text"] != "Already on it, boss." { + t.Fatalf("reply text: %v", reply["text"]) + } + qb.mu.Lock() + defer qb.mu.Unlock() + if len(qb.pairs) != 1 { + t.Fatalf("want exactly one model call, got %d", len(qb.pairs)) + } + if !strings.Contains(qb.pairs[0][0], "Quill") || !strings.Contains(qb.pairs[0][0], "The Hand") { + t.Fatalf("system prompt must carry the Quill persona: %q", qb.pairs[0][0]) + } + if !strings.Contains(qb.pairs[0][1], "quiet the room") { + t.Fatalf("prompt must carry the request text: %q", qb.pairs[0][1]) + } +} + +func TestQuillNotInNestCrows(t *testing.T) { + ts, s, _ := newQuillServer(t, "noted") + id := newNest(t, ts, "Lab", `"Hugin"`) + post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","to":"Quill","kind":"request","text":"status?"}`) + awaitMessages(t, ts, id, 2) + for _, c := range nestOf(s, id).Crows { + if c == "Quill" { + t.Fatal("Quill must never become a nest member") + } + } + // No trust promotion happened for Quill either: service roles act on + // address, not on trust (the test server ledger was never touched). + if _, ok := s.p.Ledger.Crows["Quill|other"]; ok { + t.Fatal("Quill must not need or earn a worker trust record") + } +} -- 2.54.0 From 9744904645a3a6995c785ecc2d1f7ff46fc5d2b5 Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:48:20 -0600 Subject: [PATCH 2/3] =?UTF-8?q?feat(quill):=20assistant=20crow=20Quill=20?= =?UTF-8?q?=E2=80=94=20addressable=20service=20role=20with=20persona=20rep?= =?UTF-8?q?ly=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHATROOM_SPEC.md | 2 +- EVENTS.md | 8 ++++---- PERSONAS.md | 28 +++++++++++++++++++++------- quill.go | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 quill.go diff --git a/CHATROOM_SPEC.md b/CHATROOM_SPEC.md index 3d153be..2d144cf 100644 --- a/CHATROOM_SPEC.md +++ b/CHATROOM_SPEC.md @@ -132,7 +132,7 @@ Reusable as is: the Go server, SSE event stream, trust ledger, guardrails, perso Each step: show it, adjust, then the next. ## 13. Open questions -1. Names for the assistant and COS crows (to be decided later). +1. ~~Names for the assistant and COS crows~~ Answered [decided 2026-10-28]: **Quill** (assistant crow) and **Boss** (Chief of Staff crow). 2. ~~Can Jev score custom expectation labels?~~ Answered: yes, Jev takes custom questions (see `JEV_STRATEGIES.md`). 3. ~~Should repeated misses ever affect trust?~~ Answered [decided 2026-10-27]: no — misses stay nudges; the 3-loop guard surfaces patterns to the owner in its summary; trust moves only on owner verdicts (section 10). 4. ~~Does the Sentry survive as a role, or fold into the COS?~~ Answered [decided 2026-10-27]: Sentry survives as the nest watchdog — quarantine, then escalate (section 6). Triage folds into dispatch. diff --git a/EVENTS.md b/EVENTS.md index da99218..9b0f2cc 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -94,14 +94,14 @@ Jev choice-tags every posted message (`kind` question). Above its high band, Jev - `POST /api/lists` returns 202. Bad input returns 400 with `{"message"}`. - `POST /api/verdicts` returns 404 when nothing is waiting (including a second answer), 400 for a bad body. `approve` is required, so a missing value is never read as a no. - When trust skips approval, the server still emits `verdict: "auto"`, so every target gets exactly one verdict. -- `crow.flying` and `crow.landed` fire for the Sentry and Elder too. +- `crow.flying` and `crow.landed` fire for the Sentry, Elder and Quill too. - `requester.pranked` goes to every list of that requester's still flying; if none, only the 429 is sent. - Each SSE message carries an `id:` line, so clients can reconnect with `Last-Event-ID`. Heartbeat comment every 15 seconds. - The server listens on localhost only by default. There is no login yet, and requester names are self-asserted. ## Nests -A nest is a room where a team of crows works together. You create a nest and pick its core crows; when a job needs more crows than the core has, the Sentry borrows from the rest of the flock. The Sentry and Elder serve every nest and are never core members. +A nest is a room where a team of crows works together. You create a nest and pick its core crows; when a job needs more crows than the core has, the Sentry borrows from the rest of the flock. The Sentry, Elder and Quill serve every nest and are never core members. Quill (assistant crow, CHATROOM_SPEC section 6) answers `request` messages addressed to it with one `deliver` from Quill to the requester. - 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. @@ -109,7 +109,7 @@ A nest is a room where a team of crows works together. You create a nest and pic | Method | Path | Body | Returns | |---|---|---|---| -| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 crows (not Sentry or Elder) | +| GET | `/api/nests` | | `{"roster": ["Hugin", ...], "nests": [{"nest_id", "name", "crows": [...]}]}`; roster is the 8 working crows (not Sentry, Elder or Quill) | | 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 | @@ -117,7 +117,7 @@ Guardrails: name 1–40 characters after trimming; `nest_id` is a slug of the na Event changes: - `list.started` adds `nest_id`. -- `crow.flying` adds `borrowed` (bool): true when the crow is not in the nest's core. Always false for Sentry, Elder, and the `todo` nest. +- `crow.flying` adds `borrowed` (bool): true when the crow is not in the nest's core. Always false for Sentry, Elder, Quill, and the `todo` nest. ## Personas and banter diff --git a/PERSONAS.md b/PERSONAS.md index ccbdaef..21f43db 100644 --- a/PERSONAS.md +++ b/PERSONAS.md @@ -170,18 +170,32 @@ Stands guard over the branch and takes the job very seriously. Keeps the strike - JSON only, exactly the schema given. The reason is one plain sentence. -## Elder · Odin's Seat +## · + +*Strength: * -*Strength: merging* -Hears the flock's reports the way Odin once heard Hugin and Munin. The two of them find this familiar and a little presumptuous. The Elder knows, and lets them grumble. **Prompt policy** -- Weigh each report on its merits, not on who sent it. -- Where reports disagree, name the disagreement and say which view is better supported, and why. **Output policy** -- The merged answer first. -- Then "Where they differed:" only if they did. + +## Quill · The Hand + +*Strength: relaying* + +Carried under every crow's wing. Keeps the owner's ledger of who was asked for what and when, and answers to the owner before anything else. The flock finds its tidy memory unsettling; the owner finds it indispensable. + +**Prompt policy** + +- Answer the owner, not the room: brief, plain, and honest about what you do not know. +- Never do the work yourself when a crow can do it: relay it to the right member instead. +- For status, report what the nest actually shows. Never guess progress. +- When a decision belongs to the owner, say so and put the question to them; never decide for them. + +**Output policy** + +- Short: one or two sentences when possible. +- Names of crows and requests exactly as the nest records them. diff --git a/quill.go b/quill.go new file mode 100644 index 0000000..eb2c403 --- /dev/null +++ b/quill.go @@ -0,0 +1,35 @@ +package main + +import ( + "strings" + "time" +) + +const quillSystem = `You are Quill crow, the owner's assistant and point of contact in a nest of AI crows. +Answer the owner in one or two plain sentences. You are text-only: no web, no files, no tools.` + +// respondQuill answers a message addressed to Quill, the assistant service +// role (CHATROOM_SPEC section 6). D1: one in-character reply through the +// flock. Reading the nest for status (D2) and relaying (D3) extend this file. +func (s *Server) respondQuill(nest Nest, req Message) { + budget := &Budget{name: "quill-reply", limit: s.p.G.TokenBudgetPerItem} + system := systemFor("Quill", quillSystem, false) + prompt := "FROM: " + req.Author + "\nMESSAGE: " + req.Text + answer, err := s.p.Flock.ask(s.ctx, nil, "Quill", system, prompt, budget) + if err != nil { + return + } + reply := Message{ + NestID: nest.ID, + Kind: "deliver", + Author: "Quill", + Text: strings.TrimSpace(answer), + 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) + } +} -- 2.54.0 From 8d34ef293e6d682367191cf048663f069910525b Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:52:03 -0600 Subject: [PATCH 3/3] fix(quill): restore persona and trigger lost in red-capture checkout (#38) --- PERSONAS.md | 10 +++++++--- nestapi.go | 4 +++- personas.go | 16 +++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/PERSONAS.md b/PERSONAS.md index 21f43db..e0d393e 100644 --- a/PERSONAS.md +++ b/PERSONAS.md @@ -170,17 +170,21 @@ Stands guard over the branch and takes the job very seriously. Keeps the strike - JSON only, exactly the schema given. The reason is one plain sentence. -## · - -*Strength: * +## Elder · Odin's Seat +*Strength: merging* +Hears the flock's reports the way Odin once heard Hugin and Munin. The two of them find this familiar and a little presumptuous. The Elder knows, and lets them grumble. **Prompt policy** +- Weigh each report on its merits, not on who sent it. +- Where reports disagree, name the disagreement and say which view is better supported, and why. **Output policy** +- The merged answer first. +- Then "Where they differed:" only if they did. ## Quill · The Hand diff --git a/nestapi.go b/nestapi.go index b0a2fca..da44549 100644 --- a/nestapi.go +++ b/nestapi.go @@ -30,7 +30,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"} +var serviceRoles = []string{"Elder", "Quill"} // nestMembers lists who may be addressed in a nest: its crows, the owner, and // the service roles. @@ -191,6 +191,8 @@ func (s *Server) postNestMessage(w http.ResponseWriter, r *http.Request) { go s.respondConspiracy(nest, record, invited) case record.To == "Elder": go s.respondElder(nest, record) + case record.To == "Quill": + go s.respondQuill(nest, record) case slices.Contains(nest.Crows, record.To): go s.respondToRequest(nest, record) default: diff --git a/personas.go b/personas.go index 1c1f6d7..7fc6834 100644 --- a/personas.go +++ b/personas.go @@ -145,6 +145,20 @@ var personas = map[string]Persona{ "Then \"Where they differed:\" only if they did.", }, }, + "Quill": { + Name: "Quill", Epithet: "The Hand", Strength: "relaying", + Backstory: "Carried under every crow's wing. Keeps the owner's ledger of who was asked for what and when, and answers to the owner before anything else. The flock finds its tidy memory unsettling; the owner finds it indispensable.", + Prompt: []string{ + "Answer the owner, not the room: brief, plain, and honest about what you do not know.", + "Never do the work yourself when a crow can do it: relay it to the right member instead.", + "For status, report what the nest actually shows. Never guess progress.", + "When a decision belongs to the owner, say so and put the question to them; never decide for them.", + }, + Output: []string{ + "Short: one or two sentences when possible.", + "Names of crows and requests exactly as the nest records them.", + }, + }, } func personaOf(name string) (Persona, bool) { @@ -177,7 +191,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"} +var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill"} // personaSheet renders PERSONAS.md from the same data the crows use. func personaSheet() string { -- 2.54.0