So Many Crows v1: Sentry triage, crows, guardrails, trust ledger

Go command-line tool. Sentry classifies each todo item as solo, split,
or murder; text-only crows fly under budget, timeout, and flock caps;
trust is earned per crow and per task type; abusive requesters get a
mild, fading cooldown. Runs offline with a mock brain.

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:16:58 -06:00
commit 7ae7f670ff
10 files changed
+999

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.crows/
/crows
*.crows.md
node_modules/
dist/
+56
View File
@@ -0,0 +1,56 @@
# So Many Crows
A flock of AI agents. Hand it a todo list; a Sentry crow triages each item, crows fly, and trust is earned over time.
Version one: command-line, text-only crows. No web, files, or APIs until each has guardrails.
## Run
```
go build -o crows .
./crows run todo.txt # offline mock brain (no key needed)
ANTHROPIC_API_KEY=... ./crows run todo.txt # real crows (Claude)
./crows trust # trust ledger
./crows log -n 20 # flight log
```
Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; state lives in `.crows/`.
## How an item flies
1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently).
2. Split and murder results go to the **Elder**, who merges them and flags disagreement.
3. You approve or reject, depending on trust.
## Trust ladder
| Level | Triage | Output |
|----------|-------------------|--------------------------------|
| Stranger | you approve | you approve |
| Regular | you approve | auto-accepted, marked unreviewed |
| Trusted | runs on its own | runs on its own |
- Promotion: 10 clean approvals in a row (the flock as a whole needs 20).
- Demotion: one rejection drops a level.
- A crow's level blends its own record with the flock's record for that task type, so new crows start on the flock's reputation.
- The Sentry earns triage trust separately, per task type.
- The least-trusted crow on a job sets the approval bar.
## Guardrails (`guardrails.go`)
| Guardrail | Default |
|---|---|
| Crows in flight | 5 |
| Murder size | 3 |
| Items per list | 20 |
| Subtasks per split (depth 1, never re-split) | 4 |
| Tokens per item / per list | 20,000 / 100,000 |
| Crow timeout | 60s |
| Requests per minute per requester | 5 |
| Cooldown | 30s, doubling per strike, max 1h |
Fail-safe: any answer other than `y`, including no input, is a no.
## Abuse
Flooding the flock earns a mild prank message and a cooldown. Strikes fade one per clean day. Nothing destructive, nothing public.
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
// Brain is whatever model the crows think with.
type Brain interface {
Name() string
Ask(ctx context.Context, system, prompt string) (text string, tokens int, err error)
}
// NewBrain uses Claude when ANTHROPIC_API_KEY is set, otherwise an offline mock.
func NewBrain() Brain {
key := os.Getenv("ANTHROPIC_API_KEY")
if key == "" {
return MockBrain{}
}
model := os.Getenv("CROWS_MODEL")
if model == "" {
model = "claude-sonnet-5-5"
}
return &AnthropicBrain{key: key, model: model, client: &http.Client{}}
}
type AnthropicBrain struct {
key, model string
client *http.Client
}
func (b *AnthropicBrain) Name() string { return "Claude (" + b.model + ")" }
func (b *AnthropicBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
body, _ := json.Marshal(map[string]any{
"model": b.model,
"max_tokens": 2048,
"system": system,
"messages": []map[string]string{{"role": "user", "content": prompt}},
})
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("x-api-key", b.key)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("content-type", "application/json")
resp, err := b.client.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("model API returned %d: %s", resp.StatusCode, raw)
}
var out struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", 0, err
}
var sb strings.Builder
for _, c := range out.Content {
if c.Type == "text" {
sb.WriteString(c.Text)
}
}
return sb.String(), out.Usage.InputTokens + out.Usage.OutputTokens, nil
}
// MockBrain lets the whole flow run offline with no API key.
type MockBrain struct{}
func (MockBrain) Name() string { return "offline mock" }
func (MockBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) {
if err := ctx.Err(); err != nil {
return "", 0, err
}
tokens := (len(system)+len(prompt))/4 + 40
switch {
case strings.Contains(system, markerSentry):
return mockTriage(after(prompt, "ITEM:")), tokens, nil
case strings.Contains(system, markerElder):
return "[mock] Elder's reconciled answer from " + fmt.Sprint(strings.Count(prompt, "--- ")) + " reports.", tokens, nil
default:
return "[mock] Handled: " + after(prompt, "TASK:"), tokens, nil
}
}
func after(s, marker string) string {
if i := strings.LastIndex(s, marker); i >= 0 {
return strings.TrimSpace(s[i+len(marker):])
}
return strings.TrimSpace(s)
}
func mockTriage(item string) string {
l := strings.ToLower(item)
tt := "other"
switch {
case hasAny(l, "find", "look up", "what is", "who ", "when "):
tt = "retrieval"
case hasAny(l, "write", "draft"):
tt = "writing"
case hasAny(l, "compare", "analyze", "should"):
tt = "analysis"
}
t := Triage{Mode: "solo", TaskType: tt, Reason: "simple, one crow can handle it"}
if parts := strings.Split(item, " and "); len(parts) > 1 {
t = Triage{Mode: "split", TaskType: tt, Subtasks: parts, Reason: "multiple distinct parts"}
} else if hasAny(l, "should", "decide", "best") {
t = Triage{Mode: "murder", TaskType: tt, Reason: "judgment call, worth several opinions"}
}
b, _ := json.Marshal(t)
return string(b)
}
func hasAny(s string, subs ...string) bool {
for _, x := range subs {
if strings.Contains(s, x) {
return true
}
}
return false
}
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// Event is one line in the flight log: every triage, flight and verdict.
type Event struct {
Time time.Time `json:"ts"`
Kind string `json:"kind"`
Requester string `json:"requester,omitempty"`
Item string `json:"item,omitempty"`
Crow string `json:"crow,omitempty"`
Mode string `json:"mode,omitempty"`
TaskType string `json:"task_type,omitempty"`
Tokens int `json:"tokens,omitempty"`
Ms int64 `json:"ms,omitempty"`
Verdict string `json:"verdict,omitempty"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
Note string `json:"note,omitempty"`
}
type FlightLog struct {
mu sync.Mutex
f *os.File
}
func OpenFlightLog(path string) (*FlightLog, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
return &FlightLog{f: f}, nil
}
func (l *FlightLog) Write(e Event) {
l.mu.Lock()
defer l.mu.Unlock()
e.Time = time.Now()
b, _ := json.Marshal(e)
l.f.Write(append(b, '\n'))
}
func (l *FlightLog) Close() error { return l.f.Close() }
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"time"
)
const (
markerSentry = "You are the Sentry"
markerElder = "You are the Elder"
sentrySystem = markerSentry + ` of a flock of AI agents called crows.
Classify one todo item. Reply with JSON only:
{"mode":"solo|split|murder","task_type":"retrieval|writing|analysis|other","subtasks":["..."],"reason":"one short sentence"}
- solo: simple; one crow can answer it.
- split: large; break into 2-4 independent subtasks (fill "subtasks").
- murder: ambiguous or high-stakes; several crows answer independently, then reconcile.
Crows are text-only: no web, no files, no tools.`
crowSystem = `You are a crow in a flock of AI agents. Complete the task concisely.
You are text-only: no web, no files, no tools. If the task needs something you cannot do, say so plainly instead of guessing.`
elderSystem = markerElder + ` crow. Several crows reported on one task.
Merge their reports into one answer. Where they disagree, say so and say which view is better supported. Be concise.`
)
var roster = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet"}
type Triage struct {
Mode string `json:"mode"`
TaskType string `json:"task_type"`
Subtasks []string `json:"subtasks,omitempty"`
Reason string `json:"reason"`
}
type Outcome struct {
Answer string
Crows []string // everyone accountable for the result
}
type Budget struct {
mu sync.Mutex
name string
limit, spent int
}
func (b *Budget) Check() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.spent >= b.limit {
return fmt.Errorf("%s budget spent (%d/%d tokens)", b.name, b.spent, b.limit)
}
return nil
}
func (b *Budget) Add(n int) {
b.mu.Lock()
b.spent += n
b.mu.Unlock()
}
type Flock struct {
brain Brain
g Guardrails
log *FlightLog
sem chan struct{}
mu sync.Mutex
next int
}
func NewFlock(brain Brain, g Guardrails, log *FlightLog) *Flock {
return &Flock{brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight)}
}
// pick rotates through the roster so every crow gets a chance to build a record.
func (f *Flock) pick(n int) []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, n)
for i := range out {
out[i] = roster[f.next%len(roster)]
f.next++
}
return out
}
// ask is the only way any crow touches the model: budget, flock cap, timeout, log.
func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ...*Budget) (string, error) {
for _, b := range budgets {
if err := b.Check(); err != nil {
f.log.Write(Event{Kind: "grounded", Crow: crow, Error: err.Error()})
return "", err
}
}
f.sem <- struct{}{}
defer func() { <-f.sem }()
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
defer cancel()
start := time.Now()
text, tokens, err := f.brain.Ask(cctx, system, prompt)
for _, b := range budgets {
b.Add(tokens)
}
if errors.Is(err, context.DeadlineExceeded) {
err = fmt.Errorf("%s timed out after %s and was dropped", crow, f.g.CrowTimeout)
}
e := Event{Kind: "flight", Crow: crow, Tokens: tokens, Ms: time.Since(start).Milliseconds(), Output: text}
if err != nil {
e.Error = err.Error()
}
f.log.Write(e)
return text, err
}
func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Triage, error) {
text, err := f.ask(ctx, "Sentry", sentrySystem, "ITEM: "+item, budgets...)
if err != nil {
return Triage{}, err
}
var t Triage
if i, j := strings.Index(text, "{"), strings.LastIndex(text, "}"); i < 0 || j < i || json.Unmarshal([]byte(text[i:j+1]), &t) != nil {
t = Triage{Mode: "solo", TaskType: "other", Reason: "Sentry's call was unreadable; defaulting to solo"}
}
t.Mode = strings.ToLower(strings.TrimSpace(t.Mode))
t.TaskType = strings.ToLower(strings.TrimSpace(t.TaskType))
switch t.TaskType {
case "retrieval", "writing", "analysis", "other":
default:
t.TaskType = "other"
}
switch t.Mode {
case "split":
if len(t.Subtasks) > f.g.MaxSubtasks {
t.Subtasks = t.Subtasks[:f.g.MaxSubtasks]
}
if len(t.Subtasks) < 2 {
t.Mode, t.Subtasks = "solo", nil
}
case "murder":
t.Subtasks = nil
default:
t.Mode, t.Subtasks = "solo", nil
}
return t, nil
}
func (f *Flock) Execute(ctx context.Context, item string, t Triage, budgets ...*Budget) (Outcome, error) {
switch t.Mode {
case "split":
// Split depth is 1: subtasks go straight to crows, never back to the Sentry.
crows := f.pick(len(t.Subtasks))
reports, err := f.fanOut(ctx, crows, t.Subtasks, budgets...)
if err != nil {
return Outcome{}, err
}
return f.reconcile(ctx, item, crows, t.Subtasks, reports, budgets...)
case "murder":
n := min(f.g.MurderSize, f.g.MaxCrowsInFlight)
crows := f.pick(n)
tasks := make([]string, n)
for i := range tasks {
tasks[i] = item
}
reports, err := f.fanOut(ctx, crows, tasks, budgets...)
if err != nil {
return Outcome{}, err
}
return f.reconcile(ctx, item, crows, tasks, reports, budgets...)
default:
c := f.pick(1)[0]
ans, err := f.ask(ctx, c, crowSystem, "TASK: "+item, budgets...)
return Outcome{Answer: ans, Crows: []string{c}}, err
}
}
func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*Budget) ([]string, error) {
reports := make([]string, len(tasks))
errs := make([]error, len(tasks))
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func() {
defer wg.Done()
reports[i], errs[i] = f.ask(ctx, crows[i], crowSystem, "TASK: "+tasks[i], budgets...)
}()
}
wg.Wait()
ok := 0
for i, err := range errs {
if err != nil {
reports[i] = "(no report: " + err.Error() + ")"
} else {
ok++
}
}
if ok == 0 {
return nil, fmt.Errorf("no crow came back: %w", errs[0])
}
return reports, nil
}
func (f *Flock) reconcile(ctx context.Context, item string, crows, tasks, reports []string, budgets ...*Budget) (Outcome, error) {
var sb strings.Builder
fmt.Fprintf(&sb, "ORIGINAL TASK: %s\n\n", item)
for i := range reports {
fmt.Fprintf(&sb, "--- %s on %q:\n%s\n\n", crows[i], tasks[i], reports[i])
}
ans, err := f.ask(ctx, "Elder", elderSystem, sb.String(), budgets...)
return Outcome{Answer: ans, Crows: append(append([]string{}, crows...), "Elder")}, err
}
+3
View File
@@ -0,0 +1,3 @@
module somanycrows
go 1.24
+38
View File
@@ -0,0 +1,38 @@
package main
import "time"
// Guardrails exist before capabilities. Version one crows are text-only:
// no web, no files, no APIs. Each future capability gets its own guardrail
// block here before it is switched on.
type Guardrails struct {
MaxCrowsInFlight int // flock cap: concurrent crow calls
MurderSize int // crows that attack one item together
MaxItemsPerList int // todo list size cap
MaxItemChars int // per-item length cap
MaxSubtasks int // split width; subtasks never split again (depth 1)
TokenBudgetPerItem int // cost ceiling per todo item
TokenBudgetPerList int // cost ceiling per list
CrowTimeout time.Duration // a stalled crow is dropped
PromoteAfter int // clean streak needed to earn the next trust level
MaxRequestsPerMin int // requester rate cap
BaseCooldown time.Duration // first offense cooldown; doubles per strike
MaxCooldown time.Duration
}
func DefaultGuardrails() Guardrails {
return Guardrails{
MaxCrowsInFlight: 5,
MurderSize: 3,
MaxItemsPerList: 20,
MaxItemChars: 2000,
MaxSubtasks: 4,
TokenBudgetPerItem: 20_000,
TokenBudgetPerList: 100_000,
CrowTimeout: 60 * time.Second,
PromoteAfter: 10,
MaxRequestsPerMin: 5,
BaseCooldown: 30 * time.Second,
MaxCooldown: time.Hour,
}
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"fmt"
"math"
"math/rand/v2"
"os"
"path/filepath"
"sync"
"time"
)
// Trust levels.
const (
Stranger = 0 // you approve triage and output
Regular = 1 // output auto-accepted, marked unreviewed
Trusted = 2 // runs on its own
)
// Record is a track record for one holder (a crow, or the whole flock) on one task type.
type Record struct {
Approved int `json:"approved"`
Rejected int `json:"rejected"`
Streak int `json:"streak"`
Level int `json:"level"`
}
func (r *Record) total() int { return r.Approved + r.Rejected }
// apply: slow to earn, quick to lose.
func (r *Record) apply(ok bool, promoteAfter int) {
if ok {
r.Approved++
r.Streak++
if r.Streak >= promoteAfter && r.Level < Trusted {
r.Level++
r.Streak = 0
}
return
}
r.Rejected++
r.Streak = 0
if r.Level > Stranger {
r.Level--
}
}
// Requester trust: abuse earns a mild, fading penalty.
type Requester struct {
Strikes int `json:"strikes"`
LastStrike time.Time `json:"last_strike"`
CooldownUntil time.Time `json:"cooldown_until"`
Recent []time.Time `json:"recent"`
}
type Ledger struct {
Crows map[string]*Record `json:"crows"` // key: crow|task_type
Flock map[string]*Record `json:"flock"` // key: task_type
Requesters map[string]*Requester `json:"requesters"`
path string
mu sync.Mutex
}
func LoadLedger(path string) (*Ledger, error) {
l := &Ledger{path: path}
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(data, l); err != nil {
return nil, fmt.Errorf("ledger is unreadable: %w", err)
}
}
if l.Crows == nil {
l.Crows = map[string]*Record{}
}
if l.Flock == nil {
l.Flock = map[string]*Record{}
}
if l.Requesters == nil {
l.Requesters = map[string]*Requester{}
}
return l, nil
}
func (l *Ledger) Save() error {
l.mu.Lock()
defer l.mu.Unlock()
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
return err
}
tmp := l.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, l.path)
}
func get(m map[string]*Record, k string) *Record {
r := m[k]
if r == nil {
r = &Record{}
m[k] = r
}
return r
}
// Level blends a crow's own record with the flock's record for the task type.
// A new crow flies on the flock's reputation; its own record takes over as it grows.
func (l *Ledger) Level(crow, taskType string) int {
l.mu.Lock()
defer l.mu.Unlock()
own := get(l.Crows, crow+"|"+taskType)
flock := get(l.Flock, taskType)
w := float64(own.total()) / float64(own.total()+5)
return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level)))
}
// Judge records a verdict for every crow involved, and once for the flock.
// The flock needs twice the streak to level up.
func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) {
l.mu.Lock()
defer l.mu.Unlock()
for _, c := range crows {
get(l.Crows, c+"|"+taskType).apply(ok, promoteAfter)
}
get(l.Flock, taskType).apply(ok, promoteAfter*2)
}
func (l *Ledger) requester(id string) *Requester {
r := l.Requesters[id]
if r == nil {
r = &Requester{}
l.Requesters[id] = r
}
return r
}
// Admit checks a requester at the door: cooldowns, rate cap, and fading grudges.
func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string) {
l.mu.Lock()
defer l.mu.Unlock()
r := l.requester(id)
// Grudges fade: one strike forgiven per clean day.
if r.Strikes > 0 {
if days := int(now.Sub(r.LastStrike) / (24 * time.Hour)); days > 0 {
r.Strikes = max(0, r.Strikes-days)
r.LastStrike = r.LastStrike.Add(time.Duration(days) * 24 * time.Hour)
}
}
if now.Before(r.CooldownUntil) {
return false, prank(r.CooldownUntil.Sub(now))
}
kept := r.Recent[:0]
for _, t := range r.Recent {
if now.Sub(t) < time.Minute {
kept = append(kept, t)
}
}
r.Recent = kept
if len(r.Recent) >= g.MaxRequestsPerMin {
return false, prank(strike(r, now, g))
}
r.Recent = append(r.Recent, now)
return true, ""
}
func strike(r *Requester, now time.Time, g Guardrails) time.Duration {
r.Strikes++
r.LastStrike = now
cd := g.BaseCooldown
for i := 1; i < r.Strikes && cd < g.MaxCooldown; i++ {
cd *= 2
}
cd = min(cd, g.MaxCooldown)
r.CooldownUntil = now.Add(cd)
return cd
}
var pranks = []string{
"A crow has left a small gift on your windshield. The flock will hear you again in %s.",
"The flock turns its back on you. Try again in %s.",
"Something shiny of yours has gone missing. Come back in %s.",
"Caw. (Translation: not for another %s.)",
}
func prank(d time.Duration) string {
return fmt.Sprintf(pranks[rand.IntN(len(pranks))], d.Round(time.Second))
}
+286
View File
@@ -0,0 +1,286 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"time"
)
const dataDir = ".crows"
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "run":
cmdRun(os.Args[2:])
case "trust":
cmdTrust()
case "log":
cmdLog(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, `So Many Crows
crows run <todo.txt> [-as name] hand the flock a todo list
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")
var pos []string
for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) {
pos = append(pos, fs.Arg(0))
args = fs.Args()[1:]
}
if len(pos) != 1 {
usage()
}
todoPath := pos[0]
g := DefaultGuardrails()
ledger, err := LoadLedger(filepath.Join(dataDir, "ledger.json"))
must(err)
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)
}
}
if ok, msg := ledger.Admit(*as, time.Now(), g); !ok {
fmt.Println(msg)
flog.Write(Event{Kind: "denied", Requester: *as, Note: msg})
save()
os.Exit(1)
}
items, err := readTodo(todoPath, 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()
os.Exit(1)
}
brain := NewBrain()
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))})
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)
}
func readTodo(path string, g Guardrails) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var items []string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
for _, p := range []string{"- [ ]", "- [x]", "-", "*"} {
line = strings.TrimSpace(strings.TrimPrefix(line, p))
}
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if len(line) > g.MaxItemChars {
return nil, fmt.Errorf("an item is %d characters; the cap is %d", len(line), g.MaxItemChars)
}
items = append(items, line)
}
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"))
for _, r := range results {
if r.Skipped != "" {
fmt.Fprintf(&sb, "- [ ] %s\n - skipped: %s\n\n", r.Item, r.Skipped)
continue
}
box := "x"
if r.Verdict == "rejected" {
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, " > "))
}
return os.WriteFile(path, []byte(sb.String()), 0o644)
}
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))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
fmt.Println(" (none yet)")
}
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)
}
}
show("Crows", l.Crows)
show("\nFlock", l.Flock)
fmt.Println("\nRequesters")
for id, r := range l.Requesters {
cd := "none"
if time.Now().Before(r.CooldownUntil) {
cd = time.Until(r.CooldownUntil).Round(time.Second).String()
}
fmt.Printf(" %-20s strikes %d · cooldown %s\n", id, r.Strikes, cd)
}
}
func cmdLog(args []string) {
fs := flag.NewFlagSet("log", flag.ExitOnError)
n := fs.Int("n", 20, "entries to show")
fs.Parse(args)
data, err := os.ReadFile(filepath.Join(dataDir, "flight.jsonl"))
must(err)
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) > *n {
lines = lines[len(lines)-*n:]
}
fmt.Println(strings.Join(lines, "\n"))
}
func indent(s, prefix string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := range lines {
lines[i] = prefix + lines[i]
}
return strings.Join(lines, "\n")
}
func defaultRequester() string {
if u := os.Getenv("USER"); u != "" {
return u
}
return "local"
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
+4
View File
@@ -0,0 +1,4 @@
# Sample list for the flock
- [ ] What is the difference between a crow and a raven
- [ ] Draft a tagline for So Many Crows and write a one-line product description
- [ ] Should a new agent framework launch with a CLI or a web app first