diff --git a/.gitignore b/.gitignore index 518d877..36921d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.crows.md node_modules/ dist/ +/somanycrows diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..24dca37 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,40 @@ +# Events: the shared contract + +The Go server emits these over Server-Sent Events (SSE). The web UI turns them into Rive state machine inputs. Both sides build against this file; change it first, then the code. + +Each SSE message: `event: ` and `data: `. Every payload includes `list_id` and `ts` (RFC 3339). + +| Event | Payload (besides list_id, ts) | Meaning | +|---|---|---| +| `list.started` | `items: [{item_id, text}]`, `requester` | Flock accepted a list | +| `item.triaged` | `item_id`, `mode` (solo/split/murder), `task_type`, `subtasks[]`, `reason`, `needs_approval` (bool) | Sentry made its call | +| `crow.flying` | `item_id`, `crow` | A crow (or Sentry, Elder) took off | +| `crow.landed` | `item_id`, `crow`, `ok` (bool), `tokens`, `error?` | A crow came back, or didn't | +| `item.answered` | `item_id`, `answer`, `crows[]`, `needs_approval` (bool) | Result ready | +| `verdict` | `item_id`, `target` (triage/output), `verdict` (approved/rejected/auto/timeout) | Judgment recorded | +| `trust.changed` | `holder` (crow name or "flock"), `task_type`, `from`, `to` (0 stranger, 1 regular, 2 trusted) | Level up or down | +| `item.skipped` | `item_id`, `reason` | Item dropped (rejected triage, budget, failure) | +| `requester.pranked` | `message`, `cooldown_s` | Abuse response | +| `list.finished` | `tokens_used`, `token_budget` | Done | + +## HTTP API + +| Method | Path | Body / query | Returns | +|---|---|---|---| +| POST | `/api/lists` | `{"text": "", "requester": ""}` | `{"list_id"}`, or 429 with `{"message","cooldown_s"}` when pranked | +| GET | `/api/events` | `?list_id=` | SSE stream | +| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 | +| GET | `/api/trust` | | ledger JSON | + +Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe. + +## Settled details + +- `item_id` is `-`, numbered from 1 in list order. +- `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. +- `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. diff --git a/README.md b/README.md index d9b7ab5..8fecdf1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,26 @@ ANTHROPIC_API_KEY=... ./crows run todo.txt # real crows (Claude) Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; state lives in `.crows/`. +## Web server + +``` +./crows serve # localhost:8080; API + Server-Sent Events (SSE); Ctrl-C to stop +``` + +The server runs the same pipeline as `crows run`, but items fly in parallel and approvals come over HTTP. `EVENTS.md` is the contract for events and the API: + +``` +curl -XPOST localhost:8080/api/lists -d '{"text":"- [ ] What is a crow","requester":"me"}' +curl -N "localhost:8080/api/events?list_id=" # replays the list's events so far, then streams +curl -XPOST localhost:8080/api/verdicts -d '{"item_id":"-1","target":"triage","approve":true}' +curl localhost:8080/api/trust +``` + +- Item IDs are `-`, numbered from 1 in list order. +- Approvals left unanswered for 10 minutes count as rejected (`verdict: timeout`). `-approval-timeout` changes this for testing. +- A flooding requester gets 429 with `message` and `cooldown_s`; any list of theirs that is still flying also gets `requester.pranked`. +- POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists. + ## How an item flies 1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently). diff --git a/RIVE_CONTRACT.md b/RIVE_CONTRACT.md new file mode 100644 index 0000000..2a46ab5 --- /dev/null +++ b/RIVE_CONTRACT.md @@ -0,0 +1,162 @@ +# Rive contract: the `crows.riv` file + +The animator builds `crows.riv` against this file, and the web code drives it from the events in `EVENTS.md`. Both sides build against this file, so change it first and then the code. Each animation exists only because an `EVENTS.md` event fires it. + +Style: Duolingo-like. Chunky, expressive crows with big squash-and-stretch reactions and readable silhouettes at 96 pixels. The approve and reject buttons stay in HTML for v1 (see "Why buttons stay in HTML"). + +## Rive approach: data binding, not inputs + +Rive now marks state machine inputs as deprecated and recommends data binding for new work ([migration guide](https://rive.app/docs/editor/data-binding/migration-guide), [inputs](https://rive.app/docs/runtimes/inputs)). So this file uses **view models** in place of inputs: + +- A **view model** defines the data shape. A **view model instance** holds the values, one per crow on screen ([view models](https://rive.app/docs/editor/data-binding/view-models.md)). +- Number, Boolean, Trigger, Enum, String and Color properties can drive state machine transition conditions or bind straight to scene elements ([property types](https://rive.app/docs/editor/data-binding/property-types.md), [transitions](https://rive.app/docs/editor/state-machine/transitions)). +- An enum can drive a Solo when its values match the Solo children's **names and order**, and those names must be exported ([enums](https://rive.app/docs/editor/data-binding/enums.md)). +- Layers play at the same time, one state per layer, and the lower layer wins when two layers animate the same property ([layers](https://rive.app/docs/editor/state-machine/layers.md)). Any State reaches a state from wherever the layer is. Exit State stops a layer ([states](https://rive.app/docs/editor/state-machine/states.md)). + +## 1. Artboards + +| Artboard | v1? | Purpose | +|---|---|---| +| `Crow` | yes | One reusable character. It covers all 8 crows plus the Sentry and Elder, using a `role` enum that switches the costume. | +| `Perch` | later | A scene with a branch of crows rendered as an Artboard List of `Crow` items ([lists](https://rive.app/docs/editor/data-binding/lists.md)), plus flock-wide celebrations. | +| `Splat` | later | A full-screen "gift on your windshield" overlay for the prank. | + +In v1, the web page puts one `Crow` canvas per visible crow and lays them out with HTML and CSS. The Sentry and Elder are **variants** of `Crow`, not separate artboards, so the whole cast shares one rig. + +Artboard spec: 256 × 256, transparent background, crow centered with its feet on a baseline 40 pixels from the bottom, so the crows line up in a row. + +## 2. `Crow` artboard + +State machine: `CrowMachine`. View model: `CrowModel`, set as the artboard's default view model. + +### `CrowModel` properties + +| Property | Type | Values / range | Drives | +|---|---|---|---| +| `role` | Enum `Role` | `crow`, `sentry`, `elder` | Solo `Costume` (children named `crow`, `sentry`, `elder`, in that order). The Sentry wears a lookout cap and the Elder wears spectacles and a gray ruff. | +| `name` | String | e.g. `Hugin`, `Sentry` | Text run `nameplate` under the feet | +| `pose` | Enum `Pose` | `perched`, `flying`, `waiting`, `grounded` | `Body` layer | +| `trust` | Number | integer 0–2 (0 stranger, 1 regular, 2 trusted) | `Badge` layer | +| `cheer` | Trigger | | `Reaction` layer: happy hop with a wing pump | +| `sulk` | Trigger | | `Reaction` layer: droop, single feather falls. Sad, never angry. | +| `levelUp` | Trigger | | `Reaction` layer: the level-up celebration (spin, confetti, badge shine). It's the biggest moment in the file. | +| `levelDown` | Trigger | | `Reaction` layer: badge dims, sheepish shrug | +| `prank` | Trigger | | `Reaction` layer: mischievous grin, flies off frame, a "plop" sound cue, returns smug | + +The 8 crows tell themselves apart by `name` in v1. A `plumage` Color property for per-crow tints is later polish. + +### Layers, states and transitions + +**Layer `Body`**, a loop that's always running: + +| State | Loop | Notes | +|---|---|---| +| `perched` | yes | Entry state. Idle breathing, blinking, a head tilt every few seconds. | +| `flying` | yes | Wings flapping, a slight bob. It reads as "working". | +| `waiting` | yes | Holds a rolled scroll up toward the viewer and taps a foot. It reads as "your turn". | +| `grounded` | yes | Sitting dazed with stars circling. It means failure or timeout. | + +Transitions: Any State → each state when `pose` equals that value, with a duration of 200 milliseconds (ms). There's no Exit Time, so a pose change is instant to read. + +**Layer `Reaction`**, one-shots on top of `Body`: + +- Entry → `none`, an empty state. +- Any State → `cheer`, `sulk`, `levelUp`, `levelDown` or `prank` on the trigger of the same name. +- Each one-shot → `none` with Exit Time at 100%. +- Lengths: `cheer` and `sulk` at most 0.8 seconds, `levelUp` at most 2 seconds, `levelDown` at most 1 second, `prank` at most 2.5 seconds. +- Animate only what `Body` doesn't need to own (face, wings, effects), or put `Reaction` below `Body` in the layer list so the reaction wins while it plays. + +**Layer `Badge`**: + +- Any State → `badge0`, `badge1` or `badge2` when `trust` equals 0, 1 or 2, with a duration of 150 ms. +- `badge0` shows no badge, `badge1` a bronze chest feather, `badge2` a gold chest feather with a gentle shimmer loop. + +## 3. Event → animation mapping + +The web code keeps a map from `item_id` to `crows[]` (from `item.answered`) so that it can aim `verdict` at the right crows. The `crow` field values `Sentry` and `Elder` select the instances with `role` set to `sentry` and `elder`. + +| `EVENTS.md` event | Condition | Instance(s) | Set | +|---|---|---|---| +| `list.started` | | Sentry | Create and show the Sentry. `pose = perched`. | +| `item.triaged` | `needs_approval` true | Sentry | `pose = waiting` | +| `item.triaged` | `needs_approval` false | | No animation. The HTML shows the mode and reason. | +| `crow.flying` | | `crow` (create on first sight) | `pose = flying` | +| `crow.landed` | `ok` true | `crow` | `pose = perched` | +| `crow.landed` | `ok` false | `crow` | `pose = grounded` | +| `item.answered` | `needs_approval` true | each of `crows[]` | `pose = waiting` | +| `item.answered` | `needs_approval` false | | No animation. A `verdict: auto` follows. | +| `verdict` | `target` triage, `approved` | Sentry | `cheer`, then `pose = perched` | +| `verdict` | `target` triage, `rejected` or `timeout` | Sentry | `sulk`, then `pose = perched` | +| `verdict` | `target` triage, `auto` | | No animation. A trusted Sentry just keeps going. | +| `verdict` | `target` output, `approved` or `auto` | each of the item's `crows[]` | `cheer`, then `pose = perched` | +| `verdict` | `target` output, `rejected` or `timeout` | each of the item's `crows[]` | `sulk`, then `pose = perched` | +| `trust.changed` | `holder` is a crow, `to` > `from` | `holder` | `trust = to`, then `levelUp` | +| `trust.changed` | `holder` is a crow, `to` < `from` | `holder` | `trust = to`, then `levelDown` | +| `trust.changed` | `holder` is `flock` | | No animation in v1 (HTML banner). Later: a `Perch` celebration. | +| `item.skipped` | | | No animation. The HTML strikes the item through, and any instance still `flying` for that item goes to `pose = perched`. | +| `requester.pranked` | | one random crow on screen, or the Sentry if it's alone | `prank`. Later: the `Splat` overlay. The HTML shows `message` and a `cooldown_s` countdown. | +| `list.finished` | | every crow on screen | `pose = perched`, then `cheer` | + +Two web-side notes: + +- **Pranks.** `POST /api/lists` answers a pranked requester with a 429 error and no `list_id`, so there may be no event stream to deliver `requester.pranked`. The web code treats that 429 response the same as the event and fires `prank` on the Sentry. +- **Badges.** `trust` is per crow *and* per task type, so a badge shows the level for the task type of the item the crow is working on now. The web code seeds `trust` from `GET /api/trust` when it creates an instance, and updates it on `trust.changed` only when `task_type` matches. + +## 4. Naming conventions + +The web code matches these strings exactly, so they're case-sensitive. + +| Thing | Case | Names | +|---|---|---| +| File | lowercase | `crows.riv` | +| Artboards | PascalCase | `Crow`, `Perch`, `Splat` | +| State machines | PascalCase + `Machine` | `CrowMachine`, `PerchMachine` | +| View models | PascalCase + `Model` | `CrowModel` | +| Enum types | PascalCase | `Role`, `Pose` | +| Enum values | lowercase, one word, matching server strings where they exist | `crow`, `sentry`, `elder`, `perched`, `flying`, `waiting`, `grounded` | +| Properties (including triggers) | camelCase | `role`, `name`, `pose`, `trust`, `cheer`, `sulk`, `levelUp`, `levelDown`, `prank` | +| States and layers | camelCase states, PascalCase layers | `perched`, `badge2`, `Body`, `Reaction`, `Badge` | +| Exported objects | as listed | Solo `Costume` (children `crow`, `sentry`, `elder`), text run `nameplate` | + +The web code binds one blank instance per crow, using API names from the [web data binding docs](https://rive.app/docs/runtimes/web/data-binding): + +```js +const r = new rive.Rive({ src: "crows.riv", canvas, artboard: "Crow", + stateMachines: "CrowMachine", autoplay: true, onLoad: () => { + const vmi = r.viewModelByName("CrowModel").instance(); + r.bindViewModelInstance(vmi); + vmi.enum("role").value = "crow"; + vmi.string("name").value = "Hugin"; + vmi.number("trust").value = 1; + vmi.enum("pose").value = "flying"; // crow.flying + vmi.trigger("cheer").trigger(); // verdict approved + } }); +``` + +Keep `autoBind` off. Every crow needs its own instance, or setting one crow's `pose` would move them all. + +## 5. v1 minimum vs. later polish + +**v1 minimum**, the smallest set that makes the web UI work: + +1. The `Crow` artboard, the `CrowMachine` state machine and the `CrowModel` view model, named exactly as in section 4. +2. The `role` Solo with all three costumes. The Sentry and Elder must read as different from the crows at a glance. +3. The `name` text run. +4. The `Body` layer with all four `pose` states. +5. The `Reaction` layer with `cheer`, `sulk` and `levelUp`. +6. The `Badge` layer with all three `trust` states. + +If `levelDown` or `prank` doesn't exist yet, the web code falls back to `sulk` and `cheer`, so they can ship a beat later without breaking anything. + +**Later polish**, in rough order: + +1. `levelDown` and `prank` reactions. +2. A `plumage` Color property so each of the 8 crows has a distinct tint. +3. The `Splat` artboard for the windshield gift. +4. The `Perch` scene: an Artboard List of crows, murder huddles and split formations, and a flock `levelUp` on `trust.changed` with `holder` set to `flock`. +5. A streak ring, a Duolingo-style progress bar toward promotion at 10 clean approvals. `EVENTS.md` carries no streak count today, so this needs a `streak` field added to `verdict` or `trust.changed` first. +6. A Rive-drawn approve or reject button, if HTML buttons ever feel flat. + +## Why buttons stay in HTML + +Approval is the safety gate, and the server treats anything other than an explicit yes as a no. Real `