Merge pull request #46 from jacisjake/task/38-quill-role
feat(quill): assistant crow Quill — addressable service role (#38)
This commit was merged in pull request #46.
This commit is contained in:
7 files changed
+173
-7
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+18
@@ -185,3 +185,21 @@ Hears the flock's reports the way Odin once heard Hugin and Munin. The two of th
|
||||
|
||||
- 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.
|
||||
+3
-1
@@ -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:
|
||||
|
||||
+15
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user