commit 7ae7f670ff13c5ab42c314bbe27e80f607130f2e Author: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Tue Oct 27 08:16:58 2026 -0600 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 Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..518d877 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.crows/ +/crows +*.crows.md +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9b7ab5 --- /dev/null +++ b/README.md @@ -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. diff --git a/brain.go b/brain.go new file mode 100644 index 0000000..9a51331 --- /dev/null +++ b/brain.go @@ -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 +} diff --git a/flightlog.go b/flightlog.go new file mode 100644 index 0000000..b6cdcb8 --- /dev/null +++ b/flightlog.go @@ -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() } diff --git a/flock.go b/flock.go new file mode 100644 index 0000000..fa01e10 --- /dev/null +++ b/flock.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61dcd86 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module somanycrows + +go 1.24 diff --git a/guardrails.go b/guardrails.go new file mode 100644 index 0000000..08b58c1 --- /dev/null +++ b/guardrails.go @@ -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, + } +} diff --git a/ledger.go b/ledger.go new file mode 100644 index 0000000..d0c060e --- /dev/null +++ b/ledger.go @@ -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)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d495a7f --- /dev/null +++ b/main.go @@ -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 [-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) + } +} diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..e72d96a --- /dev/null +++ b/todo.txt @@ -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