Add web server, event stream, and Rive contract

- crows serve: HTTP API, Server-Sent Events per list, web approvals
  with a 10-minute fail-safe timeout; localhost only by default
- Shared pipeline for command line and server; trust.changed events
- EVENTS.md: server/UI contract; RIVE_CONTRACT.md: data-binding spec
  for the animator (one Crow artboard for v1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
SmokeyandClaude Opus 5.5 committed 2026-10-27 08:31:27 -06:00
1 parent 7ae7f670ff
commit 940c74672a
12 files changed
+1320 -129

No files matched your search

+48 -107
View File
@@ -22,6 +22,8 @@ func main() {
switch os.Args[1] {
case "run":
cmdRun(os.Args[2:])
case "serve":
cmdServe(os.Args[2:])
case "trust":
cmdTrust()
case "log":
@@ -35,16 +37,12 @@ func usage() {
fmt.Fprintln(os.Stderr, `So Many Crows
crows run <todo.txt> [-as name] hand the flock a todo list
crows serve [-addr 127.0.0.1:8080] run the web server (API + SSE)
crows trust show trust ledger
crows log [-n 20] show recent flight log entries`)
os.Exit(2)
}
type itemResult struct {
Item, Mode, TaskType, Answer, Verdict, Skipped string
Crows []string
}
func cmdRun(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
as := fs.String("as", defaultRequester(), "requester id")
@@ -64,111 +62,73 @@ func cmdRun(args []string) {
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
must(err)
defer flog.Close()
save := func() {
if err := ledger.Save(); err != nil {
fmt.Fprintln(os.Stderr, "could not save ledger:", err)
}
}
p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
if ok, msg := ledger.Admit(*as, time.Now(), g); !ok {
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
fmt.Println(msg)
flog.Write(Event{Kind: "denied", Requester: *as, Note: msg})
save()
p.save()
os.Exit(1)
}
items, err := readTodo(todoPath, g)
data, err := os.ReadFile(todoPath)
must(err)
items, err := parseTodo(string(data), g)
must(err)
if len(items) > g.MaxItemsPerList {
fmt.Printf("That list has %d items. The flock takes at most %d at a time.\n", len(items), g.MaxItemsPerList)
save()
p.save()
os.Exit(1)
}
brain := NewBrain()
flock := NewFlock(brain, g, flog)
p.Flock = NewFlock(brain, g, flog)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
in := bufio.NewReader(os.Stdin)
listBudget := &Budget{name: "list", limit: g.TokenBudgetPerList}
fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items))
flog.Write(Event{Kind: "list", Requester: *as, Note: fmt.Sprintf("%s (%d items)", todoPath, len(items))})
l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items}
results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}})
var results []itemResult
for i, item := range items {
fmt.Printf("\n[%d/%d] %s\n", i+1, len(items), item)
r := itemResult{Item: item}
itemBudget := &Budget{name: "item", limit: g.TokenBudgetPerItem}
t, err := flock.Triage(ctx, item, itemBudget, listBudget)
if err != nil {
r.Skipped = "triage failed: " + err.Error()
fmt.Println(" " + r.Skipped)
results = append(results, r)
continue
}
r.Mode, r.TaskType = t.Mode, t.TaskType
fmt.Printf(" Sentry: %s · %s · %s\n", t.Mode, t.TaskType, t.Reason)
for _, s := range t.Subtasks {
fmt.Printf(" - %s\n", s)
}
flog.Write(Event{Kind: "triage", Requester: *as, Item: item, Mode: t.Mode, TaskType: t.TaskType, Note: t.Reason})
// The Sentry earns autonomy on triage separately from the crows' work.
triageType := "triage/" + t.TaskType
if ledger.Level("Sentry", triageType) < Trusted {
ok := confirm(in, " Approve triage?")
ledger.Judge([]string{"Sentry"}, triageType, ok, g.PromoteAfter)
flog.Write(Event{Kind: "verdict", Item: item, Crow: "Sentry", TaskType: triageType, Verdict: verdict(ok)})
if !ok {
r.Skipped = "triage rejected"
results = append(results, r)
continue
}
}
out, err := flock.Execute(ctx, item, t, itemBudget, listBudget)
if err != nil {
r.Skipped = "flight failed: " + err.Error()
fmt.Println(" " + r.Skipped)
results = append(results, r)
continue
}
r.Answer, r.Crows = out.Answer, out.Crows
fmt.Printf(" Crows: %s\n%s\n", strings.Join(out.Crows, ", "), indent(out.Answer, " > "))
// The least-trusted crow involved sets the bar.
lowest := Trusted
for _, c := range out.Crows {
lowest = min(lowest, ledger.Level(c, t.TaskType))
}
if lowest < Regular {
ok := confirm(in, " Accept this answer?")
ledger.Judge(out.Crows, t.TaskType, ok, g.PromoteAfter)
r.Verdict = verdict(ok)
} else if lowest < Trusted {
r.Verdict = "accepted, unreviewed"
} else {
r.Verdict = "accepted, trusted"
}
flog.Write(Event{Kind: "verdict", Item: item, Crow: strings.Join(out.Crows, ","), TaskType: t.TaskType, Verdict: r.Verdict})
results = append(results, r)
}
save()
resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md"
must(writeResults(resultPath, todoPath, results))
fmt.Printf("\nResults: %s · tokens used: %d/%d\n", resultPath, listBudget.spent, listBudget.limit)
fmt.Printf("\nResults: %s · tokens used: %d/%d\n", resultPath, listBudget.Spent(), listBudget.limit)
}
func readTodo(path string, g Guardrails) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
// cliEmitter prints the events a person at a terminal cares about.
type cliEmitter struct {
items []string
seen map[string]bool
}
func (c *cliEmitter) Emit(event string, f map[string]any) {
if id, ok := f["item_id"].(string); ok && !c.seen[id] {
c.seen[id] = true
var n int
fmt.Sscanf(id[strings.LastIndex(id, "-")+1:], "%d", &n)
fmt.Printf("\n[%d/%d] %s\n", n, len(c.items), c.items[n-1])
}
switch event {
case "item.triaged":
fmt.Printf(" Sentry: %s · %s · %s\n", f["mode"], f["task_type"], f["reason"])
for _, s := range f["subtasks"].([]string) {
fmt.Printf(" - %s\n", s)
}
case "item.answered":
fmt.Printf(" Crows: %s\n%s\n", strings.Join(f["crows"].([]string), ", "), indent(f["answer"].(string), " > "))
case "trust.changed":
fmt.Printf(" Trust: %s on %s went %s → %s\n", f["holder"], f["task_type"], levelNames[f["from"].(int)], levelNames[f["to"].(int)])
case "item.skipped":
fmt.Printf(" %s\n", f["reason"])
}
}
var levelNames = []string{"stranger", "regular", "trusted"}
// parseTodo turns todo text (markdown checkboxes, bullets or plain lines) into items.
func parseTodo(text string, g Guardrails) ([]string, error) {
var items []string
for _, line := range strings.Split(string(data), "\n") {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
for _, p := range []string{"- [ ]", "- [x]", "-", "*"} {
line = strings.TrimSpace(strings.TrimPrefix(line, p))
@@ -184,24 +144,6 @@ func readTodo(path string, g Guardrails) ([]string, error) {
return items, nil
}
// confirm fails safe: anything but y/yes, including no input at all, is a no.
func confirm(in *bufio.Reader, prompt string) bool {
fmt.Print(prompt + " [y/N] ")
line, _ := in.ReadString('\n')
a := strings.ToLower(strings.TrimSpace(line))
if line == "" {
fmt.Println()
}
return a == "y" || a == "yes"
}
func verdict(ok bool) string {
if ok {
return "approved"
}
return "rejected"
}
func writeResults(path, source string, results []itemResult) error {
var sb strings.Builder
fmt.Fprintf(&sb, "# So Many Crows: %s\n\n_%s_\n\n", filepath.Base(source), time.Now().Format("2006-01-02 15:04"))
@@ -211,7 +153,7 @@ func writeResults(path, source string, results []itemResult) error {
continue
}
box := "x"
if r.Verdict == "rejected" {
if r.Verdict == "rejected" || r.Verdict == "timeout" {
box = " "
}
fmt.Fprintf(&sb, "- [%s] %s\n - %s · %s · %s · %s\n%s\n\n", box, r.Item, r.Mode, r.TaskType, strings.Join(r.Crows, ", "), r.Verdict, indent(r.Answer, " > "))
@@ -222,7 +164,6 @@ func writeResults(path, source string, results []itemResult) error {
func cmdTrust() {
l, err := LoadLedger(filepath.Join(dataDir, "ledger.json"))
must(err)
names := []string{"stranger", "regular", "trusted"}
show := func(title string, m map[string]*Record) {
fmt.Println(title)
keys := make([]string, 0, len(m))
@@ -235,7 +176,7 @@ func cmdTrust() {
}
for _, k := range keys {
r := m[k]
fmt.Printf(" %-28s %-9s +%d -%d streak %d\n", k, names[r.Level], r.Approved, r.Rejected, r.Streak)
fmt.Printf(" %-28s %-9s +%d -%d streak %d\n", k, levelNames[r.Level], r.Approved, r.Rejected, r.Streak)
}
}
show("Crows", l.Crows)