Coordinate a fleet of cheap parallel coding agents from any ticket source (Notion, ClickUp, Obsidian vault boards, or a local folder of markdown files). Groom…

<img src="docs/assets/svg/ember.svg" width="820" alt="Brigade — let your agents cook">
A Claude Code plugin that turns your session into the planner of a cheap parallel coding fleet.
Point it at a task board — Notion, ClickUp, an Obsidian vault, or a folder of markdown files — and pick a ticket. Scouts research the codebase in parallel, the planner breaks the ticket into small disjoint work packets, cooks implement them in isolated git worktrees, an inspector adversarially reviews every diff before it lands, and you get one PR to review.
You have exactly two jobs: approve the decomposition, and review the PR.
claude plugin marketplace add jimador/brigade
claude plugin install brigade@brigade
Working from a local clone, pass the clone's path to marketplace add instead.
Then, in a repo: set up brigade, and once that is done, work my board.
Quickstart · Usage · Configuration · Architecture · All docs
The expensive model is the scarce resource. Brigade spends it on the one thing only it can do — decomposition — and pushes everything else onto cheap subagents.
That only works if the packets are good. A weak model told to "add rate limiting to login" produces garbage; the same model handed three named files, the contracts it needs pasted in, and the exact command that proves it done produces mergeable code. The granularity rules exist to make cheap execution viable, not to be tidy.
Four design goals, in order:
A dish is one ticket cooked to completion.
<img src="docs/assets/svg/ember-pipeline.svg" width="900" alt="The brigade pipeline: scouts fan out, the planner decomposes, cooks implement in parallel, the inspector gates every diff">
flowchart LR
Board[board / ticket] --> Planner[Planner: plans once]
Planner --> Research[brigade-research: scouts fan out]
Research --> Plan[PLAN.md: DAG of work packets]
Plan --> Execute[brigade-execute: cook, inspect, land]
Execute -->|escalation ladder, circuit breaker| Execute
Execute --> Delivery[delivery branch]
Delivery --> PR[one PR]
PR --> Human[human review]
Human --> Retro[analyst retro]
Retro -->|LEARNINGS + heuristics| Planner
Two deterministic Workflow scripts drive the fleet: brigade-research.js fans questions out to scouts, and brigade-execute.js runs the whole item DAG — worktree creation, the escalation ladder, review, and rebase-and-fast-forward landing. Control flow lives in JavaScript, not in a model deciding what to do next.
State lives in PLAN.md frontmatter and a typed report trail on disk, so any session can resume mid-run and nothing already landed is re-cooked.
Claude and Codex Brigade use the same .brigade wire protocol. They share schemas, configuration, artifact paths, PLAN statuses, branches, worktrees, and reports. An atomic per-dish lease allows different dishes to run concurrently while keeping one writer for the same dish; either runtime can release and hand the dish to the other at a human checkpoint.
| SDLC role | Brigade form |
|---|---|
| requirements | grooming + two-stage grilling |
| analysis | scouts |
| design + design review | decomposition + blind plan check |
| implementation | cooks in worktrees |
| code review | inspector gate |
| integration | serialized rebase + fast-forward-only landing |
| CI runner | the workflow scripts |
| QA | verification gate + per-criterion acceptance pass |
| release | one human-review PR |
| retrospective | analyst |
Pick how much model you buy per dish.
| ★★★ "brigade heavy" | ★★ (default) | ★ "brigade light" | |
|---|---|---|---|
| planning | frontier | opus | sonnet |
| first-attempt cook | heavy cook (sonnet) | cook (haiku) | cook (haiku) |
| scouts per dish | ≤ 6 | ≤ 4 | ≤ 2 |
| plan check | always | on triggers | never |
| analyst retro | every dish (intensive) + every 10 items (standard) | every dish | every 3rd dish |
Say brigade heavy or brigade light for one dish; set tier in config for the repo. Any single row can be decoupled from its tier — see docs/tiers.md.
Long-horizon dispatches — heavy items and rework attempts — carry a working-memory ledger: the packet's constraints held as protected Canon plus the cook's own verified World state, kept in one bounded file per item, inherited across attempts, audited by the Inspector. Small first-attempt items skip it; at that horizon the packet alone is enough. On by default — set workingMemory: false in any config layer to disable. Protocol: skills/brigade/MEMORY.md; adapted from arc-mem (Activation-Ranked Context — governed working memory for LLM agents). The Planner keeps a ledger of its own (state/planner.md), and brigade-status prints its live World state so a resumed session starts from verified facts.
Settings come from four layers, later winning key by key:
built-in defaults
→ ~/.brigade/config.json personal, every repo
→ <repo>/brigade.config.json committed, whole team
→ <repo>/.brigade/config.local.json personal, this repo
Prompt overrides use the same layers but stack instead of replacing, so a repo can tighten a global rule without losing it. Nothing an override says can remove the inspector gate or the evidence requirements.
brigade-config resolve # merged settings, and which layer set each key
brigade-config prompts # prompt-override stacks, by role
brigade-config doctor # validate every layer
Any role's agent is swappable — point models.inspector at your own reviewer and the workflow scripts dispatch it instead. See docs/configuration.md and docs/overrides.md.
<img src="docs/assets/svg/board-demo.svg" width="720" alt="The task board playing one run of a dish: a scout researches, two cooks start, an inspector sends the token bucket back with two findings and its detail box opens, a fresh cook makes a second pass, and both items reach Done as the context meter fills">
Run /brigade-board to open the task board in a pane. While a dish is being worked, the header names the repo and the dish's delivery branch, the ticket's title, and a detail line with the ticket, its kind, how many work items are done and the service tier as Effort: with one to three stars. At the top right, the context meter shows how full the session's context window is, as a percent and a bar that turns amber at half full and red at three quarters.
The board is drawn differently on each surface. In the terminal it is an animated region: sprites walk, and the pointer hovers and clicks. The desktop app does not load a plugin's drawing region today, so there the hooks module draws the board as a picture with the same header, lanes, cards, sprites, messages, learnings and legend. The picture fills the pane: it is drawn about as wide as the pane (96 to 200 columns) and the app scales it to fit, and it only changes when something on the board does. If the terminal's region does not load within 3 seconds, the terminal pane falls back to rows of text; opening the board again gives the region another try. Other surfaces (mobile, the editor's panel) show the board as plain lines.
Below the header, five lanes hold one card for each work item of the dish being worked, titled by the first sentence of the item's goal. The dish being worked is the one the working agent seen most recently is on, so the board follows the main session from one dish to the next; an agent that has been quiet for ten minutes no longer picks the dish. An item goes in the first lane whose rule matches, looking at who is working it now, then at its newest report and verdict, then at the plan's own status:
heavy pill.second pass.sent back · N findings pill, or whose newest report says blocked; the plan's rework and blocked statuses land here too.A lane shows four cards (Done shows its two newest) and counts the rest as +N more; a card an agent is working always shows. When no dish is being worked (no agent is working one, and no plan that changed in the last day has unfinished items), the lanes show the board's tickets instead, sorted into the same five lanes by status, and the header reads Ticket board with the ticket count.
Every agent in the session is a retro pixel sprite, standing on the card it works, or with the crew under the lanes when its card isn't on the board. Every sprite is one row tall, and its colour tells the model: haiku, sonnet, opus or fable, as the legend at the bottom right shows; a finished agent turns grey and a failed one red. Each sprite carries its role mark, name and role (♨ Miso · cook) and an activity line: what its latest tool call does, such as reading gateway.ts, editing bucket.ts or running tests, and finished once it is done. The role shows once the board can tell it, from the agent's type or label or from what it writes (an edit in a worktree makes a cook, a verdict an inspector, a brief a scout); until then it reads agent. A sprite stands still on its card and moves only while it walks there: when its card moves, it walks to the card's new lane, a step every quarter second, never overlapping another sprite or a name line. In the terminal a new sprite walks in from the left edge; in the desktop app it appears in place on its card. In the terminal, point at a sprite for a hover card with its model, item, state, tokens and running time. The desktop picture has no tooltips; the buttons under it open each agent's details. A finished agent stays on the board for two minutes.
The Messages panel shows the newest four messages, made from the notes agents leave in the dish folder: a cook's report reads Miso → inspector: token-bucket ready for review, a failed verdict goes from the inspector back to the cook with its first finding, and a passed verdict, a blocked report, a scout's brief and a plan check go to the planner. The cook or inspector in a message is named when exactly one agent in that role is on the item, and goes by the role otherwise. The Learnings panel lists up to five of the newest learnings in .brigade/LEARNINGS.md: each ## heading, or each bullet of a dated retro section.
In the terminal, click a card, an agent or a message to open a detail box over the board, and click [x] or anywhere off the box to close it. Clicks do not reach the desktop app's picture, so a row of buttons under it opens the detail of a card, an agent or a message, and closes it; the terminal's fallback rows carry the same buttons. A work item's box has its goal, files, dependencies, attempts, newest cook report, newest review with its findings, and every agent on the item; a ticket's box has its title, kind, assignee and goal; an agent's has its model, item, activity, tokens, running time and the tail of its working memory; a message's has its text, its item, the file it came from and the start of that file.
The pane asks its dock for 124 columns, enough for five lanes side by side; in the terminal, below 104 columns the lanes wrap into bands, and below 70 the two panels stack. The board reads the ticket folder .brigade/config.md names, the dish folders under .brigade/dishes/ and .brigade/LEARNINGS.md, and writes no files. It needs a Claude Code build with mods (function hooks).
The demo above is generated by scripts/board-demo, which plays one made-up run through the board's own code and draws every frame with it; the whole run plays in under 30 seconds.
| Path | What |
|---|---|
skills/brigade/SKILL.md | the Planner's router: standing rules, the dish checklist, and pointers into the phase companions |
skills/brigade/DECOMPOSE.md · EXECUTE.md · HANDOFF.md | the phase companions: decomposition rules and the plan check; pre-flight, the execute ledger, stop conditions; the handoff and acceptance pass |
skills/brigade/COORDINATION.md · CONFIG.md | the Claude/Codex dish lease and wire contract; settings layers and prompt overrides |
skills/brigade/SCHEMAS.md | typed artifact registry — every plan, brief, report, and verdict has a fixed envelope and authority rule |
skills/brigade/TIERS.md | service-tier reference and difficult-planning triggers |
skills/brigade/GRAPHITE.md | optional Graphite modes, both off by default |
skills/brigade/sources/ | one adapter per ticket source, plus the four-operation template for writing your own |
skills/brigade/writing/ | writing presets; ste-80.md holds the sentence rules the Planner writes work packets to when the preset is on |
skills/brigade/templates/ | per-repo board config, one example per settings layer, and the work-packet format |
skills/groom/SKILL.md | board-grooming session: cluster, split, merge, sharpen. Never cooks |
agents/ | scout, cook, heavy cook, inspector, analyst, design, designer |
commands/ | /brigade:status, /brigade:config, /brigade:validate, /brigade:tier, /brigade:retro, /brigade:design, /brigade:ui, /brigade:review |
scripts/brigade-status | zero-token dish-state summary; --json for tooling |
scripts/brigade-config | resolves the config layers and prompt-override stacks; doctor validates |
scripts/brigade-coord | atomic per-dish Claude/Codex ownership and handoff leases |
scripts/brigade-validate | zero-token schema conformance checker for dish artifacts |
scripts/brigade-evidence | zero-token verification-scope classifier — stops a targeted pass being read as repo green |
scripts/brigade-bundle | regenerates workflows/brigade-*.js; --check catches drift |
scripts/board-demo | regenerates the board demo in this README from the board's own drawing code; --check catches drift |
workflows/ | the three Workflow scripts — brigade-research.js, brigade-execute.js, brigade-review.js — and the policy consts spliced into them |
hooks/ | SessionStart state injection, a PreToolUse git-hygiene guard, and a SubagentStop artifact-validate gate |
hooks/board/ | the live board pane (/brigade-board): the task board with agents as pixel sprites coloured by model, a context meter, Messages and Learnings panels, and a detail box on click in the terminal, from a button on desktop |
evals/ | claude plugin eval suite: eleven expected-workflow cases with scaffolded fixtures; results stay local |
docs/intent.md, docs/experiments.md | what the plugin optimizes for, and the log of hypotheses tested — result and decision per experiment |
Each artifact can carry its own writing rules: plain sentences in the writing block of any config layer, handed only to the agent that writes that artifact (packet, plan, brief, report, verdict, ticket_comment, pr_body). Rules stack across layers like prompt overrides.
The ste-80 preset holds work packets to short sentences with one instruction each, based on the sentence rules of Simplified Technical English. It is off by default. Turn it on in any layer:
{ "writing": { "preset": "ste-80" } }
brigade-config writing --json prints the resolved block. With the preset on, the Planner writes writing: ste-80 into the plan, and brigade-validate warns on a step sentence over 20 words, a description sentence over 25, a step with more than one instruction, vendor-specific markup, and any word the checks.packet.terms map bans. Misses are warnings only; none fails a plan. Details in skills/brigade/CONFIG.md.
Whether the preset helps is experiment E-004 in docs/experiments.md, run by an operator with the kit in evals/experiments/writing-rules/. Its first run measured ste-80 at 16/18 first-attempt passes against 15/18 for plain packets on claude-haiku, and 18/18 against 18/18 on gpt-5.5, which passed every run in both styles, so the decision is keep watching and the preset stays off by default.
git, node, and python3. jq unlocks brigade-status --json; gh lets brigade open the PR for you. No MCP server is required — an MCP ticket source is used when the session already has one, and falls back to REST or the filesystem otherwise.
Legacy copy install for environments without plugin support: ./install.sh --legacy (./install.sh --uninstall removes it).
Kitchen vocabulary: a dish is one ticket cooked to completion, a cook implements one work packet, the inspector reviews every diff, a scout researches, the analyst runs the retro, the steward handles worktrees and landing, and plating is handoff.
Branches are named for what they deliver, never for the process that made them — no "brigade" in any branch name.
MIT. See LICENSE.
hooks/board/register.tsx 1198 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Detail, Fleet, Learnings, Message, Note, Project, Snapshot, Stage, Weather, WorkLane } from '../../types'
5import { boardDirFrom, laneOf, parseTicket } from './lib/board.mjs'
6import { arrange } from './lib/board-layout.mjs'
7import { draw } from './lib/board-paint.mjs'
8import { pictureOf } from './lib/board-svg.mjs'
9import { safeText } from './lib/canvas.mjs'
10import { agentDetail, cardDetail, messageDetail, projectOf, ticketDetail } from './lib/detail.mjs'
11import { envelope, findingsOf, ledgerTail, learningsFrom, messagesFrom, noteFrom } from './lib/dish.mjs'
12import { activityOf, applyEvent, prune } from './lib/fleet.mjs'
13import { colorOf, ROLES, sizeOf } from './lib/sprites.mjs'
14import { advance } from './lib/stage.mjs'
15import { forecast } from './lib/weather.mjs'
16import { PHASES, pickDish, planItems, ticketCards, toWorkLanes, workCards } from './lib/work.mjs'
17
18const PANE = 'brigade-board'
19// The dock width that fits all five lanes side by side.
20const PANE_COLUMNS = 124
21// How often the board looks at the disk and the context figures again.
22const TICK_MS = 2000
23
24const fleet = atom({ plugin: 'brigade', key: 'fleet' } as const, { agents: {}, order: [] } as Fleet)
25const weather = atom({ plugin: 'brigade', key: 'weather' } as const, null as Weather | null)
26// Which ticket each dish belongs to, by dish folder name. A missing dish is just unknown.
27const dishes = atom({ plugin: 'brigade', key: 'dishes' } as const, {} as Record<string, string>)
28// The header, the five lanes of cards, the Messages and Learnings panels, and the detail box.
29const project = atom({ plugin: 'brigade', key: 'project' } as const, projectOf({ mode: 'tickets', repo: '', count: 0 }) as Project)
30const work = atom({ plugin: 'brigade', key: 'work' } as const, toWorkLanes([]) as WorkLane[])
31const messages = atom({ plugin: 'brigade', key: 'messages' } as const, [] as Message[])
32const learnings = atom({ plugin: 'brigade', key: 'learnings' } as const, { total: 0, lines: [] } as Learnings)
33const detail = atom({ plugin: 'brigade', key: 'detail' } as const, null as Detail | null)
34// Where the sprites stand when the hooks module draws the board itself, which frame a walking one
35// shows, whether the pane is open, and whether the terminal's region reported in or the pane fell
36// back to rows drawn here.
37const STAGE_START: Stage = { positions: {}, frame: 0, open: false, openedAt: null, ready: false, plain: false }
38const stage = atom({ plugin: 'brigade', key: 'stage' } as const, STAGE_START)
39
40// How long a finished agent stays on the board before it leaves.
41const KEEP_MS = 120000
42// The most tool calls we look at per agent while working out who it is. It is generous because
43// a cook explores for a good while before its first write, and an inspector writes its verdict
44// last. An agent that never gives itself away still stops costing anything after this many.
45// Counted per agent id, for the life of the session.
46const TOOL_LOOKS = 400
47const toolLooks = new Map<string, number>()
48
49// What each agent was last seen doing, by agent id, until the next refresh puts it on the roster.
50// A tool call only sets an entry here, so saying what an agent is doing never costs a tool call a
51// state read or write. The Planner's calls carry no agent id and go under 'main'.
52const activities = new Map<string, string>()
53
54// Folder and item names that are safe to put in a path.
55const SLUG = /^[a-z0-9-]+$/
56// Note file names that are safe to put in a path.
57const NOTE_FILE = /^[A-Za-z0-9._-]+\.md$/
58
59// The roster with the Planner on it, under 'main', added now if it isn't there yet.
60const withPlanner = (roster: Fleet, at: number | undefined, model?: string) =>
61 Object.hasOwn(roster.agents, 'main') ? roster : (applyEvent(roster, { type: 'spawn', id: 'main', at, description: 'planner', subagentType: 'planner', model }) as Fleet)
62
63// Applies one roster event to the fleet. This runs on the session's hot path, so a failure
64// here is swallowed: the board missing an event is far better than a tool call failing.
65// With `planner`, the Planner is put on the roster first if it isn't there yet.
66const record = async ($: EngineInterface, event: object, planner = false) => {
67 try {
68 const at = await $.clock.now()
69 await update($, fleet, roster => {
70 const next = planner ? withPlanner(roster, at, (event as { model?: string }).model) : roster
71 return applyEvent(next, { ...event, at }) as Fleet
72 })
73 } catch {
74 // The roster stays as it was.
75 }
76}
77
78// Applies one tool event, but writes the roster only when the event changes it. Most tool calls
79// teach us nothing, and those then cost one read and no redraw. Failures are swallowed, like
80// in record. With `planner`, the Planner is put on the roster first if it isn't there yet, so a
81// main-session call that lands before the session's first step still shows up as the Planner.
82const learn = async ($: EngineInterface, event: { type: 'tool'; id: string; paths: unknown[]; act: object }, planner = false) => {
83 try {
84 const roster = await read($, fleet)
85 const seeded = planner ? withPlanner(roster, undefined) : roster
86 if (same(roster, applyEvent(seeded, event))) return
87 // Only a write needs the time: it is when a newcomer joins the roster.
88 const at = await $.clock.now()
89 await update($, fleet, current => applyEvent(planner ? withPlanner(current, at) : current, { ...event, at }) as Fleet)
90 } catch {
91 // The roster stays as it was.
92 }
93}
94
95// Whether a main-session tool call touches a dish's or a worktree's folder. Only those can move
96// the main session to another dish, and this runs on every one of its calls, so it is kept to
97// a couple of substring tests and reads no state.
98const onBoard = (paths: string[]) =>
99 paths.some(path => path.includes('.brigade/dishes/') || path.includes('.brigade/worktrees/'))
100
101// The folder the board reads `.brigade/` from, without a trailing slash, and its last part, which
102// names the repo. It is the repository's root, because in a git worktree the session's own folder
103// has no `.brigade/`: that folder is untracked and lives only in the main checkout, and for a
104// worktree the engine answers with the main checkout's root. Outside a repository, or when the
105// engine can't say, it is the session's folder, as it always was.
106//
107// The board wants this several times a tick, and asking for the repository can be slow, so the
108// answer is kept for as long as the session's folder stays the same. A failed ask is only kept
109// until the next pass, so a hiccup at the start doesn't leave the board empty for good, and a
110// call that keeps failing still costs one ask a pass at most. The ask itself is kept, not just
111// its answer, so a click that lands while it is out waits for it instead of asking again.
112type RepoAsk = { session: string; pass: number; failed: boolean; root: Promise<string> }
113let repoAsk: RepoAsk | null = null
114// Goes up at the start of every refresh pass.
115let pass = 0
116
117async function rootOf($: EngineInterface) {
118 const session = (await $.session.root()).replace(/[\\/]+$/, '')
119 const kept = repoAsk
120 if (kept !== null && kept.session === session && !(kept.failed && kept.pass !== pass)) return kept.root
121 const ask: RepoAsk = { session, pass, failed: false, root: Promise.resolve(session) }
122 ask.root = repoRootOf($, session, () => {
123 ask.failed = true
124 })
125 repoAsk = ask
126 return ask.root
127}
128
129// The repository's root when the engine gives one, or the session's folder. No repository is an
130// answer; a throw or an answer without a usable root is a failure, reported through `failed`.
131async function repoRootOf($: EngineInterface, session: string, failed: () => void) {
132 try {
133 const repo: unknown = await $.session.repo()
134 if (repo == null) return session
135 const root = isPlain(repo) ? repo.root : undefined
136 if (typeof root === 'string' && root !== '') return root.replace(/[\\/]+$/, '')
137 } catch {
138 // Read from the session's folder until the next pass asks again.
139 }
140 failed()
141 return session
142}
143
144function repoOf(root: string) {
145 return root.split(/[\\/]/).pop() ?? ''
146}
147
148// The agents on the roster, in the order they arrived.
149function rosterAgents(roster: Fleet) {
150 return roster.order.map(id => roster.agents[id]).filter(agent => agent != null)
151}
152
153// The ticket an agent is working, through its dish. Null when either is unknown.
154function ticketOf(agent: Agent, byDish: Record<string, string>) {
155 if (agent.dish == null || !Object.hasOwn(byDish, agent.dish)) return null
156 const id = byDish[agent.dish]
157 return typeof id === 'string' && id !== '' ? id : null
158}
159
160// The dish the board is showing, from the last refresh, or null when it shows the tickets.
161let currentDish: string | null = null
162
163// Everything the board draws, read from state so the pane redraws when any of it changes.
164// Each agent's ticket and card are worked out here, fresh each time: in a dish it stands on its
165// item's card, on the ticket board on its ticket's card, and with the crew when that card isn't
166// showing. Only the fields the board draws are passed on.
167async function snapshotOf($: EngineInterface): Promise<Snapshot> {
168 const roster = await read($, fleet)
169 const byDish = await read($, dishes)
170 const head = await read($, project)
171 const shown = await read($, work)
172 const onBoard = new Set(shown.flatMap(lane => lane.cards.map(card => card.id)))
173 const agents = rosterAgents(roster).map((agent): Agent => {
174 const ticket = ticketOf(agent, byDish)
175 let card: string | null = null
176 if (head.mode === 'dish') {
177 if (currentDish !== null && agent.dish === currentDish && agent.item != null && onBoard.has(agent.item)) card = agent.item
178 } else if (ticket !== null && onBoard.has(ticket)) {
179 card = ticket
180 }
181 return {
182 id: agent.id,
183 name: agent.name,
184 role: agent.role,
185 model: agent.model ?? null,
186 state: agent.state,
187 dish: agent.dish ?? null,
188 item: agent.item ?? null,
189 ticket,
190 card,
191 activity: agent.activity ?? null,
192 tokens: agent.tokens,
193 startedAt: agent.startedAt ?? null,
194 endedAt: agent.endedAt ?? null,
195 }
196 })
197 return {
198 project: head,
199 lanes: shown,
200 agents,
201 weather: await read($, weather),
202 messages: await read($, messages),
203 learnings: await read($, learnings),
204 detail: await read($, detail),
205 now: await $.clock.now(),
206 }
207}
208
209type Ticket = NonNullable<ReturnType<typeof parseTicket>>
210
211// Tickets we have already parsed, by file name, with the mtime we read them at. A file is only
212// read again when its mtime moves, so a quiet board costs one folder listing per tick.
213let cacheDir: string | null = null
214let cache: Record<string, { mtimeMs: number; ticket: Ticket | null }> = {}
215
216// Every ticket in the cache.
217function cachedTickets() {
218 return Object.values(cache).flatMap(hit => (hit.ticket ? [hit.ticket] : []))
219}
220
221// Whether two plain values would store the same, so an idle board skips the write and never redraws.
222const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
223
224// Rebuilds the ticket cache from the ticket folder named in .brigade/config.md. Any missing piece
225// (config, folder, a file that won't read) stops here and the cache keeps what it had.
226async function refreshTickets($: EngineInterface) {
227 const root = await rootOf($)
228 const config = `${root}/.brigade/config.md`
229 if (!(await $.fs.exists(config))) return
230 const named = boardDirFrom(await $.fs.read(config))
231 if (named === null) return
232 const dir = /^([A-Za-z]:)?[\\/]/.test(named) ? named : `${root}/${named.replace(/^\.[\\/]/, '')}`
233 const entries = await $.fs.list(dir)
234 // A different folder means none of the cached tickets apply any more.
235 const seen: typeof cache = dir === cacheDir ? cache : {}
236 const fresh: typeof cache = {}
237 for (const entry of entries) {
238 if (entry.kind !== 'file' || !entry.name.endsWith('.md') || entry.name.startsWith('_')) continue
239 const known = seen[entry.name]
240 fresh[entry.name] =
241 known && known.mtimeMs === entry.mtimeMs
242 ? known
243 : { mtimeMs: entry.mtimeMs, ticket: parseTicket(await $.fs.read(`${dir}/${entry.name}`), entry.name) }
244 }
245 // Only a complete pass replaces the cache, which also drops files that have gone.
246 cacheDir = dir
247 cache = fresh
248}
249
250// Dishes whose PLAN.md moved in the last day get their folders scanned for notes, and so do
251// dishes an agent is working, however old their plan.
252const RECENT_MS = 24 * 60 * 60 * 1000
253// How many messages the board keeps, and how many learnings.
254const KEEP_MESSAGES = 8
255const KEEP_LEARNINGS = 5
256// How many messages the text board lists.
257const SHOW_MESSAGES = 3
258// The folders inside a dish that hold notes: research briefs, cook and inspector reports, and
259// each agent's working memory.
260const NOTE_DIRS = ['briefs', 'reports', 'state']
261
262type Plan = ReturnType<typeof planItems>
263// Dish files we have already read, by full path, with the mtime we read them at. A file is only
264// read again when its mtime moves, so quiet dishes cost a few folder listings per tick, and a
265// long plan is parsed once per change.
266let dishCache: Record<string, { mtimeMs: number; plan?: Plan; note?: Note | null }> = {}
267// Every dish with a plan, as the last complete pass found it: its folder name, when its plan
268// last moved, the plan, and the notes in it (none for a dish that has gone quiet).
269let dishesSeen: { dish: string; mtimeMs: number; plan: Plan; notes: Note[] }[] = []
270
271// Reads every dish's plan, and the notes of the dishes that are still moving or being worked.
272// Missing folders are skipped; a file that won't read throws, and the board keeps what it had
273// until the next tick.
274async function refreshDishes($: EngineInterface) {
275 const root = await rootOf($)
276 const base = `${root}/.brigade/dishes`
277 const fresh: typeof dishCache = {}
278 const seen: typeof dishesSeen = []
279 const byDish: [string, string][] = []
280 if (await $.fs.exists(base)) {
281 const now = await $.clock.now()
282 // The board can show an old dish because someone is working it, and then its notes must be
283 // current. With nobody working, this adds no file read to a tick.
284 const worked = new Set(rosterAgents(await read($, fleet)).flatMap(agent => (agent.state === 'working' && typeof agent.dish === 'string' ? [agent.dish] : [])))
285 for (const folder of await $.fs.list(base)) {
286 if (folder.kind !== 'dir' || !SLUG.test(folder.name)) continue
287 const dir = `${base}/${folder.name}`
288 const inside = await $.fs.list(dir)
289 const planEntry = inside.find(entry => entry.kind === 'file' && entry.name === 'PLAN.md')
290 if (!planEntry) continue
291 const planPath = `${dir}/PLAN.md`
292 const knownPlan = dishCache[planPath]
293 const plan =
294 knownPlan && knownPlan.mtimeMs === planEntry.mtimeMs && knownPlan.plan
295 ? knownPlan.plan
296 : (planItems(await $.fs.read(planPath)) as Plan)
297 fresh[planPath] = { mtimeMs: planEntry.mtimeMs, plan }
298 if (plan.ticket !== '') byDish.push([folder.name, plan.ticket])
299 const found: Note[] = []
300 if (!(now - planEntry.mtimeMs > RECENT_MS) || worked.has(folder.name)) {
301 for (const sub of NOTE_DIRS) {
302 if (!inside.some(entry => entry.kind === 'dir' && entry.name === sub)) continue
303 for (const entry of await $.fs.list(`${dir}/${sub}`)) {
304 if (entry.kind !== 'file' || !NOTE_FILE.test(entry.name)) continue
305 const path = `${dir}/${sub}/${entry.name}`
306 const known = dishCache[path]
307 const note =
308 known && known.mtimeMs === entry.mtimeMs && known.note !== undefined
309 ? known.note
310 : (noteFrom(await $.fs.read(path), entry.mtimeMs, `${sub}/${entry.name}`) as Note | null)
311 fresh[path] = { mtimeMs: entry.mtimeMs, note }
312 if (note) found.push(note)
313 }
314 }
315 }
316 seen.push({ dish: folder.name, mtimeMs: planEntry.mtimeMs, plan, notes: found })
317 }
318 }
319 // Only a complete pass replaces the cache, which also drops files that have gone.
320 dishCache = fresh
321 dishesSeen = seen
322 // fromEntries makes plain own keys, so a dish named like "__proto__" can't touch the prototype.
323 const nextDishes = Object.fromEntries(byDish) as Record<string, string>
324 if (!same(await read($, dishes), nextDishes)) await update($, dishes, () => nextDishes)
325}
326
327// Works out what the board shows from what the disk and the roster say: the dish being worked,
328// with one card per work item, or the board's tickets when no dish is. Stores only what changed.
329async function refreshWork($: EngineInterface) {
330 const repo = repoOf(await rootOf($))
331 const roster = rosterAgents(await read($, fleet))
332 const byDish = await read($, dishes)
333 const now = await $.clock.now()
334 const plans = dishesSeen.map(seen => ({ dish: seen.dish, mtimeMs: seen.mtimeMs, items: seen.plan.items }))
335 const picked = pickDish(plans, roster, now, RECENT_MS) as string | null
336 const current = picked === null ? undefined : dishesSeen.find(seen => seen.dish === picked)
337 let nextWork: WorkLane[]
338 let nextProject: Project
339 let nextMessages: Message[] = []
340 if (current) {
341 // Only this dish's agents count, and the items they are working always show.
342 const crew = roster.filter(agent => agent.dish === current.dish)
343 const pinned = crew.flatMap(agent => (agent.state === 'working' && agent.item != null ? [agent.item] : []))
344 const cards = workCards(current.plan.items, current.notes, crew)
345 nextWork = toWorkLanes(cards, pinned) as WorkLane[]
346 const ticket = cachedTickets().find(hit => hit.id === current.plan.ticket) ?? null
347 const done = cards.filter(card => card.phase === 'done').length
348 nextProject = projectOf({ mode: 'dish', repo, plan: current.plan, ticket, done, total: cards.length }) as Project
349 nextMessages = messagesFrom(current.notes, crew, KEEP_MESSAGES) as Message[]
350 } else {
351 const tickets = cachedTickets()
352 const pinned = roster.flatMap(agent => {
353 const ticket = ticketOf(agent, byDish)
354 return ticket === null ? [] : [ticket]
355 })
356 nextWork = toWorkLanes(ticketCards(tickets, laneOf), pinned) as WorkLane[]
357 nextProject = projectOf({ mode: 'tickets', repo, count: tickets.length }) as Project
358 }
359 // Set before the writes, so the redraw each write causes places agents on the right cards.
360 currentDish = current ? current.dish : null
361 if (!same(await read($, work), nextWork)) await update($, work, () => nextWork)
362 if (!same(await read($, project), nextProject)) await update($, project, () => nextProject)
363 if (!same(await read($, messages), nextMessages)) await update($, messages, () => nextMessages)
364}
365
366// The repo's learnings as last parsed, with the mtime of the file they came from.
367let learned: { mtimeMs: number; value: Learnings } | null = null
368
369// Reads .brigade/LEARNINGS.md, only when its mtime moved, and stores the newest few learnings.
370// No file means no learnings.
371async function refreshLearnings($: EngineInterface) {
372 const path = `${await rootOf($)}/.brigade/LEARNINGS.md`
373 let next: Learnings = { total: 0, lines: [] }
374 if (await $.fs.exists(path)) {
375 const { mtimeMs } = await $.fs.stat(path)
376 next = learned !== null && learned.mtimeMs === mtimeMs ? learned.value : (learningsFrom(await $.fs.read(path), KEEP_LEARNINGS) as Learnings)
377 learned = { mtimeMs, value: next }
378 }
379 if (!same(await read($, learnings), next)) await update($, learnings, () => next)
380}
381
382// Puts what each agent was last seen doing on the roster, in one write, and only when that
383// changes something. The waiting entries are taken first, so calls that land meanwhile wait
384// for the next tick rather than being lost.
385async function foldActivities($: EngineInterface) {
386 if (activities.size === 0) return
387 const events = [...activities].map(([id, text]) => ({ type: 'activity', id, text }))
388 activities.clear()
389 const apply = (roster: Fleet) => events.reduce((next, event) => applyEvent(next, event) as Fleet, roster)
390 const before = await read($, fleet)
391 if (same(before, apply(before))) return
392 await update($, fleet, apply)
393}
394
395// Re-reads the disk, the roster's latest doings and the context figures, and stores what changed.
396// Runs on a timer, so it never throws: a failed part just leaves its piece of the board as it was
397// until the next tick. Overlapping calls share one pass, so a slow tick can't land on top of a
398// newer one.
399let running: Promise<void> | null = null
400const refresh = async ($: EngineInterface) => {
401 if (running) return running
402 running = (async () => {
403 pass++
404 try {
405 // Agents that finished more than two minutes ago leave the board. Only a pass that
406 // drops someone writes, so an idle board never redraws.
407 const now = await $.clock.now()
408 const before = await read($, fleet)
409 if ((prune(before, now, KEEP_MS) as Fleet).order.length !== before.order.length) {
410 await update($, fleet, roster => prune(roster, now, KEEP_MS) as Fleet)
411 // Agents that left stop holding a tool-call count.
412 const after = await read($, fleet)
413 for (const id of [...toolLooks.keys()]) if (!Object.hasOwn(after.agents, id)) toolLooks.delete(id)
414 }
415 } catch {
416 // The roster stays as it was.
417 }
418 try {
419 await foldActivities($)
420 } catch {
421 // Agents keep saying what they said before.
422 }
423 try {
424 await refreshTickets($)
425 } catch {
426 // The tickets stay as they were.
427 }
428 try {
429 await refreshDishes($)
430 } catch {
431 // The dishes stay as they were.
432 }
433 try {
434 await refreshWork($)
435 } catch {
436 // The lanes, header and messages stay as they were.
437 }
438 try {
439 await refreshDetail($)
440 } catch {
441 // The detail box stays as it was.
442 }
443 try {
444 await refreshLearnings($)
445 } catch {
446 // The learnings stay as they were.
447 }
448 try {
449 const next = forecast((await $.session.usage()).context) as Weather
450 if (!same(await read($, weather), next)) await update($, weather, () => next)
451 } catch {
452 // The context reading stays as it was.
453 }
454 })().finally(() => {
455 running = null
456 })
457 return running
458}
459
460// What the detail box is about: the kind of thing clicked and its id, or null when no box is up.
461// The box itself lives in state; this is what each tick rebuilds it from.
462type Target = { kind: 'card' | 'agent' | 'message'; id: string }
463let target: Target | null = null
464// Goes up on every open and close, so a rebuild that started before one can't put back a box
465// that has since closed, or swap in the box for an older click.
466let generation = 0
467
468const KINDS = new Set(['card', 'agent', 'message'])
469const MAX_ID = 200
470// A note's place inside its dish folder that is safe to put in a path.
471const NOTE_PATH = /^(briefs|reports|state)\/[A-Za-z0-9._-]+\.md$/
472// How many lines of an agent's working memory the box shows, and of a message's source file.
473const MEMORY_LINES = 8
474const BODY_LINES = 12
475
476function isPlain(value: unknown): value is Record<string, unknown> {
477 return value !== null && typeof value === 'object' && !Array.isArray(value)
478}
479
480// What the pane asked for, when it is exactly `{ open: { kind, id } }` or `{ close: true }`.
481// The pane is our own code, but its message crosses a boundary, so anything else is dropped.
482function requestOf(data: unknown): { open: Target } | { close: true } | null {
483 if (!isPlain(data)) return null
484 const keys = Object.keys(data)
485 if (keys.length !== 1) return null
486 if (keys[0] === 'close') return data.close === true ? { close: true } : null
487 if (keys[0] !== 'open') return null
488 const open = data.open
489 if (!isPlain(open)) return null
490 const fields = Object.keys(open)
491 if (fields.length !== 2 || !Object.hasOwn(open, 'kind') || !Object.hasOwn(open, 'id')) return null
492 const { kind, id } = open
493 if (typeof kind !== 'string' || !KINDS.has(kind)) return null
494 if (typeof id !== 'string' || id === '' || id.length > MAX_ID) return null
495 return { open: { kind: kind as Target['kind'], id } }
496}
497
498// A file's text, or null when it isn't there.
499async function readIfThere($: EngineInterface, path: string) {
500 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
501}
502
503// The first `limit` non-empty lines of a file after its frontmatter.
504function bodyLines(text: string, limit: number) {
505 const lines = text.split(/\r?\n/)
506 let start = 0
507 if (lines[0]?.trim() === '---') {
508 const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
509 if (end !== -1) start = end + 1
510 }
511 return lines.slice(start).filter(line => line.trim() !== '').slice(0, limit)
512}
513
514// The first paragraph under a ticket's `## Goal` heading, as one line, or '' when it has none.
515function goalOf(text: string) {
516 const out: string[] = []
517 let inGoal = false
518 for (const line of text.split(/\r?\n/)) {
519 const bare = line.trim()
520 if (!inGoal) {
521 if (bare === '## Goal') inGoal = true
522 continue
523 }
524 if (bare === '') {
525 if (out.length > 0) break
526 continue
527 }
528 if (bare.startsWith('#')) break
529 out.push(bare)
530 }
531 return out.join(' ')
532}
533
534// Whether a ticket file name from our own listing of the ticket folder is safe to read: a
535// markdown file right in that folder, not a hidden one, and nothing that could reach another folder.
536function isTicketFile(name: string) {
537 return name.endsWith('.md') && !/[\\/]/.test(name) && !name.startsWith('.')
538}
539
540// The newest note of one kind, by time; a note without a time counts as the oldest.
541function newest(notes: Note[], kind: string) {
542 const timeOf = (note: Note) => (Number.isFinite(note.at) ? note.at : 0)
543 let best: Note | null = null
544 for (const note of notes) if (note.kind === kind && (best === null || timeOf(note) > timeOf(best))) best = note
545 return best
546}
547
548// A card's box. In a dish the id has to be one of the plan's items; on the ticket board it has
549// to be a ticket we have read. Every path below comes from what the board already holds, never
550// from the click: the dish and item slugs, a note's own file, a ticket's file name.
551async function cardBox($: EngineInterface, id: string): Promise<Detail | null> {
552 if (currentDish === null) return ticketBox($, id)
553 const seen = dishesSeen.find(entry => entry.dish === currentDish)
554 if (!seen || !SLUG.test(seen.dish)) return null
555 const item = seen.plan.items.find(entry => entry.slug === id)
556 if (!item || !SLUG.test(item.slug)) return null
557 const crew = rosterAgents(await read($, fleet)).filter(agent => agent.dish === seen.dish)
558 const [card] = workCards([item], seen.notes, crew) as { phase: string }[]
559 const phaseTitle = PHASES.find(phase => phase.key === card?.phase)?.title ?? ''
560 const mine = seen.notes.filter(note => note.item === item.slug)
561 const report = newest(mine, 'report')
562 const verdict = newest(mine, 'verdict')
563 let findings: unknown[] = []
564 if (verdict !== null && typeof verdict.file === 'string' && NOTE_PATH.test(verdict.file)) {
565 const text = await readIfThere($, `${await rootOf($)}/.brigade/dishes/${seen.dish}/${verdict.file}`)
566 if (text !== null) findings = findingsOf(text)
567 }
568 const agents = crew
569 .filter(agent => agent.item === item.slug)
570 .map(agent => ({ name: agent.name, role: roleLabel(agent.role), activity: agent.activity, state: agent.state }))
571 return cardDetail({ item, phaseTitle, report, verdict, findings, agents }) as Detail
572}
573
574async function ticketBox($: EngineInterface, id: string): Promise<Detail | null> {
575 const hit = Object.entries(cache).find(([, entry]) => entry.ticket !== null && entry.ticket.id === id)
576 if (!hit || cacheDir === null) return null
577 const [name, { ticket }] = hit
578 let goal = ''
579 if (isTicketFile(name)) {
580 const text = await readIfThere($, `${cacheDir}/${name}`)
581 if (text !== null) goal = goalOf(text)
582 }
583 return ticketDetail({ ticket, goal }) as Detail
584}
585
586// An agent's box, for an agent on the roster. Its working memory is read from its own dish and
587// item, when both are plain slugs.
588async function agentBox($: EngineInterface, id: string): Promise<Detail | null> {
589 const roster = await read($, fleet)
590 if (!Object.hasOwn(roster.agents, id)) return null
591 const agent = roster.agents[id]
592 if (agent == null) return null
593 const ticket = ticketOf(agent, await read($, dishes))
594 let memory: string[] = []
595 if (typeof agent.dish === 'string' && typeof agent.item === 'string' && SLUG.test(agent.dish) && SLUG.test(agent.item)) {
596 const text = await readIfThere($, `${await rootOf($)}/.brigade/dishes/${agent.dish}/state/${agent.item}.md`)
597 if (text !== null) memory = ledgerTail(text, MEMORY_LINES)
598 }
599 return agentDetail({ agent: { ...agent, ticket }, roleLabel: roleLabel(agent.role), memory, now: await $.clock.now() }) as Detail
600}
601
602// A message's box, for a message in the Messages panel. The file it came from is the note's own,
603// inside the dish on the board.
604async function messageBox($: EngineInterface, id: string): Promise<Detail | null> {
605 const message = (await read($, messages)).find(entry => entry.id === id)
606 if (!message) return null
607 let findings: unknown[] = []
608 let body: string[] = []
609 const dish = currentDish
610 if (dish !== null && SLUG.test(dish) && typeof message.file === 'string' && NOTE_PATH.test(message.file)) {
611 const text = await readIfThere($, `${await rootOf($)}/.brigade/dishes/${dish}/${message.file}`)
612 if (text !== null) {
613 if (envelope(text).doc === 'verdict') findings = findingsOf(text)
614 body = bodyLines(text, BODY_LINES)
615 }
616 }
617 return messageDetail({ message, findings, body }) as Detail
618}
619
620// The box for a target, or null when the board no longer holds what it is about.
621function boxFor($: EngineInterface, want: Target) {
622 if (want.kind === 'card') return cardBox($, want.id)
623 if (want.kind === 'agent') return agentBox($, want.id)
624 return messageBox($, want.id)
625}
626
627// Whether two targets are about the same thing. Two nulls match: no box either way.
628function sameTarget(a: Target | null, b: Target | null) {
629 return a === b || (a !== null && b !== null && a.kind === b.kind && a.id === b.id)
630}
631
632// Stores the box while `still()` says it is current. It is asked again inside the write itself,
633// so an open or a close that lands during the read just before can't be written over.
634async function showDetail($: EngineInterface, next: Detail | null, still: () => boolean) {
635 if (!still()) return
636 if (same(await read($, detail), next)) return
637 await update($, detail, current => (still() ? next : current))
638}
639
640// Opens or closes the box for what the pane posted. An open whose subject the board doesn't hold
641// leaves everything as it was.
642async function detailFrom($: EngineInterface, data: unknown) {
643 const request = requestOf(data)
644 if (request === null) return
645 const mine = ++generation
646 const current = () => mine === generation
647 if ('close' in request) {
648 target = null
649 await showDetail($, null, current)
650 return
651 }
652 const built = await boxFor($, request.open)
653 if (built === null || mine !== generation) return
654 target = request.open
655 await showDetail($, built, current)
656}
657
658// Rebuilds the open box so it stays current, and takes it down once its subject has gone. A box
659// with nothing behind it, left over from before a reload, comes down too. A click can land while
660// the rebuild is reading files, so the box is only written while the stored target is still the
661// one it was built for: a newer click's box, or a close, wins.
662async function refreshDetail($: EngineInterface) {
663 const mine = generation
664 const want = target
665 const built = want === null ? null : await boxFor($, want)
666 if (mine !== generation || !sameTarget(target, want)) return
667 const after = built === null ? null : want
668 target = after
669 await showDetail($, built, () => mine === generation && sameTarget(target, after))
670}
671
672// Whether this module load has started its refresh timer. A reload runs the module again and
673// starts over; a session.start that fires again within one load leaves the timer alone.
674let ticking = false
675
676// The role's label, or the role itself when the board doesn't know it.
677function roleLabel(role: string) {
678 return Object.hasOwn(ROLES, role) ? ROLES[role as keyof typeof ROLES].label : role
679}
680
681// 'To do 2: usage-docs, admin-toggle', or just 'To do 0' for an empty lane.
682function laneLine(lane: WorkLane) {
683 const head = `${lane.title} ${lane.total}`
684 return lane.cards.length === 0 ? head : `${head}: ${lane.cards.map(card => card.id).join(', ')}`
685}
686
687// 'Miso · cook · token-bucket · editing bucket.ts', leaving out whatever isn't known.
688function agentLine(agent: Agent) {
689 return [agent.name, roleLabel(agent.role), agent.item ?? agent.ticket, agent.activity].filter(part => typeof part === 'string' && part !== '').join(' · ')
690}
691
692// The plain-text board's longest line, and how many agents it lists before counting the rest.
693const PLAIN_MAX = 200
694const PLAIN_AGENTS = 12
695
696// One line of the plain-text board. Its text comes from files and tool calls, so control
697// characters become spaces, invisible marks go, and it is cut to PLAIN_MAX characters.
698function plain(text: string) {
699 return Array.from(safeText(text)).slice(0, PLAIN_MAX).join('')
700}
701
702function contextLine(reading: Weather | null) {
703 const percent = reading?.percent
704 return typeof percent === 'number' && Number.isFinite(percent) ? `Context ${percent}%` : 'Context --'
705}
706
707// The desktop app can't load the board's drawing region, so there the board is a picture with a
708// row of buttons under it that open the detail box. The app refuses a picture wider or taller
709// than this many pixels, and it checks that after the hook has returned, so we check first.
710const PICTURE_MAX_PX = 4096
711// The picture is drawn as many board columns as the pane is wide in the app's own cells, times
712// 1.4: a pane the app calls 108 columns is about 1260 pixels across, and gets 151 columns. The app
713// scales a picture wider than the pane down to fill it but never grows a narrower one, so the
714// picture comes out a little wider than the pane and fills it. A very narrow pane still gets a
715// readable 96 columns, and a very wide one stops at 200 so the board doesn't turn into a thin strip.
716const PICTURE_SCALE = 1.4
717const PICTURE_MIN_COLUMNS = 96
718const PICTURE_MAX_COLUMNS = 200
719// How many buttons of each kind go under the picture, and the longest label a button gets.
720const BUTTON_CARDS = 12
721const BUTTON_AGENTS = 12
722const BUTTON_MESSAGES = 4
723const LABEL_MAX = 24
724
725type Picture = { source: string; width: number; height: number }
726type DetailButton = { key: string; label: string; open: Target }
727type Region = { kind: string; id: string; x: number; y: number; w: number; h: number; frame: 0 | 1 }
728
729// How many columns to draw the desktop picture at, from the pane's width in the app's cells.
730// Without a usable width it is the dock's own 124.
731function pictureColumns(paneColumns: unknown) {
732 if (typeof paneColumns !== 'number' || !Number.isFinite(paneColumns)) return PANE_COLUMNS
733 return Math.min(PICTURE_MAX_COLUMNS, Math.max(PICTURE_MIN_COLUMNS, Math.round(paneColumns * PICTURE_SCALE)))
734}
735
736// A button label from file text. Control characters become spaces and invisible marks go, as on
737// every board line; half of a surrogate pair and the engine's own placeholder character go too,
738// because the app refuses a button whose label holds one. Cut to LABEL_MAX characters, and a
739// label with nothing left to read says what kind of thing the button opens.
740function labelOf(text: string, kind: string) {
741 const kept = Array.from(safeText(text)).filter(ch => !/^[\ud800-\udfff]$/.test(ch) && ch !== '\u{10eeee}')
742 const label = kept.slice(0, LABEL_MAX).join('')
743 return label.trim() === '' ? kind : label
744}
745
746// The board as one still SVG picture, `columns` wide: every sprite drawn in real pixels where the
747// walk has got it to, or at its home when the walk hasn't placed it. A sprite at home stands on
748// frame 0 and a walking one shows the stage's frame, which the walk flips as it moves them, so the
749// picture only changes when something on the board does. It holds no tooltips: the app shows it as
750// a plain image, which has none. Throws when the board is too big for a picture the app will draw.
751function pictureFor(snapshot: Snapshot, shown: Stage, columns: number): Picture {
752 const drawn = draw(snapshot, { positions: shown.positions, frame: shown.frame === 1 ? 1 : 0, hovered: null, over: null }, columns)
753 // The painter names each sprite by the safe form of its agent's id, and when two agents share
754 // one, the first of them is the one it draws.
755 const byId = new Map<string, Agent>()
756 for (const agent of snapshot.agents) {
757 const id = safeText(agent.id)
758 if (!byId.has(id)) byId.set(id, agent)
759 }
760 const sprites = (drawn.regions as Region[]).flatMap(region => {
761 const agent = region.kind === 'agent' ? byId.get(region.id) : undefined
762 if (!agent) return []
763 const { x, y, w, h, frame } = region
764 return [{ x, y, w, h, frame, size: sizeOf(agent.model), color: colorOf(agent.role, agent.state, agent.model) }]
765 })
766 const picture = pictureOf({ rows: drawn.rows, columns, sprites }) as Picture
767 const fits = (px: number) => Number.isFinite(px) && px > 0 && px <= PICTURE_MAX_PX
768 if (!fits(picture.width) || !fits(picture.height)) throw new Error('the board is too big for a picture')
769 return picture
770}
771
772// The buttons that open the detail box on desktop, in the board's own order: the cards lane by
773// lane, the agents as they arrived, then the messages the Messages panel shows. Each key says only
774// the kind and the place in the row, so no file text ever becomes an address.
775function detailButtons(snapshot: Snapshot): DetailButton[] {
776 const cards = snapshot.lanes.flatMap(lane => lane.cards).slice(0, BUTTON_CARDS)
777 const agents = snapshot.agents.slice(0, BUTTON_AGENTS)
778 const shown = snapshot.messages.slice(0, BUTTON_MESSAGES)
779 return [
780 ...cards.map((card, i) => ({ key: `card-${i}`, label: labelOf(card.id, 'card'), open: { kind: 'card' as const, id: card.id } })),
781 ...agents.map((agent, i) => ({ key: `agent-${i}`, label: labelOf(agent.name, 'agent'), open: { kind: 'agent' as const, id: agent.id } })),
782 ...shown.map((message, i) => ({ key: `message-${i}`, label: labelOf(`${message.from} → ${message.to}`, 'message'), open: { kind: 'message' as const, id: message.id } })),
783 ]
784}
785
786// What a button under the picture does when pressed: the same open or close a click on the board
787// posts. A press comes after the drawing, so it may write. A failure leaves the box as it was.
788async function pressed($: EngineInterface, data: unknown) {
789 try {
790 await detailFrom($, data)
791 } catch {
792 // The detail box stays as it was until the next press.
793 }
794}
795
796type Elements = ReturnType<EngineInterface['ui']['resolve']>
797type Run = { text: string; color: string; backgroundColor: string; bold: boolean }
798
799// The row of buttons under a board the hooks module draws itself: `Details:`, then Close details
800// while a box is up, then one button per card, agent and message. An empty board gets no row.
801function detailRow($: EngineInterface, { Box, Button, Text }: Elements, snapshot: Snapshot) {
802 const closing = snapshot.detail !== null ? [{ key: 'close-details', label: 'Close details' }] : []
803 const opening = detailButtons(snapshot)
804 if (closing.length + opening.length === 0) return []
805 return [
806 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
807 <Text>Details:</Text>
808 {closing.map(button => (
809 <Button key={button.key} label={button.label} role="dismiss" onPress={() => pressed($, { close: true })} />
810 ))}
811 {opening.map(button => (
812 <Button key={button.key} label={button.label} onPress={() => pressed($, { open: button.open })} />
813 ))}
814 </Box>,
815 ]
816}
817
818// The app refuses a drawing holding more than 100000 characters of text, and checks that after the
819// hook has returned, so the rows are measured first. The margin leaves room for the buttons.
820const ROWS_MAX_CHARS = 99000
821
822// The painted board as rows of coloured text runs, the way the terminal's region draws it, with
823// every sprite where the walk has got it to. The terminal is monospaced, so the rows line up.
824// Throws when the board is too big for the app to draw as text.
825function boardRows(snapshot: Snapshot, positions: Stage['positions']) {
826 const rows = draw(snapshot, { positions, frame: 0, hovered: null, over: null }, PANE_COLUMNS).rows as Run[][]
827 const chars = rows.reduce((sum, runs) => sum + runs.reduce((inRow, run) => inRow + String(run.text).length, 0), 0)
828 if (chars > ROWS_MAX_CHARS) throw new Error('the board is too big to draw as rows')
829 return rows
830}
831
832// How often the walk's clock ticks while the pane is open, and how long a terminal waits for its
833// region to report in before drawing the board itself.
834const STEP_MS = 500
835const FALLBACK_MS = 3000
836
837// How the pane was last drawn: by the terminal's region, as a picture, as rows drawn here, or as
838// plain lines. A render may not write state, so it notes this here and the walk's step reads it.
839let drawnAs: 'region' | 'picture' | 'rows' | 'lines' | null = null
840// How many columns the last picture was drawn at, so the walk lays the board out the same way.
841let pictureDrawnAt = PANE_COLUMNS
842// The walk's clock while the pane is open. There is never more than one.
843let walker: { cancel(): void } | null = null
844// How many times the pane has closed. An open notes it before writing the stage and checks it
845// after, so it sees a close that came in at any point meanwhile, whatever order the state calls
846// ran in.
847let closes = 0
848// A region has reported in. Kept here as well as in state so that a step in a healthy terminal
849// returns without reading anything.
850let regionReady = false
851// A step is still running, so a slow one can't overlap the next.
852let stepping = false
853
854// Notes that the pane is open and starts the walk. Running the command on a pane that is already
855// open changes nothing, so a second clock never starts. Each open gives the terminal's region
856// another try: a terminal that fell back to rows may only have loaded its region slowly, so the
857// fallback is dropped and the region gets its 3 seconds again. Whether the region reported in
858// carries over, so a terminal whose region drew keeps it and never waits.
859async function openStage($: EngineInterface) {
860 if (walker !== null) return
861 const seen = closes
862 const at = await $.clock.now()
863 await update($, stage, current => ({ ...current, open: true, openedAt: at, plain: false }))
864 // The pane closed while this was writing. That close had no clock to cancel, and may have read
865 // the stage before this write and left it alone, so the stage is put back to closed here and no
866 // clock starts for a pane that isn't there.
867 if (closes !== seen) {
868 await update($, stage, current => ({ ...current, open: false }))
869 return
870 }
871 // Another open may have started the clock while this one was writing.
872 if (walker !== null) return
873 walker = $.clock.every(STEP_MS, () => {
874 void step($)
875 })
876}
877
878// Stops the walk when the pane closes. The close is counted before anything else, with nothing
879// awaited first, so an open still writing the stage always sees it. The clock goes next, so a
880// failed write can't leave it running. The sprites keep their places for the next open.
881async function closeStage($: EngineInterface) {
882 closes += 1
883 const running = walker
884 walker = null
885 running?.cancel()
886 if ((await read($, stage)).open) await update($, stage, current => ({ ...current, open: false }))
887}
888
889// One tick of the walk's clock. In a terminal whose region reported in there is nothing to do. In a
890// terminal still waiting on its region, the step checks whether it has waited too long. Where the
891// board is drawn here the sprites walk, laid out the way the board was last drawn: a picture at its
892// own columns, rows at the dock's. Runs on a timer, so it never throws.
893async function step($: EngineInterface) {
894 const how = drawnAs
895 const columns = how === 'picture' ? pictureDrawnAt : PANE_COLUMNS
896 if (stepping || (how === 'region' && regionReady)) return
897 if (how !== 'region' && how !== 'picture' && how !== 'rows') return
898 stepping = true
899 try {
900 const now = await read($, stage)
901 if (!now.open) return
902 if (how === 'region') await fallBack($, now)
903 else await walk($, now, how === 'picture', columns)
904 } catch {
905 // This step is skipped; the next one tries again.
906 } finally {
907 stepping = false
908 }
909}
910
911// A terminal region that hasn't reported in by FALLBACK_MS after the pane opened is taken for one
912// that failed to load, and the pane switches to rows drawn here. Ready is checked again inside the
913// write, so a region that reports in at the last moment still wins.
914async function fallBack($: EngineInterface, now: Stage) {
915 if (now.ready) {
916 regionReady = true
917 return
918 }
919 if (now.plain || now.openedAt === null) return
920 if ((await $.clock.now()) - now.openedAt < FALLBACK_MS) return
921 await update($, stage, current => (current.ready ? current : { ...current, plain: true }))
922}
923
924// Whether a stored position is a real place on the board.
925function isPlace(position: unknown): position is { x: number; y: number } {
926 return isPlain(position) && Number.isFinite(position.x) && Number.isFinite(position.y)
927}
928
929// Moves every sprite toward its home on the board as it stands now, `columns` wide, in one write.
930//
931// On the picture a sprite the walk hasn't placed yet starts at its home, so a new agent appears in
932// place, and a sprite only walks when its card moves. The picture takes two steps a tick, as many
933// a second as the terminal's region takes, and flips the walking frame in the same write. The rows
934// fallback takes one step a tick and keeps the walk-in from the left edge, as the region does.
935//
936// Nothing placed and nothing moving means nothing written, so a settled board never redraws.
937async function walk($: EngineInterface, now: Stage, picture: boolean, columns: number) {
938 const plan = arrange(await snapshotOf($), columns) as { homes: Record<string, { x: number; y: number }>; obstacles: unknown[] }
939 let from = now.positions
940 if (picture) {
941 const unplaced = Object.keys(plan.homes).filter(id => !(Object.hasOwn(from, id) && isPlace(from[id])))
942 if (unplaced.length > 0) from = { ...from, ...Object.fromEntries(unplaced.map(id => [id, { x: plan.homes[id].x, y: plan.homes[id].y }])) }
943 }
944 let next = advance(from, plan.homes, plan.obstacles) as Stage['positions']
945 if (picture) next = advance(next, plan.homes, plan.obstacles) as Stage['positions']
946 if (same(now.positions, next)) return
947 const moved = Object.entries(next).some(([id, to]) => from[id]?.x !== to.x || from[id]?.y !== to.y)
948 await update($, stage, current => {
949 const frame = current.frame === 1 ? 1 : 0
950 return { ...current, positions: next, frame: picture && moved ? (frame === 1 ? 0 : 1) : frame }
951 })
952}
953
954// Whether a post is exactly the region's `{ ready: true }`, with nothing else in it.
955function isReady(data: unknown) {
956 return isPlain(data) && Object.keys(data).length === 1 && Object.hasOwn(data, 'ready') && data.ready === true
957}
958
959// The terminal's region drew. From now on the step leaves the terminal alone, and a pane that had
960// fallen back to rows gets its region again.
961async function reportIn($: EngineInterface) {
962 regionReady = true
963 const now = await read($, stage)
964 if (now.ready && !now.plain) return
965 await update($, stage, current => ({ ...current, ready: true, plain: false }))
966}
967
968export const register: Register = on => {
969 // Every hook below follows one rule: the board's own work is wrapped so its failure is
970 // swallowed, and the hook hands back exactly what next(e) gave it, or lets what next(e) threw
971 // pass through untouched. The session must never wait on, or break over, the board.
972 on('session.start', async ($, e, next) => {
973 try {
974 await $.command.register({ name: 'brigade-board', description: 'Open the brigade board' })
975 } catch {
976 // No board command this session; the session goes on.
977 }
978 try {
979 // The first scan runs on its own; refresh never throws, and the session doesn't wait for it.
980 void refresh($)
981 if (!ticking) {
982 $.clock.every(TICK_MS, () => {
983 void refresh($)
984 })
985 ticking = true
986 }
987 } catch {
988 // The board stays as it was until the board command opens it.
989 }
990 return next(e)
991 })
992
993 on('command.run', { command: 'brigade-board' }, async $ => {
994 await refresh($)
995 await $.ui.open({ id: PANE, title: 'Brigade board', columns: PANE_COLUMNS })
996 try {
997 await openStage($)
998 } catch {
999 // The board shows without walking until the next open.
1000 }
1001 return { text: 'Board opened.' }
1002 })
1003
1004 on('ui.close', async ($, e, next) => {
1005 const ran = await next(e)
1006 try {
1007 if (e.id === PANE) await closeStage($)
1008 } catch {
1009 // The clock has already stopped; only the stage's open flag may be stale.
1010 }
1011 return ran
1012 })
1013
1014 // Every agent of the session goes on the board as it starts, works and finishes. These hooks
1015 // run for every tool call and model request, so each one does as little as it can and always
1016 // hands the event on unchanged.
1017 on('agent.spawn', async ($, e, next) => {
1018 const ran = await next(e)
1019 try {
1020 // A refused spawn, or one that resolved to nothing, started no agent to put on the board.
1021 if (ran != null && ran.agentId !== undefined) {
1022 await record($, {
1023 type: 'spawn',
1024 id: ran.agentId,
1025 model: ran.model,
1026 description: e.description,
1027 subagentType: e.subagentType,
1028 name: e.name,
1029 prompt: e.prompt,
1030 })
1031 }
1032 } catch {
1033 // The agent stays off the board.
1034 }
1035 return ran
1036 })
1037
1038 on('turn.step', async function* ($, e, next) {
1039 const ran = yield* next(e)
1040 try {
1041 const usage = ran?.usage
1042 const tokens = usage ? usage.input_tokens + usage.output_tokens : 0
1043 // A step without an agent id is the main loop, which is the Planner.
1044 if (e.agentId === undefined) await record($, { type: 'step', id: 'main', model: e.model, tokens }, true)
1045 else await record($, { type: 'step', id: e.agentId, model: e.model, tokens })
1046 } catch {
1047 // The step goes uncounted.
1048 }
1049 return ran
1050 })
1051
1052 on('tool.call', async ($, e, next) => {
1053 const id = e.agentId
1054 const fields = e as unknown as Record<string, unknown>
1055 // What the call does, for saying what its agent is doing and for working out its role.
1056 let act: { tool: unknown; filePath: unknown; command: unknown } | null = null
1057 try {
1058 act = { tool: fields.tool, filePath: fields.file_path, command: fields.command }
1059 // Every call says what its agent is doing, for the agent's whole life: no cap, no state.
1060 const doing = activityOf(act)
1061 if (typeof doing === 'string') activities.set(id ?? 'main', doing)
1062 } catch {
1063 // The agent keeps saying what it said before.
1064 }
1065 if (act === null) return next(e)
1066 try {
1067 // A call without an agent id is the main session's. It works ticket after ticket, so it is
1068 // heard for its whole life, with no cap, but only when the call names a board folder.
1069 if (id === undefined) {
1070 const paths = [fields.file_path, fields.command].filter((value): value is string => typeof value === 'string')
1071 if (onBoard(paths)) await learn($, { type: 'tool', id: 'main', paths, act }, true)
1072 } else {
1073 // Every call counts toward the cap, so past it an agent's tool calls cost nothing more here.
1074 const looks = toolLooks.get(id) ?? 0
1075 if (looks < TOOL_LOOKS) toolLooks.set(id, looks + 1)
1076 if (looks < TOOL_LOOKS) {
1077 const paths = [fields.file_path, fields.command].filter(value => typeof value === 'string')
1078 // A write can tell us the role; reads and mentions never do.
1079 await learn($, { type: 'tool', id, paths, act })
1080 }
1081 }
1082 } catch {
1083 // Nothing learned from this call.
1084 }
1085 return next(e)
1086 })
1087
1088 on('turn.complete', async ($, e, next) => {
1089 try {
1090 if (e.agentId !== undefined) await record($, { type: 'complete', id: e.agentId, reason: e.reason })
1091 } catch {
1092 // The agent keeps its last state on the board.
1093 }
1094 return next(e)
1095 })
1096
1097 on('ui.message', async ($, e, next) => {
1098 if (e.requestId === PANE) {
1099 try {
1100 if (isReady(e.data)) await reportIn($)
1101 else await detailFrom($, e.data)
1102 } catch {
1103 // The detail box, or the region's ready flag, stays as it was until the next post.
1104 }
1105 }
1106 return next(e)
1107 })
1108
1109 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1110 const elements = $.ui.resolve(e)
1111 const { Box, Client, Svg, Text } = elements
1112 // An empty board, drawn as plain lines when the board itself can't be read.
1113 let snapshot: Snapshot = {
1114 project: projectOf({ mode: 'tickets', repo: '', count: 0 }) as Project,
1115 lanes: toWorkLanes([]) as WorkLane[],
1116 agents: [],
1117 weather: null,
1118 messages: [],
1119 learnings: { total: 0, lines: [] },
1120 detail: null,
1121 now: 0,
1122 }
1123 try {
1124 snapshot = await snapshotOf($)
1125 const shown = await read($, stage)
1126 if (e.surface === 'terminal' && !shown.plain) {
1127 drawnAs = 'region'
1128 return <Client key="stage" module="./screen.tsx" width="100%" props={snapshot} />
1129 }
1130 // The terminal whose region never reported in gets the painted board as rows; desktop can't
1131 // load a region at all and gets it as a picture. Either way the detail box, when it is up, is
1132 // already drawn in, and the buttons that open it sit underneath.
1133 if (e.surface === 'terminal') {
1134 const rows = (
1135 <Box flexDirection="column">
1136 {boardRows(snapshot, shown.positions).map(runs => (
1137 <Text>
1138 {runs.map(run => (
1139 <Text color={run.color} backgroundColor={run.backgroundColor} bold={run.bold}>
1140 {run.text}
1141 </Text>
1142 ))}
1143 </Text>
1144 ))}
1145 {detailRow($, elements, snapshot)}
1146 </Box>
1147 )
1148 drawnAs = 'rows'
1149 return rows
1150 }
1151 if (e.surface === 'desktop') {
1152 // A plain image with no size of its own. The app scales it to the pane, and swaps a changed
1153 // one in place; a sized, interactive picture sits in a frame that reloads, and flashes, on
1154 // every change.
1155 const columns = pictureColumns(e.viewport?.columns)
1156 const picture = pictureFor(snapshot, shown, columns)
1157 const drawn = (
1158 <Box flexDirection="column">
1159 <Svg source={picture.source} alt="Brigade board" />
1160 {detailRow($, elements, snapshot)}
1161 </Box>
1162 )
1163 drawnAs = 'picture'
1164 pictureDrawnAt = columns
1165 return drawn
1166 }
1167 } catch {
1168 // Not drawn this time; the plain lines below still show the board, or an empty one when
1169 // the board itself couldn't be read.
1170 }
1171 drawnAs = 'lines'
1172 // Surfaces without a region get the same board as plain lines. Text from files only ever
1173 // goes through Text, so nothing in it can turn into a link or markup, and every line goes
1174 // through plain first. A crowd of agents is listed up to a dozen, then counted.
1175 const listed = snapshot.agents.slice(0, PLAIN_AGENTS)
1176 const unlisted = snapshot.agents.length - listed.length
1177 return (
1178 <Box flexDirection="column">
1179 <Text bold>{plain(snapshot.project.title)}</Text>
1180 <Text>{plain(snapshot.project.detail)}</Text>
1181 <Text>{plain(contextLine(snapshot.weather))}</Text>
1182 {snapshot.lanes.map(lane => (
1183 <Text>{plain(laneLine(lane))}</Text>
1184 ))}
1185 {listed.map(agent => (
1186 <Text>{plain(agentLine(agent))}</Text>
1187 ))}
1188 {(unlisted > 0 ? [`+${unlisted} more`] : []).map(line => (
1189 <Text>{line}</Text>
1190 ))}
1191 {snapshot.messages.slice(0, SHOW_MESSAGES).map(message => (
1192 <Text>{plain(`${message.from} → ${message.to}: ${message.text}`)}</Text>
1193 ))}
1194 </Box>
1195 )
1196 })
1197}
1198hooks/board/lib/board.mjs 138 lines1// Turns the board config and ticket files into lanes of tickets. Pure functions over strings:
2// the caller reads the files, this module only parses and sorts, so it runs anywhere JS does.
3
4// The six lanes the pane shows, left to right, and which ticket statuses land in each.
5export const LANES = [
6 { key: 'backlog', title: 'BACKLOG', statuses: ['backlog', 'scoping', 'design'] },
7 { key: 'todo', title: 'TODO', statuses: ['todo'] },
8 { key: 'in_progress', title: 'COOKING', statuses: ['in_progress'] },
9 { key: 'in_review', title: 'IN REVIEW', statuses: ['in_review'] },
10 { key: 'blocked', title: 'BLOCKED', statuses: ['blocked'] },
11 { key: 'done', title: 'DONE', statuses: ['done'] },
12]
13
14// Splits text into lines, tolerating Windows line endings.
15function linesOf(text) {
16 return String(text ?? '').split(/\r?\n/)
17}
18
19// Drops a trailing `# comment` (a `#` at the start or after whitespace) and trims what is left.
20function stripComment(value) {
21 return value.replace(/(^|\s)#.*$/, '').trim()
22}
23
24// The value of a config bullet like `- source: obsidian # note`, or null when the line is absent.
25function configValue(configText, key) {
26 const pattern = new RegExp('^\\s*-\\s*' + key + '\\s*:(.*)$')
27 for (const line of linesOf(configText)) {
28 const match = line.match(pattern)
29 if (match) return stripComment(match[1])
30 }
31 return null
32}
33
34// The board folder named by `.brigade/config.md`, or null when the config names no usable
35// folder or the board source is something other than obsidian or local.
36export function boardDirFrom(configText) {
37 const source = (configValue(configText, 'source') || '').toLowerCase()
38 if (source !== 'obsidian' && source !== 'local') return null
39 const dir = configValue(configText, 'database_id')
40 return dir ? dir : null
41}
42
43// Reads a quoted value that starts at raw[0]. Returns the unquoted text, or null when the
44// closing quote is missing. Double quotes honour backslash escapes; single quotes use YAML's
45// doubled-quote escape ('it''s').
46function unquote(raw) {
47 const quote = raw[0]
48 let out = ''
49 for (let i = 1; i < raw.length; i++) {
50 const ch = raw[i]
51 if (quote === '"' && ch === '\\' && i + 1 < raw.length) {
52 const next = raw[++i]
53 out += next === 'n' ? '\n' : next === 't' ? '\t' : next
54 } else if (quote === "'" && ch === "'" && raw[i + 1] === "'") {
55 out += "'"
56 i++
57 } else if (ch === quote) {
58 return out
59 } else {
60 out += ch
61 }
62 }
63 return null
64}
65
66// One frontmatter value: quoted values are kept whole, unquoted ones lose a trailing comment.
67function scalar(raw) {
68 const value = raw.trim()
69 if (value[0] === '"' || value[0] === "'") {
70 const inner = unquote(value)
71 if (inner !== null) return inner
72 }
73 return stripComment(value)
74}
75
76// The top-level `key: value` pairs of a frontmatter block, or null when there is no block.
77// Indented lines (nested YAML) and list items are skipped; a key splits on its first colon only.
78function frontmatter(text) {
79 const lines = linesOf(text)
80 if (lines[0].replace(/^/, '').trimEnd() !== '---') return null
81 const end = lines.findIndex((line, i) => i > 0 && line.trimEnd() === '---')
82 if (end === -1) return null
83 const fields = {}
84 for (const line of lines.slice(1, end)) {
85 const match = line.match(/^([A-Za-z_][\w-]*)\s*:(?:\s+(.*))?$/)
86 if (match) fields[match[1]] = scalar(match[2] ?? '')
87 }
88 return fields
89}
90
91// One ticket from a markdown file, or null when the file is not a ticket (an underscore file like
92// `_board.md`, a non-markdown file, or text without frontmatter). Missing fields come back as ''.
93export function parseTicket(text, fileName) {
94 const name = String(fileName ?? '')
95 if (name.startsWith('_') || !name.endsWith('.md')) return null
96 const fields = frontmatter(text)
97 if (!fields) return null
98 const field = (key) => (typeof fields[key] === 'string' ? fields[key] : '')
99 const id = field('id') || name.slice(0, -'.md'.length)
100 return {
101 id,
102 title: field('title') || id,
103 status: field('status'),
104 kind: field('kind'),
105 assignee: field('assignee'),
106 worker: field('worker'),
107 }
108}
109
110// The lane key a status belongs to. Anything the lanes do not list, including '', is backlog.
111export function laneOf(status) {
112 const lane = LANES.find((l) => l.statuses.includes(status))
113 return lane ? lane.key : 'backlog'
114}
115
116// Compares ids as plain strings so the order is the same on every machine.
117function byId(a, b) {
118 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
119}
120
121// All six lanes in order, each with its full ticket count and up to `perLane` tickets to show.
122// Pinned tickets (say, the ones an agent is working) always show and come first, even past the cap.
123export function toLanes(tickets, perLane = 6, pinned = []) {
124 const pins = new Set(pinned)
125 return LANES.map((lane) => {
126 const mine = (tickets || []).filter((t) => t && laneOf(t.status) === lane.key)
127 const first = mine.filter((t) => pins.has(t.id)).sort(byId)
128 const rest = mine.filter((t) => !pins.has(t.id)).sort(byId)
129 const room = Math.max(0, perLane - first.length)
130 return {
131 key: lane.key,
132 title: lane.title,
133 total: mine.length,
134 tickets: first.concat(rest.slice(0, room)),
135 }
136 })
137}
138hooks/board/lib/board-layout.mjs 457 lines1// Works out where everything on the task board goes, in character cells, for a pane of a given
2// width: the header and its context meter, the five lanes of cards with the agents working them,
3// the crew standing aside, the Messages and Learnings panels, the legend and the detail box.
4// It only places things and clips their text to fit; drawing happens elsewhere.
5//
6// Every string it hands back has been through safeText and fits the cells it was given, because
7// ids, titles, names and messages all come from files. Ids too: a card, lane, agent or message id
8// comes back in its safe form, and agents are matched to cards on that form. So the painter looks
9// an agent up as homes[safeText(id)], and two ids that differ only by a control character or an
10// invisible mark are the same id to the board.
11
12import { SIZES, sizeOf, ROLES, FAMILIES } from './sprites.mjs'
13import { cellWidth, clip, safeText } from './canvas.mjs'
14
15// Narrowest pane we lay out for; anything smaller is treated as this wide.
16const MIN_COLUMNS = 24
17// Header rows 0 to 2, a blank row 3, then the lanes.
18const LANES_TOP = 4
19// The context meter: an 8-cell cloud, a space, then 12 cells of text with the gauge under it.
20const METER_W = 21
21const METER_TEXT_W = 12
22const ICON_W = 8
23// Below this the meter drops its cloud.
24const METER_ICON_MIN = 40
25// The header text stops this many cells short of the meter.
26const METER_GAP = 2
27const LANE_MAX_W = 30
28const MAX_LANES_PER_ROW = 5
29// Below this the two panels stack instead of sitting side by side.
30const PANELS_SIDE_MIN = 70
31const MAX_MESSAGES = 4
32const MAX_LEARNINGS = 5
33const MODAL_MAX_W = 72
34// A board showing the detail box is never shorter than this, so the box has room to say something.
35const MODAL_MIN_ROWS = 12
36// Cells between slots side by side in the crew, which has the whole width to use.
37const CREW_SLOT_GAP = 2
38// Blank rows between one row of crew slots and the next, so sprites in different rows never touch.
39const CREW_ROW_GAP = 1
40const ELLIPSIS = '…'
41
42function isObject(value) {
43 return value !== null && typeof value === 'object'
44}
45
46function list(value) {
47 return Array.isArray(value) ? value : []
48}
49
50// Outside values as text: strings as they are, numbers and booleans spelled out, anything else empty.
51function text(value) {
52 if (typeof value === 'string') return value
53 if (typeof value === 'number' || typeof value === 'boolean') return String(value)
54 return ''
55}
56
57// An id as the board keeps it: its text made safe, like every other string handed back.
58function idOf(value) {
59 return safeText(text(value))
60}
61
62function widthOf(columns) {
63 return typeof columns === 'number' && Number.isFinite(columns) ? Math.max(MIN_COLUMNS, Math.floor(columns)) : MIN_COLUMNS
64}
65
66// Breaks a word too wide for a whole line into line-sized pieces, one walk over its characters.
67function pieces(word, width) {
68 const out = []
69 let piece = ''
70 let used = 0
71 for (const ch of word) {
72 const w = cellWidth(ch)
73 // A character wider than the whole line can never be drawn, so it is left out.
74 if (w > width) continue
75 if (used + w > width) {
76 out.push(piece)
77 piece = ''
78 used = 0
79 }
80 piece += ch
81 used += w
82 }
83 if (piece !== '') out.push(piece)
84 return out
85}
86
87// The safe form of `value` broken into lines at most `width` cells wide, at spaces where it can
88// be, and inside a word only when the word alone is wider than a line. Stops once it has `limit`
89// lines plus one, which is enough to know the text was cut.
90function wrap(value, width, limit = Infinity) {
91 const lines = []
92 let line = ''
93 let used = 0
94 for (const word of safeText(value).split(' ')) {
95 if (lines.length > limit) break
96 if (word === '') continue
97 const w = cellWidth(word)
98 if (line !== '' && used + 1 + w <= width) {
99 line += ` ${word}`
100 used += 1 + w
101 continue
102 }
103 if (line !== '') lines.push(line)
104 if (w <= width) {
105 line = word
106 used = w
107 continue
108 }
109 const parts = pieces(word, width)
110 line = parts.pop() ?? ''
111 used = cellWidth(line)
112 lines.push(...parts)
113 }
114 if (line !== '') lines.push(line)
115 return lines
116}
117
118// At most `max` wrapped lines; when there were more, the last kept line takes as much of the rest
119// as fits and ends in '…' so a reader can see the text goes on.
120function fitLines(value, width, max) {
121 const lines = wrap(value, width, max)
122 if (lines.length <= max) return lines
123 const kept = lines.slice(0, max - 1)
124 kept.push(clip(lines.slice(max - 1).join(' '), width - 1).trimEnd() + ELLIPSIS)
125 return kept
126}
127
128function roleOf(agent) {
129 // Own keys only, so a role like 'toString' can't pick up an Object method.
130 return Object.hasOwn(ROLES, agent.role) ? ROLES[agent.role] : ROLES.agent
131}
132
133// '♨ Miso · cook': the role's mark, the agent's name, the role's label.
134function nameLine(agent) {
135 const role = roleOf(agent)
136 return `${role.mark} ${text(agent.name)} · ${role.label}`
137}
138
139// What the agent is doing, or how it ended when it has nothing to say.
140function activityLine(agent) {
141 const said = text(agent.activity)
142 if (safeText(said).trim() !== '') return said
143 if (agent.state === 'done') return 'finished'
144 if (agent.state === 'failed') return 'failed'
145 return ''
146}
147
148// The agents the board can place: one per id, the first one listed wins, so two can never share a home.
149function uniqueAgents(value) {
150 const seen = new Set()
151 const out = []
152 for (const agent of list(value)) {
153 if (!isObject(agent) || (typeof agent.id !== 'string' && typeof agent.id !== 'number')) continue
154 const id = idOf(agent.id)
155 if (seen.has(id)) continue
156 seen.add(id)
157 out.push({ id, agent })
158 }
159 return out
160}
161
162// Turns measured slot items into rows, left to right, starting a new row when the next one won't
163// fit in `room` cells. Each item gets `dx`, its offset from the left edge.
164function intoRows(items, room, gap) {
165 const rows = []
166 let row = null
167 for (const item of items) {
168 if (row && row.used + gap + item.w <= room) {
169 item.dx = row.used + gap
170 row.used += gap + item.w
171 row.items.push(item)
172 } else {
173 item.dx = 0
174 row = { items: [item], used: item.w }
175 rows.push(row)
176 }
177 }
178 return rows
179}
180
181// The agents standing on one card, placed inside its border from (left, top) with `room` cells
182// across, one agent to a row. The name and activity go to the right of the sprite when the whole
183// name fits there, else under it, so a name is never cut just to sit beside its sprite.
184function cardSlots(agents, left, top, room) {
185 const slots = []
186 let y = top
187 for (const { id, agent } of agents) {
188 const size = SIZES[sizeOf(agent.model)]
189 const fullName = nameLine(agent)
190 const beside = size.w + 1 + cellWidth(fullName) <= room
191 const textRoom = beside ? room - size.w - 1 : room
192 // Beside, the name shares the sprite's row and the activity goes on the next; under it, the
193 // two lines follow the sprite.
194 const textX = beside ? left + size.w + 1 : left
195 const textY = beside ? y : y + size.h
196 slots.push({
197 agentId: id, x: left, y, w: size.w, h: size.h,
198 name: { x: textX, y: textY, text: clip(fullName, textRoom) },
199 activity: { x: textX, y: textY + 1, text: clip(activityLine(agent), textRoom) },
200 })
201 y += beside ? Math.max(size.h, 2) : size.h + 2
202 }
203 return { slots, height: y - top }
204}
205
206// One card at (x, y), `w` wide: the id, up to two title lines, the tag, then the agents on it.
207function placeCard(source, agents, x, y, w) {
208 const room = w - 2
209 const idText = clip(text(source.id), room)
210 const titleLines = fitLines(text(source.title), room, 2)
211 const rawTag = text(source.tag)
212 const tag = safeText(rawTag).trim() === '' ? null : clip(rawTag, room)
213 const above = 1 + titleLines.length + (tag === null ? 0 : 1)
214 const placed = cardSlots(agents, x + 1, y + 1 + above, room)
215 const inner = Math.max(2, above + placed.height)
216 return {
217 id: idOf(source.id), x, y, w, h: inner + 2, idText, titleLines, tag, alert: source.alert === true, slots: placed.slots,
218 }
219}
220
221// The crew: agents with no card on the board, left to right from `top`, wrapping onto more rows.
222// The name and activity go to the right of the sprite when the whole name fits there, else under it.
223function crewSlots(agents, top, columns) {
224 const items = agents.map(({ id, agent }) => {
225 const size = SIZES[sizeOf(agent.model)]
226 const fullName = nameLine(agent)
227 const beside = size.w + 1 + cellWidth(fullName) <= columns
228 const room = beside ? columns - size.w - 1 : columns
229 const name = clip(fullName, room)
230 const activity = clip(activityLine(agent), room)
231 const textW = Math.max(cellWidth(name), cellWidth(activity))
232 const w = beside ? size.w + 1 + textW : Math.min(columns, Math.max(size.w, textW))
233 return { id, size, name, activity, beside, w }
234 })
235 const slots = []
236 let y = top
237 for (const [i, row] of intoRows(items, columns, CREW_SLOT_GAP).entries()) {
238 if (i > 0) y += CREW_ROW_GAP
239 // Every slot in a row starts on the row's top line, and the row is as tall as its tallest slot.
240 let rowH = 0
241 for (const item of row.items) {
242 const x = item.dx
243 // Beside the sprite the name shares its row and the activity goes on the next; under it,
244 // the two lines follow the sprite.
245 const textX = item.beside ? x + item.size.w + 1 : x
246 const textY = item.beside ? y : y + item.size.h
247 slots.push({
248 agentId: item.id, x, y, w: item.size.w, h: item.size.h,
249 name: { x: textX, y: textY, text: item.name },
250 activity: { x: textX, y: textY + 1, text: item.activity },
251 })
252 rowH = Math.max(rowH, item.beside ? Math.max(item.size.h, 2) : item.size.h + 2)
253 }
254 y += rowH
255 }
256 return { slots, height: y - top }
257}
258
259// Rows 0 to 2 and the context meter at the top right of rows 0 and 1.
260function placeHeader(project, columns) {
261 const p = isObject(project) ? project : {}
262 const narrow = columns < METER_ICON_MIN
263 const w = narrow ? METER_TEXT_W : METER_W
264 const x = columns - w
265 const room = x - METER_GAP
266 const top = [text(p.repo), text(p.branch)].filter((part) => part !== '').join(' · ')
267 return {
268 y: 0,
269 repo: clip(top, room),
270 title: clip(text(p.title), room),
271 detail: clip(text(p.detail), room),
272 meter: { x, y: 0, w, icon: narrow ? null : { x }, text: { x: narrow ? x : x + ICON_W + 1 } },
273 }
274}
275
276// Messages and Learnings from row `top`: side by side when the pane is wide enough, else stacked.
277// Text inside a panel is clipped to its width less the border and a cell of padding each side.
278function placePanels(snap, top, columns) {
279 const side = columns >= PANELS_SIDE_MIN
280 const mW = side ? Math.floor(columns * 0.6) : columns
281 const lW = side ? columns - mW - 1 : columns
282 const lX = side ? mW + 1 : 0
283
284 const shownMessages = list(snap.messages).filter(isObject).slice(0, MAX_MESSAGES)
285 const source = isObject(snap.learnings) ? snap.learnings : {}
286 const all = list(source.lines)
287 const lines = all.slice(0, MAX_LEARNINGS).map((line) => clip(text(line), lW - 4))
288 const total = Number.isFinite(source.total) ? Math.max(Math.floor(source.total), lines.length) : all.length
289 const more = total - lines.length
290
291 // An empty panel keeps one blank row inside its border for the painter's "nothing here" line.
292 const mH = 2 + Math.max(1, shownMessages.length * 2)
293 const lH = 2 + Math.max(1, lines.length + (more > 0 ? 1 : 0))
294 const mY = top
295 const lY = side ? top : top + mH + 1
296 const height = side ? Math.max(mH, lH) : null
297
298 const rows = shownMessages.map((m, i) => ({
299 id: idOf(m.id),
300 y: mY + 1 + i * 2,
301 head: clip(`${text(m.from)} → ${text(m.to)}`, mW - 4),
302 text: clip(text(m.text), mW - 4),
303 }))
304 const messages = { x: 0, y: mY, w: mW, h: height ?? mH, rows }
305 const learnings = { x: lX, y: lY, w: lW, h: height ?? lH, lines, more }
306 return { messages, learnings, bottom: Math.max(mY + messages.h, lY + learnings.h) }
307}
308
309// The legend, right-aligned on row `y`. When the pane is too narrow for the whole line it drops the
310// 'Color: ' label, then the dots, so the four family words always show.
311function placeLegend(y, columns) {
312 const words = FAMILIES.map((f) => f.label)
313 const shapes = [['Color: ', ' · '], ['', ' · '], ['', ' ']]
314 const fits = shapes.find(([label, sep]) => cellWidth(label + words.join(sep)) <= columns) ?? shapes.at(-1)
315 const [label, sep] = fits
316 const legendText = label + words.join(sep)
317 const x = columns - cellWidth(legendText)
318 const spans = []
319 let at = x + cellWidth(label)
320 for (const family of FAMILIES) {
321 const w = cellWidth(family.label)
322 spans.push({ x: at, w, family: family.key })
323 at += w + cellWidth(sep)
324 }
325 return { y, x, text: legendText, spans }
326}
327
328// The detail box, centred on a board `rows` tall. Lines are wrapped to the box less its border and
329// padding; when they don't all fit, the last one that would show becomes '…'.
330function placeModal(detail, columns, rows) {
331 const w = Math.min(columns - 4, MODAL_MAX_W)
332 const width = w - 4
333 const room = rows - 2 - 4
334 const wrapped = []
335 for (const line of list(detail.lines)) {
336 if (wrapped.length > room) break
337 const parts = wrap(text(line), width, room)
338 // A blank line stays, so paragraphs keep their spacing.
339 wrapped.push(...(parts.length > 0 ? parts : ['']))
340 }
341 const lines = wrapped.length <= room ? wrapped : [...wrapped.slice(0, room - 1), ELLIPSIS]
342 const h = 4 + lines.length
343 return { x: Math.floor((columns - w) / 2), y: Math.floor((rows - h) / 2), w, h, title: clip(text(detail.title), width), lines }
344}
345
346// Everything the painter needs to draw the board `columns` cells wide, in cells. Same input, same output.
347export function arrange(snapshot, columns) {
348 const width = widthOf(columns)
349 const snap = isObject(snapshot) ? snapshot : {}
350 const header = placeHeader(snap.project, width)
351
352 // Which agents stand on which card: the first card on the board with the agent's card id.
353 const sourceLanes = list(snap.lanes).filter(isObject)
354 const onBoard = new Set()
355 for (const lane of sourceLanes) for (const c of list(lane.cards)) if (isObject(c)) onBoard.add(idOf(c.id))
356 const waiting = new Map()
357 const crewAgents = []
358 for (const entry of uniqueAgents(snap.agents)) {
359 const card = entry.agent.card
360 const key = card == null ? null : idOf(card)
361 if (key !== null && onBoard.has(key)) {
362 if (!waiting.has(key)) waiting.set(key, [])
363 waiting.get(key).push(entry)
364 } else {
365 crewAgents.push(entry)
366 }
367 }
368 const takeAgents = (id) => {
369 const here = waiting.get(id) ?? []
370 waiting.delete(id)
371 return here
372 }
373
374 // Lanes, side by side in bands; a band starts one blank row below the tallest lane above it.
375 const perRow = Math.min(MAX_LANES_PER_ROW, Math.max(1, Math.floor((width + 1) / 21)))
376 const laneW = Math.min(LANE_MAX_W, Math.floor((width - (perRow - 1)) / perRow))
377 const lanes = []
378 let next = LANES_TOP
379 for (let first = 0; first < sourceLanes.length; first += perRow) {
380 const bandTop = next
381 let bandBottom = bandTop
382 for (let j = 0; j < perRow && first + j < sourceLanes.length; j++) {
383 const source = sourceLanes[first + j]
384 const x = j * (laneW + 1)
385 let y = bandTop + 1
386 const cards = list(source.cards).filter(isObject).map((c) => {
387 const placed = placeCard(c, takeAgents(idOf(c.id)), x, y, laneW)
388 y += placed.h
389 return placed
390 })
391 // The lanes arrive capped: `total` may count cards not shown, never fewer than are.
392 const total = Number.isFinite(source.total) ? Math.max(Math.floor(source.total), cards.length) : cards.length
393 const more = total - cards.length
394 if (more > 0) y += 1
395 lanes.push({ key: idOf(source.key), title: clip(text(source.title), laneW), total, x, y: bandTop, w: laneW, more, cards })
396 bandBottom = Math.max(bandBottom, y)
397 }
398 next = bandBottom + 1
399 }
400
401 // The crew label goes on the first free row; its slots start on the row under it.
402 let crew = null
403 if (crewAgents.length > 0) {
404 const placed = crewSlots(crewAgents, next + 1, width)
405 crew = { y: next, slots: placed.slots }
406 next += 1 + placed.height + 1
407 }
408
409 const panels = placePanels(snap, next, width)
410 const detail = isObject(snap.detail) ? snap.detail : null
411 // The legend takes the row after the panels; a board with the detail box up is at least 12 rows.
412 const rows = Math.max(panels.bottom + 1, detail ? MODAL_MIN_ROWS : 0)
413 const legend = placeLegend(rows - 1, width)
414 const modal = detail ? placeModal(detail, width, rows) : null
415
416 const homes = []
417 const obstacles = []
418 const allCards = lanes.flatMap((lane) => lane.cards)
419 for (const s of [...allCards.flatMap((c) => c.slots), ...(crew ? crew.slots : [])]) {
420 homes.push([s.agentId, { x: s.x, y: s.y, w: s.w, h: s.h }])
421 obstacles.push({ x: s.name.x, y: s.name.y, w: cellWidth(s.name.text), h: 1 })
422 obstacles.push({ x: s.activity.x, y: s.activity.y, w: cellWidth(s.activity.text), h: 1 })
423 }
424
425 // Click targets, later ones on top: cards, messages, then the detail box's three.
426 const regions = [
427 ...allCards.map((c) => ({ kind: 'card', id: c.id, x: c.x, y: c.y, w: c.w, h: c.h })),
428 ...panels.messages.rows.map((row) => ({
429 kind: 'message', id: row.id, x: panels.messages.x + 1, y: row.y, w: panels.messages.w - 2, h: 2,
430 })),
431 ]
432 if (modal) {
433 regions.push(
434 // A click anywhere off the box closes it, a click on the box does nothing, a click on [x] closes it.
435 { kind: 'close', id: 'close', x: 0, y: 0, w: width, h: rows },
436 { kind: 'modal', id: 'modal', x: modal.x, y: modal.y, w: modal.w, h: modal.h },
437 { kind: 'close', id: 'close', x: modal.x + modal.w - 4, y: modal.y, w: 3, h: 1 },
438 )
439 }
440
441 return {
442 columns: width,
443 rows,
444 header,
445 lanes,
446 crew,
447 messages: panels.messages,
448 learnings: panels.learnings,
449 legend,
450 // Built from entries so an agent id like '__proto__' is just another key.
451 homes: Object.fromEntries(homes),
452 obstacles,
453 regions,
454 modal,
455 }
456}
457hooks/board/lib/board-paint.mjs 295 lines1// Draws one frame of the task board in character cells: the project header and its context meter,
2// the five lanes of ticket cards, the agents as pixel sprites on the cards they work, the crew, the
3// Messages and Learnings panels, the legend, the hover card and the detail box. Where everything
4// goes comes from the layout; this file only decides how it looks. The result is rows of coloured
5// text runs a pane can print as they are, plus the click regions that match what was drawn.
6
7import { arrange } from './board-layout.mjs'
8import { createCanvas, putText, putSprite, toRuns, cellWidth, clip, safeText } from './canvas.mjs'
9import { SPRITES, sizeOf, ROLES, PALETTE, FAMILIES, CLOUD, colorOf } from './sprites.mjs'
10import { gauge } from './weather.mjs'
11import { cardLines } from './stage.mjs'
12
13const ROUND = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }
14const DOUBLE = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' }
15const GAUGE_W = 10
16
17function isObject(value) {
18 return value !== null && typeof value === 'object'
19}
20
21function isCell(p) {
22 return isObject(p) && Number.isFinite(p.x) && Number.isFinite(p.y)
23}
24
25// An id the way the layout keeps it, so a lookup from the view matches what the layout handed back.
26function idOf(value) {
27 return typeof value === 'string' || typeof value === 'number' ? safeText(String(value)) : null
28}
29
30// A box outline `w` by `h` at (x, y) in one colour. The inside is left alone.
31function putBox(canvas, x, y, w, h, edges, style) {
32 if (w < 2 || h < 2) return
33 const across = edges.h.repeat(w - 2)
34 putText(canvas, x, y, edges.tl + across + edges.tr, style)
35 putText(canvas, x, y + h - 1, edges.bl + across + edges.br, style)
36 for (let row = y + 1; row < y + h - 1; row++) {
37 putText(canvas, x, row, edges.v, style)
38 putText(canvas, x + w - 1, row, edges.v, style)
39 }
40}
41
42// Paints the inside of a box (everything but its border) with blanks in `bg`.
43function fillInside(canvas, x, y, w, h, bg) {
44 if (w < 3 || h < 3) return
45 const blank = ' '.repeat(w - 2)
46 for (let row = y + 1; row < y + h - 1; row++) putText(canvas, x + 1, row, blank, { color: bg, backgroundColor: bg })
47}
48
49// The colour of one cloud pixel: '#' is cloud, '*' is lightning, anything else is empty.
50function cloudColor(pixel) {
51 if (pixel === '#') return PALETTE.ink
52 if (pixel === '*') return PALETTE.bolt
53 return null
54}
55
56// The cloud, one cell at a time. A cell holds two pixels stacked, and the cloud has two colours,
57// so a cell with cloud on top and lightning below needs both a foreground and a background.
58function paintCloud(canvas, x, y) {
59 for (let p = 0; p < CLOUD.length; p += 2) {
60 const upper = CLOUD[p] ?? ''
61 const lower = CLOUD[p + 1] ?? ''
62 for (let i = 0; i < Math.max(upper.length, lower.length); i++) {
63 const top = cloudColor(upper[i])
64 const bottom = cloudColor(lower[i])
65 if (top === null && bottom === null) continue
66 let cell
67 if (top !== null && top === bottom) cell = ['█', { color: top, backgroundColor: PALETTE.field }]
68 else if (bottom === null) cell = ['▀', { color: top, backgroundColor: PALETTE.field }]
69 else if (top === null) cell = ['▄', { color: bottom, backgroundColor: PALETTE.field }]
70 else cell = ['▀', { color: top, backgroundColor: bottom }]
71 putText(canvas, x + i, y + p / 2, cell[0], cell[1])
72 }
73 }
74}
75
76// The gauge and percent colour: calm below half full, a warning from half, alarm from three quarters.
77function bandColor(percent) {
78 if (percent >= 75) return PALETTE.alert
79 if (percent >= 50) return PALETTE.bolt
80 return PALETTE.header
81}
82
83// The context meter: the cloud, `Context 61%` on row 0 and its gauge on row 1. With no reading it
84// says `Context --` and draws no gauge. It never names the weather.
85function paintMeter(canvas, meter, weather) {
86 if (meter.icon) paintCloud(canvas, meter.icon.x, meter.y)
87 const raw = isObject(weather) ? weather.percent : null
88 if (typeof raw !== 'number' || !Number.isFinite(raw)) {
89 putText(canvas, meter.text.x, meter.y, 'Context --', { color: PALETTE.dim })
90 return
91 }
92 const percent = Math.round(Math.min(100, Math.max(0, raw)))
93 const color = bandColor(percent)
94 putText(canvas, meter.text.x, meter.y, 'Context ', { color: PALETTE.ink })
95 putText(canvas, meter.text.x + 8, meter.y, `${percent}%`, { color })
96 putText(canvas, meter.text.x, meter.y + 1, gauge(percent, GAUGE_W), { color })
97}
98
99function paintHeader(canvas, header, weather) {
100 putText(canvas, 0, header.y, header.repo, { color: PALETTE.dim })
101 putText(canvas, 0, header.y + 1, header.title, { color: PALETTE.ink, bold: true })
102 putText(canvas, 0, header.y + 2, header.detail, { color: PALETTE.dim })
103 paintMeter(canvas, header.meter, weather)
104}
105
106// A slot's name and activity lines. On a card they keep the card's background.
107function paintSlotText(canvas, slot, hovered, bg) {
108 putText(canvas, slot.name.x, slot.name.y, slot.name.text, { color: PALETTE.ink, backgroundColor: bg, bold: slot.agentId === hovered })
109 putText(canvas, slot.activity.x, slot.activity.y, slot.activity.text, { color: PALETTE.dim, backgroundColor: bg })
110}
111
112// One card: a rounded border, the card colour inside, the id, the title, the tag as a pill, and the
113// name and activity of each agent on it. The border lights up when the pointer is over the card.
114function paintCard(canvas, card, view) {
115 const edge = card.id === view.over ? PALETTE.ink : card.alert ? PALETTE.alert : PALETTE.cardEdge
116 fillInside(canvas, card.x, card.y, card.w, card.h, PALETTE.card)
117 putBox(canvas, card.x, card.y, card.w, card.h, ROUND, { color: edge, backgroundColor: PALETTE.field })
118 const x = card.x + 1
119 const room = card.w - 2
120 const onCard = { backgroundColor: PALETTE.card }
121 putText(canvas, x, card.y + 1, card.idText, { ...onCard, color: PALETTE.dim })
122 card.titleLines.forEach((line, i) => putText(canvas, x, card.y + 2 + i, line, { ...onCard, color: PALETTE.ink }))
123 if (card.tag !== null) {
124 // A space either side when there is room, so the pill reads as a pill.
125 const pill = cellWidth(card.tag) + 2 <= room ? ` ${card.tag} ` : card.tag
126 putText(canvas, x, card.y + 2 + card.titleLines.length, pill, {
127 color: PALETTE.field, backgroundColor: card.alert ? PALETTE.alert : PALETTE.dim,
128 })
129 }
130 for (const slot of card.slots) paintSlotText(canvas, slot, view.hovered, PALETTE.card)
131}
132
133// A lane: its header row, its cards stacked under it, and `+N more` when it holds more than it shows.
134function paintLane(canvas, lane, view) {
135 const shouting = lane.key === 'rework' && lane.total > 0
136 putText(canvas, lane.x, lane.y, clip(`${lane.title} ${lane.total}`, lane.w), { color: shouting ? PALETTE.alert : PALETTE.header })
137 for (const card of lane.cards) paintCard(canvas, card, view)
138 if (lane.more > 0) {
139 const y = lane.y + 1 + lane.cards.reduce((sum, card) => sum + card.h, 0)
140 putText(canvas, lane.x, y, clip(`+${lane.more} more`, lane.w), { color: PALETTE.dim })
141 }
142}
143
144function paintCrew(canvas, crew, view) {
145 if (!crew) return
146 putText(canvas, 0, crew.y, 'Crew', { color: PALETTE.dim })
147 for (const slot of crew.slots) paintSlotText(canvas, slot, view.hovered, undefined)
148}
149
150// A panel's rounded border with its title set into the top edge.
151function paintPanel(canvas, panel, title) {
152 putBox(canvas, panel.x, panel.y, panel.w, panel.h, ROUND, { color: PALETTE.cardEdge })
153 putText(canvas, panel.x + 2, panel.y, clip(` ${title} `, panel.w - 4), { color: PALETTE.header })
154}
155
156function paintMessages(canvas, panel, view) {
157 paintPanel(canvas, panel, 'Messages')
158 const x = panel.x + 2
159 if (panel.rows.length === 0) {
160 putText(canvas, x, panel.y + 1, clip('No messages yet', panel.w - 4), { color: PALETTE.dim })
161 return
162 }
163 for (const row of panel.rows) {
164 const over = row.id === view.over
165 putText(canvas, x, row.y, row.head, { color: PALETTE.ink, bold: true })
166 putText(canvas, x, row.y + 1, row.text, over ? { color: PALETTE.ink, bold: true } : { color: PALETTE.dim })
167 }
168}
169
170function paintLearnings(canvas, panel) {
171 paintPanel(canvas, panel, 'Learnings in play')
172 const x = panel.x + 2
173 const room = panel.w - 4
174 if (panel.lines.length === 0 && panel.more <= 0) {
175 putText(canvas, x, panel.y + 1, clip('None recorded', room), { color: PALETTE.dim })
176 return
177 }
178 panel.lines.forEach((line, i) => putText(canvas, x, panel.y + 1 + i, line, { color: PALETTE.ink }))
179 if (panel.more > 0) putText(canvas, x, panel.y + 1 + panel.lines.length, clip(`+${panel.more} more`, room), { color: PALETTE.dim })
180}
181
182// The legend in dim, then each family word again in its own colour.
183function paintLegend(canvas, legend) {
184 putText(canvas, legend.x, legend.y, legend.text, { color: PALETTE.dim })
185 for (const span of legend.spans) {
186 const family = FAMILIES.find((f) => f.key === span.family)
187 if (family) putText(canvas, span.x, legend.y, family.label, { color: family.color })
188 }
189}
190
191// Where the hover card's top-left corner goes: above the sprite when there is room, else below it,
192// else beside it (right when it fits, otherwise left), always pulled inside the canvas.
193function hoverSpot(canvas, box, cardW, cardH) {
194 const across = Math.max(0, Math.min(box.x, canvas.columns - cardW))
195 if (box.y - cardH >= 0) return { x: across, y: box.y - cardH }
196 const below = box.y + box.h
197 if (below + cardH <= canvas.rows) return { x: across, y: below }
198 const right = box.x + box.w + 1
199 const x = right + cardW <= canvas.columns ? right : Math.max(0, Math.min(box.x - 1 - cardW, canvas.columns - cardW))
200 const y = Math.max(0, Math.min(box.y, canvas.rows - cardH))
201 return { x, y }
202}
203
204// The hover card: four lines on a light block, every line padded to the same width so the card is
205// a clean rectangle, and cut at the right edge when the pane is narrower than the card.
206function paintHover(canvas, agent, box, now) {
207 const role = Object.hasOwn(ROLES, agent.role) ? ROLES[agent.role] : ROLES.agent
208 const lines = cardLines(agent, role.label, now).map((line) => safeText(line ?? ''))
209 const cardW = Math.max(...lines.map(cellWidth)) + 2
210 const spot = hoverSpot(canvas, box, cardW, lines.length)
211 const room = Math.min(cardW, canvas.columns - spot.x)
212 lines.forEach((line, i) => {
213 const shown = clip(` ${line}`, room)
214 putText(canvas, spot.x, spot.y + i, shown + ' '.repeat(room - cellWidth(shown)), {
215 color: PALETTE.field, backgroundColor: PALETTE.ink,
216 })
217 })
218}
219
220// The detail box: a filled card-coloured box with a double border, its title, [x] and its lines.
221function paintModal(canvas, modal) {
222 const { x, y, w, h } = modal
223 fillInside(canvas, x, y, w, h, PALETTE.card)
224 putBox(canvas, x, y, w, h, DOUBLE, { color: PALETTE.ink, backgroundColor: PALETTE.card })
225 putText(canvas, x + w - 4, y, '[x]', { color: PALETTE.header, backgroundColor: PALETTE.card })
226 putText(canvas, x + 2, y + 1, modal.title, { color: PALETTE.ink, backgroundColor: PALETTE.card, bold: true })
227 modal.lines.forEach((line, i) => putText(canvas, x + 2, y + 3 + i, line, { color: PALETTE.ink, backgroundColor: PALETTE.card }))
228}
229
230// The agents by the id the layout gave them, the first listed winning, as the layout does.
231function agentsById(snapshot) {
232 const byId = new Map()
233 for (const agent of Array.isArray(snapshot.agents) ? snapshot.agents : []) {
234 if (!isObject(agent)) continue
235 const id = idOf(agent.id)
236 if (id !== null && !byId.has(id)) byId.set(id, agent)
237 }
238 return byId
239}
240
241/**
242 * Draws one frame of the task board.
243 * @param snapshot the board state: project, lanes, agents, weather, messages, learnings, detail, now
244 * @param view what is moving and under the pointer: { positions, frame, hovered, over }
245 * @param columns how wide the pane is
246 * @return { rows, regions, height }: coloured runs per row, click regions (sprites after the cards
247 * and messages, before the detail box's, each with the frame it was drawn on), and the row count
248 */
249export function draw(snapshot, view, columns) {
250 const snap = isObject(snapshot) ? snapshot : {}
251 const v = isObject(view) ? view : {}
252 const positions = isObject(v.positions) ? v.positions : {}
253 const frame = v.frame === 1 ? 1 : 0
254 const look = { hovered: idOf(v.hovered), over: idOf(v.over) }
255 const plan = arrange(snap, columns)
256 const canvas = createCanvas(plan.columns, plan.rows, PALETTE.field)
257
258 paintHeader(canvas, plan.header, snap.weather)
259 for (const lane of plan.lanes) paintLane(canvas, lane, look)
260 paintCrew(canvas, plan.crew, look)
261 paintMessages(canvas, plan.messages, look)
262 paintLearnings(canvas, plan.learnings)
263 paintLegend(canvas, plan.legend)
264
265 // Sprites go on after everything else so nothing hides them, each where it is walking or at home.
266 // Movement on the board means something happened, so a sprite settled at home stands still on
267 // frame 0 and only a walking one shows the view's frame.
268 const agents = agentsById(snap)
269 const sprites = []
270 for (const [id, home] of Object.entries(plan.homes)) {
271 const agent = agents.get(id)
272 if (!agent) continue
273 const live = Object.hasOwn(positions, id) && isCell(positions[id]) ? positions[id] : home
274 const box = { x: Math.floor(live.x), y: Math.floor(live.y), w: home.w, h: home.h }
275 const shown = box.x === home.x && box.y === home.y ? 0 : frame
276 putSprite(canvas, box.x, box.y, SPRITES[sizeOf(agent.model)][shown], colorOf(agent.role, agent.state, agent.model))
277 sprites.push({ kind: 'agent', id, ...box, frame: shown })
278 }
279
280 if (look.hovered !== null) {
281 const agent = agents.get(look.hovered)
282 const box = sprites.find((s) => s.id === look.hovered)
283 if (agent && box) paintHover(canvas, agent, box, snap.now)
284 }
285
286 if (plan.modal) paintModal(canvas, plan.modal)
287
288 // Sprites sit above the cards under them for clicks, and below the detail box.
289 const firstClose = plan.regions.findIndex((r) => r.kind === 'close')
290 const at = firstClose === -1 ? plan.regions.length : firstClose
291 const regions = [...plan.regions.slice(0, at), ...sprites, ...plan.regions.slice(at)]
292
293 return { rows: toRuns(canvas), regions, height: canvas.rows }
294}
295hooks/board/lib/board-svg.mjs 316 lines1// Turns one drawn board (the painter's rows of coloured text runs) into an SVG picture. The
2// desktop app can't load the board's drawing region and prints plain text in a proportional font,
3// so a picture is how the board shows there. Every character is pinned to its own cell, so the
4// grid lines up whatever font the reader has.
5//
6// Card titles, agent names and messages come from files and tool calls, and the picture ends up
7// in a browser frame, so every piece of text is escaped, characters XML refuses are drawn as
8// blanks, and colours are only used when they are plain hex. The markup uses only svg, rect,
9// style, text and path; nothing here ever writes a script, an event handler, a link or a url().
10
11import { PALETTE, ART } from './sprites.mjs'
12import { cellWidth } from './canvas.mjs'
13
14export const CELL_W = 9
15export const CELL_H = 18
16// The app refuses a picture whose source is longer than this.
17export const SVG_MAX = 131072
18
19const FONT = 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace'
20// Where the text baseline sits inside a cell, from the cell's top.
21const BASELINE = 13
22const TOO_LARGE = 'Board too large to draw'
23
24// Block characters as rectangles inside a cell: [x, y, w, h] in pixels.
25const BLOCKS = {
26 '█': [0, 0, CELL_W, CELL_H],
27 '▀': [0, 0, CELL_W, CELL_H / 2],
28 '▄': [0, CELL_H / 2, CELL_W, CELL_H / 2],
29 '▌': [0, 0, CELL_W / 2, CELL_H],
30 '▐': [CELL_W / 2, 0, CELL_W / 2, CELL_H],
31}
32const GAUGE = new Set(['▰', '▱'])
33
34const COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
35// A character that takes a cell but must not be drawn as text: every control character (as the
36// canvas treats them) plus everything XML refuses, which includes U+FFFE, U+FFFF and half of a
37// surrogate pair.
38const BLANK = /^(?:[\u0000-\u001f\u007f-\u009f]|[\ud800-\udfff])$/
39
40// Numbers print as whole numbers or with one decimal, so output never depends on float noise.
41function num(n) {
42 return Number.isInteger(n) ? String(n) : n.toFixed(1)
43}
44
45// Numbers in a sprite's path, which fall on fractions of a pixel: at most two decimals, with
46// trailing zeros dropped, so 4/3 prints as 1.33 and 2 prints as 2.
47function fine(n) {
48 return String(Math.round(n * 100) / 100)
49}
50
51function colorOr(value, fallback) {
52 return typeof value === 'string' && COLOR.test(value) ? value : fallback
53}
54
55// Text made safe to put inside an element or an attribute. The four markup characters become
56// entities. On top of that, the few spots where plain text could look like a script hook to a
57// filter that scans the raw source (an `on...=` pair, the word href, a `url(`) get one character
58// written as a character reference. A reader of the picture sees exactly the same text.
59function esc(text) {
60 return text
61 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
62 .replace(/(on[a-z0-9_-]*\s*)=/gi, '$1=')
63 .replace(/h(ref)/gi, (all, rest) => `&#${all.charCodeAt(0)};${rest}`)
64 .replace(/(url\s*)\(/gi, '$1(')
65}
66
67// A whole number of cells, or 0 when the value is not a number at all.
68function count(n) {
69 const v = Math.floor(Number(n))
70 return Number.isFinite(v) && v > 0 ? v : 0
71}
72
73// Spreads a row of runs out into one entry per cell, cut at `columns`. A wide character takes its
74// cell and leaves '' in the next, the same as the canvas does; one that would hang over the right
75// edge ends the row. A control character or one XML refuses keeps its cell as a blank, so
76// everything after it stays where the canvas put it.
77function cellsOf(runs, columns) {
78 const cells = []
79 if (!Array.isArray(runs)) return cells
80 for (const run of runs) {
81 if (run === null || typeof run !== 'object') continue
82 const text = typeof run.text === 'string' ? run.text : String(run.text ?? '')
83 const color = colorOr(run.color, PALETTE.ink)
84 const bg = colorOr(run.backgroundColor, PALETTE.field)
85 const bold = run.bold === true
86 for (const raw of text) {
87 const ch = BLANK.test(raw) ? ' ' : raw
88 const w = cellWidth(ch)
89 // Zero-width marks take no cell on the canvas, so they take none here.
90 if (w === 0) continue
91 if (cells.length + w > columns) return cells
92 cells.push({ ch, color, bg, bold })
93 for (let k = 1; k < w; k++) cells.push({ ch: '', color, bg, bold })
94 }
95 }
96 return cells
97}
98
99function isText(ch) {
100 return ch !== '' && ch !== ' ' && !Object.hasOwn(BLOCKS, ch) && !GAUGE.has(ch)
101}
102
103function rect(x, y, w, h, fill) {
104 return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" fill="${fill}"/>`
105}
106
107// One row of cells as SVG, its top at pixel `top`. Backgrounds go first, then blocks, then text.
108// The field colour is left to the picture's own background. Each pass walks the row once.
109function rowSvg(cells, top) {
110 const out = []
111
112 // Backgrounds, merged across neighbouring cells of the same colour.
113 for (let x = 0; x < cells.length; x++) {
114 const c = cells[x]
115 if (c.bg === PALETTE.field) continue
116 let end = x
117 while (end + 1 < cells.length && cells[end + 1].bg === c.bg) end++
118 out.push(rect(x * CELL_W, top, (end - x + 1) * CELL_W, CELL_H, c.bg))
119 x = end
120 }
121
122 // Blocks, with a rectangle that touches the last one of the same colour and shape merged in.
123 // `last` remembers the newest rectangle for each colour and shape, so the lookup stays constant.
124 const blocks = []
125 const last = new Map()
126 cells.forEach((c, x) => {
127 const shape = Object.hasOwn(BLOCKS, c.ch) ? BLOCKS[c.ch] : null
128 if (shape) {
129 const [dx, dy, w, h] = shape
130 const left = x * CELL_W + dx
131 const key = `${c.color} ${dy} ${h}`
132 const prev = last.get(key)
133 if (prev && prev.x + prev.w === left) {
134 prev.w += w
135 } else {
136 const b = { x: left, y: dy, w, h, fill: c.color }
137 blocks.push(b)
138 last.set(key, b)
139 }
140 } else if (c.ch === '▰') {
141 blocks.push({ x: x * CELL_W + 1, y: 1, w: CELL_W - 2, h: CELL_H - 2, fill: c.color })
142 } else if (c.ch === '▱') {
143 blocks.push({ x: x * CELL_W + 1, y: 1, w: CELL_W - 2, h: CELL_H - 2, stroke: c.color })
144 }
145 })
146 for (const b of blocks) {
147 if (b.stroke) {
148 // An outline stays inside its box: the stroke is centred on the edge, so pull in by half.
149 out.push(`<rect x="${num(b.x + 0.5)}" y="${num(top + b.y + 0.5)}" width="${num(b.w - 1)}" height="${num(b.h - 1)}" fill="none" stroke="${b.stroke}"/>`)
150 } else {
151 out.push(rect(b.x, top + b.y, b.w, b.h, b.fill))
152 }
153 }
154
155 // Text: each stretch of same-style characters is one <text>, and every character gets its own
156 // x so it sits in its cell whatever font the reader has. A single space between two words of
157 // the same style stays inside the stretch, so a phrase like "In review" reads as one. A wider
158 // gap ends it, because some renderers squeeze runs of spaces into one and would then hand the
159 // x positions to the wrong characters.
160 const textAt = (x) => x < cells.length && isText(cells[x].ch)
161 const y = num(top + BASELINE)
162 for (let x = 0; x < cells.length; x++) {
163 if (!textAt(x)) continue
164 const c = cells[x]
165 const xs = []
166 let text = ''
167 let end = x
168 while (end < cells.length) {
169 const d = cells[end]
170 if (d.ch === '') { end++; continue }
171 if (d.ch === ' ') {
172 const next = end + 1
173 if (!textAt(next) || cells[next].color !== c.color || cells[next].bold !== c.bold) break
174 xs.push(num(end * CELL_W))
175 text += ' '
176 end = next
177 continue
178 }
179 if (!isText(d.ch) || d.color !== c.color || d.bold !== c.bold) break
180 xs.push(num(end * CELL_W))
181 text += d.ch
182 end++
183 }
184 out.push(`<text x="${xs.join(' ')}" y="${y}" fill="${c.color}"${c.bold ? ' class="b"' : ''}>${esc(text)}</text>`)
185 x = end - 1
186 }
187 return out.join('')
188}
189
190const SPRITE_COLOR = /^#[0-9a-f]{6}$/i
191
192// A sprite the caller handed over, checked. Every value comes from outside, so each is read once
193// and anything of the wrong type gives null: a size that isn't one of ART's own keys, a colour that
194// isn't six-digit hex, a position or size that isn't a finite number (a numeric string included),
195// a width or height that isn't positive. The box comes back floored to whole cells.
196function checked(sprite) {
197 if (sprite === null || typeof sprite !== 'object') return null
198 const { x, y, w, h, size, color, frame } = sprite
199 if (typeof size !== 'string' || !Object.hasOwn(ART, size)) return null
200 if (typeof color !== 'string' || !SPRITE_COLOR.test(color)) return null
201 if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) return null
202 const [bx, by, bw, bh] = [x, y, w, h].map(Math.floor)
203 return { bx, by, bw, bh, bitmap: ART[size][frame === 1 ? 1 : 0], fill: color.toLowerCase() }
204}
205
206// A checked sprite pulled onto the board: its box is clamped to the board, and one that doesn't
207// touch the board gives null.
208function spriteOf(sprite, columns, height) {
209 const s = checked(sprite)
210 if (s === null) return null
211 const { bx, by, bw, bh, bitmap, fill } = s
212 const left = Math.min(Math.max(bx, 0), columns)
213 const top = Math.min(Math.max(by, 0), height)
214 const right = Math.min(bx + bw, columns)
215 const bottom = Math.min(by + bh, height)
216 if (right <= left || bottom <= top) return null
217 return { left, top, right, bottom, bitmap, fill }
218}
219
220// A sprite as one path in real pixels. A pixel is as big as fits the box across, and down with a
221// pixel of room above and below; the art sits at the box's left, centred top to bottom. Each run
222// of lit pixels in a bitmap row is one little rectangle. Positions are worked out exactly and only
223// rounded as they're written, so rounding never piles up along a row.
224function spritePath({ left, top, right, bottom, bitmap, fill }) {
225 const across = bitmap[0].length
226 const down = bitmap.length
227 const boxH = (bottom - top) * CELL_H
228 const p = Math.min((right - left) * CELL_W / across, (boxH - 2) / down)
229 const x0 = left * CELL_W
230 const y0 = top * CELL_H + (boxH - down * p) / 2
231 const d = []
232 bitmap.forEach((line, row) => {
233 for (let col = 0; col < line.length; col++) {
234 if (line[col] !== '#') continue
235 let end = col
236 while (end + 1 < line.length && line[end + 1] === '#') end++
237 const len = (end - col + 1) * p
238 d.push(`M${fine(x0 + col * p)} ${fine(y0 + row * p)}h${fine(len)}v${fine(p)}h${fine(-len)}z`)
239 col = end
240 }
241 })
242 return `<path fill="${fill}" d="${d.join('')}"/>`
243}
244
245/**
246 * Draws one sprite on its own, as the path the picture would draw for it at that place. The demo
247 * uses it to draw each sprite's art once and place it wherever the sprite stands. There is no
248 * board here, so the box is never clamped to one.
249 * @param sprite { x, y, w, h, size, color, frame }: a box in cells, an ART size, a '#rrggbb'
250 * colour and frame 0 or 1, checked the same way the picture checks its sprites
251 * @return the sprite's `<path .../>`, or '' when a value is bad or the box floors to nothing
252 */
253export function spriteMarkup(sprite) {
254 const s = checked(sprite)
255 if (s === null || s.bw <= 0 || s.bh <= 0) return ''
256 const { bx, by, bw, bh, bitmap, fill } = s
257 return spritePath({ left: bx, top: by, right: bx + bw, bottom: by + bh, bitmap, fill })
258}
259
260// Blanks the characters of the cells a sprite covers, keeping their backgrounds, so the terminal's
261// blocks for that sprite don't show underneath the drawing. `spans` is a list of [from, to) columns.
262function clear(cells, spans) {
263 if (spans === undefined) return cells
264 for (const [from, to] of spans) {
265 for (let x = from; x < Math.min(to, cells.length); x++) cells[x].ch = ' '
266 }
267 return cells
268}
269
270function head(width, height) {
271 return `<svg xmlns="http://www.w3.org/2000/svg" width="${num(width)}" height="${num(height)}" viewBox="0 0 ${num(width)} ${num(height)}">` +
272 `<rect width="${num(width)}" height="${num(height)}" fill="${PALETTE.field}"/>` +
273 `<style>text{font:14px ${FONT};white-space:pre}.b{font-weight:700}</style>`
274}
275
276// The stand-in when even the plain board is too long: same size, one line of text.
277function tooLarge(width, height) {
278 const xs = Array.from(TOO_LARGE, (_, i) => num((i + 1) * CELL_W)).join(' ')
279 return `${head(width, height)}<text x="${xs}" y="${BASELINE}" fill="${PALETTE.ink}">${TOO_LARGE}</text></svg>`
280}
281
282/**
283 * Draws one board as a still SVG picture.
284 * @param rows the painter's rows, each a list of { text, color, backgroundColor, bold }
285 * @param columns how many cells across
286 * @param sprites agents to draw in real pixels, { x, y, w, h, size, color, frame }: a box in
287 * cells, an ART size, a '#rrggbb' colour and frame 0 or 1. Each is a still drawing of the frame
288 * it was given, over its box's cells with their characters cleared; the caller redraws the
289 * picture with the other frame when a sprite walks. A sprite with a bad value is skipped.
290 * @return { source, width, height }: the SVG document and its size in CSS pixels. The source is
291 * never longer than SVG_MAX: a board too long for that becomes a one-line stand-in of the same
292 * size, which drops the sprites along with everything else.
293 */
294export function pictureOf({ rows, columns, sprites = [] } = {}) {
295 const cols = count(columns)
296 const list = Array.isArray(rows) ? rows : []
297 const width = cols * CELL_W
298 const height = list.length * CELL_H
299
300 // The columns each row loses to sprites, so their cells are cleared before the row is drawn.
301 const placed = (Array.isArray(sprites) ? sprites : []).map((s) => spriteOf(s, cols, list.length)).filter((s) => s !== null)
302 const spans = new Map()
303 for (const s of placed) {
304 for (let y = s.top; y < s.bottom; y++) {
305 if (!spans.has(y)) spans.set(y, [])
306 spans.get(y).push([s.left, s.right])
307 }
308 }
309 const art = placed.map(spritePath).join('')
310 const body = list.map((runs, y) => rowSvg(clear(cellsOf(runs, cols), spans.get(y)), y * CELL_H)).join('')
311
312 let source = `${head(width, height)}${body}${art}</svg>`
313 if (source.length > SVG_MAX) source = tooLarge(width, height)
314 return { source, width, height }
315}
316hooks/board/lib/canvas.mjs 186 lines1// A grid of character cells the board draws into. Text and pixel sprites go in cell by cell,
2// and toRuns reads the grid back as coloured stretches of text, one list per row.
3// No imports on purpose: this has to load anywhere the board does.
4
5// Turns a size into a whole number of at least 1, so a bad size still gives a usable grid.
6function size(n) {
7 const v = Math.floor(Number(n))
8 return Number.isFinite(v) && v >= 1 ? v : 1
9}
10
11// Turns a coordinate into a whole number, or null when it is not a number at all.
12function coord(n) {
13 const v = Math.floor(Number(n))
14 return Number.isFinite(v) ? v : null
15}
16
17// Control characters would move the cursor or recolour the terminal, so they become a space.
18const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
19// Zero-width characters and combining marks take no cell of their own, so they are dropped. So
20// are the line and paragraph separators, which can break a row, and the direction marks that
21// make a terminal draw the rest of a row backwards (U+202A to U+202E, U+2066 to U+2069).
22const INVISIBLE = /[\u0300-\u036f\u200b-\u200f\u2028-\u202e\u2060\u2066-\u2069\ufe00-\ufe0f\ufeff]/g
23
24// Code point ranges a terminal draws two cells wide: CJK, Hangul, fullwidth forms and emoji.
25const WIDE = [
26 [0x1100, 0x115f],
27 [0x2e80, 0xa4cf],
28 [0xac00, 0xd7a3],
29 [0xf900, 0xfaff],
30 [0xfe30, 0xfe4f],
31 [0xff00, 0xff60],
32 [0xffe0, 0xffe6],
33 [0x1f300, 0x1faff],
34 [0x20000, 0x3fffd],
35]
36
37function isWide(ch) {
38 const cp = ch.codePointAt(0)
39 return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi)
40}
41
42// How many cells one character of safe text takes.
43function cellsOf(ch) {
44 return isWide(ch) ? 2 : 1
45}
46
47// Text made safe to draw: control characters become a space and invisible marks, separators and
48// direction marks are dropped, so every character left takes one or two whole cells.
49export function safeText(text) {
50 return String(text ?? '').replace(INVISIBLE, '').replace(CONTROL, ' ')
51}
52
53// How many cells the safe form of `text` takes on screen.
54export function cellWidth(text) {
55 let n = 0
56 for (const ch of safeText(text)) n += cellsOf(ch)
57 return n
58}
59
60// The longest start of the safe form of `text` that fits in `cells` cells.
61export function clip(text, cells) {
62 let out = ''
63 let used = 0
64 for (const ch of safeText(text)) {
65 const w = cellsOf(ch)
66 if (used + w > cells) break
67 out += ch
68 used += w
69 }
70 return out
71}
72
73function blank(bg) {
74 return { ch: ' ', color: bg, backgroundColor: bg, bold: false }
75}
76
77// Makes a fresh grid where every cell is a space painted in the background colour.
78export function createCanvas(columns, rows, bg) {
79 const w = size(columns)
80 const h = size(rows)
81 const cells = []
82 for (let y = 0; y < h; y++) {
83 const row = []
84 for (let x = 0; x < w; x++) row.push(blank(bg))
85 cells.push(row)
86 }
87 return { columns: w, rows: h, bg, cells }
88}
89
90// A wide character fills its own cell and leaves the next one holding '' so the row's text still
91// adds up to the right number of cells. Before a cell is overwritten, any wide character it is
92// half of gets its other half blanked, so no orphan half is left behind.
93function claim(row, col) {
94 const cell = row[col]
95 if (cell.ch === '' && col > 0) row[col - 1].ch = ' '
96 if (cell.ch !== '' && isWide(cell.ch) && col + 1 < row.length && row[col + 1].ch === '') row[col + 1].ch = ' '
97}
98
99function paintCell(cell, ch, s) {
100 cell.ch = ch
101 cell.color = s.color
102 if (s.backgroundColor !== undefined) cell.backgroundColor = s.backgroundColor
103 cell.bold = Boolean(s.bold)
104}
105
106// Writes the safe form of the text starting at (x, y), one or two cells per character. Anything
107// that lands off the grid is dropped, a wide character that would hang over either edge is not
108// drawn, and nothing wraps onto the next row.
109export function putText(canvas, x, y, text, style = {}) {
110 const cx = coord(x)
111 const cy = coord(y)
112 if (cx === null || cy === null || cy < 0 || cy >= canvas.rows) return
113 const row = canvas.cells[cy]
114 const s = style || {}
115 let col = cx
116 for (const ch of safeText(text)) {
117 const w = cellsOf(ch)
118 if (col + w > canvas.columns) break
119 if (col >= 0) {
120 claim(row, col)
121 if (w === 2) claim(row, col + 1)
122 paintCell(row[col], ch, s)
123 if (w === 2) paintCell(row[col + 1], '', s)
124 }
125 col += w
126 }
127}
128
129// Draws a little bitmap where '#' is a lit pixel. Each cell holds two pixels stacked on top of
130// each other, using half-block characters, so a bitmap N pixels tall takes ceil(N / 2) rows.
131// Cells with no lit pixel are left exactly as they were.
132export function putSprite(canvas, x, y, bitmap, color) {
133 const cx = coord(x)
134 const cy = coord(y)
135 if (cx === null || cy === null || !Array.isArray(bitmap)) return
136 const lines = bitmap.map((line) => Array.from(String(line ?? '')))
137 for (let p = 0; p < lines.length; p += 2) {
138 const row = cy + p / 2
139 if (row < 0 || row >= canvas.rows) continue
140 const upper = lines[p]
141 const lower = p + 1 < lines.length ? lines[p + 1] : []
142 const width = Math.max(upper.length, lower.length)
143 for (let i = 0; i < width; i++) {
144 const col = cx + i
145 if (col < 0 || col >= canvas.columns) continue
146 const top = upper[i] === '#'
147 const bottom = lower[i] === '#'
148 if (!top && !bottom) continue
149 claim(canvas.cells[row], col)
150 const cell = canvas.cells[row][col]
151 cell.ch = top && bottom ? '█' : top ? '▀' : '▄'
152 cell.color = color
153 cell.bold = false
154 }
155 }
156}
157
158// Reads the grid back row by row, merging neighbouring cells that look the same into one run.
159// Every row's run texts take exactly `columns` cells: a wide character counts two, and the cell
160// it spills into adds no text.
161export function toRuns(canvas) {
162 return canvas.cells.map((row) => {
163 const runs = []
164 let run = null
165 for (const cell of row) {
166 if (
167 run &&
168 run.color === cell.color &&
169 run.backgroundColor === cell.backgroundColor &&
170 run.bold === cell.bold
171 ) {
172 run.text += cell.ch
173 } else {
174 run = {
175 text: cell.ch,
176 color: cell.color,
177 backgroundColor: cell.backgroundColor,
178 bold: cell.bold,
179 }
180 runs.push(run)
181 }
182 }
183 return runs
184 })
185}
186hooks/board/lib/detail.mjs 201 lines1// The words on the board's header and in the detail box that opens when you
2// click a card, an agent or a message. Pure functions: plain data in, plain
3// data out, so the live board and the README demo always say the same thing.
4//
5// Every value here comes from files or tool calls, so nothing is trusted: any
6// field can be missing or the wrong type, and every line is cleaned and cut
7// before it leaves.
8
9import { safeText } from './canvas.mjs'
10import { kTokens, elapsed } from './stage.mjs'
11
12const MAX_LINES = 40
13const MAX_CHARS = 200
14const MAX_FILES = 6
15const MAX_MEMORY = 8
16const MAX_BODY = 12
17
18// A plain object to read fields from, or an empty one when it isn't.
19function obj(v) {
20 return v != null && typeof v === 'object' && !Array.isArray(v) ? v : {}
21}
22
23// A list to walk, or an empty one when it isn't.
24function list(v) {
25 return Array.isArray(v) ? v : []
26}
27
28// Text from a field: strings as they are, real numbers written out, anything else empty.
29function str(v) {
30 if (typeof v === 'string') return v
31 if (typeof v === 'number' && Number.isFinite(v)) return String(v)
32 return ''
33}
34
35// A count that can be shown: a whole number zero or above, else zero.
36function count(v) {
37 return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0
38}
39
40function isNum(v) {
41 return typeof v === 'number' && Number.isFinite(v)
42}
43
44// One safe line: control characters become spaces, invisible marks go, and it
45// stops at 200 characters without splitting an emoji in half.
46function clean(v) {
47 const text = safeText(str(v))
48 if (text.length <= MAX_CHARS) return text
49 let out = ''
50 for (const ch of text) {
51 if (out.length + ch.length > MAX_CHARS) break
52 out += ch
53 }
54 return out
55}
56
57// The finished detail: every line cleaned and cut, and a long list ends with '…'.
58function finish(kind, id, title, lines) {
59 let out = lines.map(clean)
60 if (out.length > MAX_LINES) out = [...out.slice(0, MAX_LINES - 1), '…']
61 return { kind, id: clean(id), title: clean(title), lines: out }
62}
63
64// 'F1 (blocking) Refill uses wall-clock time', leaving out whatever is missing.
65// A finding with nothing to say gives no line at all.
66function findingLines(findings) {
67 const out = []
68 for (const raw of list(findings)) {
69 const f = obj(raw)
70 const severity = str(f.severity).toLowerCase()
71 const parts = [str(f.id), severity && `(${severity})`, str(f.summary)].filter(Boolean)
72 if (parts.length) out.push(parts.join(' '))
73 }
74 return out
75}
76
77// 'done, attempt 2' for a report or verdict note.
78function noteGist(note) {
79 const gist = str(note.gist) || 'filed'
80 return isNum(note.attempt) ? `${gist}, attempt ${note.attempt}` : gist
81}
82
83// Non-empty strings only, for lists of names and paths.
84function names(v) {
85 return list(v).map(str).filter(Boolean)
86}
87
88// Stars for each service tier. The header shows these instead of the tier's name.
89const EFFORT = { 'one-star': '★', 'two-star': '★★', 'three-star': '★★★' }
90
91// 'Effort: ★★' for a known tier, or nothing for anything else.
92function effortOf(tier) {
93 const stars = Object.hasOwn(EFFORT, tier) ? EFFORT[tier] : ''
94 return stars && `Effort: ${stars}`
95}
96
97// The header's project line. Anything other than a dish reads as the ticket board.
98export function projectOf(input) {
99 const src = obj(input)
100 const repo = clean(src.repo)
101 if (src.mode !== 'dish') {
102 const n = count(src.count)
103 return { mode: 'tickets', repo, branch: null, title: 'Ticket board', detail: `${n} ${n === 1 ? 'ticket' : 'tickets'}` }
104 }
105 const plan = obj(src.plan)
106 const ticket = obj(src.ticket)
107 const progress = isNum(src.total) ? `${count(src.done)} of ${count(src.total)} done` : ''
108 const detail = [str(plan.ticket), str(ticket.kind) || str(plan.kind), progress, effortOf(plan.tier)]
109 .filter(Boolean)
110 .join(' · ')
111 return {
112 mode: 'dish',
113 repo,
114 branch: clean(plan.branch) || null,
115 title: clean(str(ticket.title) || str(plan.ticket) || 'Untitled'),
116 detail: clean(detail),
117 }
118}
119
120// A work item's detail: what it is for, what it touches, how it has gone so far, and who has it.
121export function cardDetail(input) {
122 const src = obj(input)
123 const item = obj(src.item)
124 const slug = str(item.slug)
125 const title = [slug, str(src.phaseTitle)].filter(Boolean).join(' · ') || 'Work item'
126 const lines = [str(item.goal) || 'No description.']
127
128 const files = names(item.files)
129 if (files.length) {
130 const shown = files.slice(0, MAX_FILES)
131 if (files.length > MAX_FILES) shown.push(`+${files.length - MAX_FILES} more`)
132 lines.push(`Files: ${shown.join(', ')}`)
133 }
134 const deps = names(item.dependsOn)
135 if (deps.length) lines.push(`Depends on: ${deps.join(', ')}`)
136 lines.push(`Attempts: ${count(item.attempts)}`)
137
138 if (src.report != null && typeof src.report === 'object') lines.push(`Cook report: ${noteGist(src.report)}`)
139 if (src.verdict != null && typeof src.verdict === 'object') {
140 lines.push(`Review: ${noteGist(src.verdict)}`, ...findingLines(src.findings))
141 }
142
143 for (const raw of list(src.agents)) {
144 const a = obj(raw)
145 const parts = [str(a.name), str(a.role), str(a.activity) || str(a.state)].filter(Boolean)
146 if (parts.length) lines.push(`Working it: ${parts.join(' · ')}`)
147 }
148 return finish('card', slug, title, lines)
149}
150
151// A board ticket's detail, for when no dish is running and the lanes hold tickets.
152export function ticketDetail(input) {
153 const src = obj(input)
154 const t = obj(src.ticket)
155 const id = str(t.id)
156 const title = [id, str(t.status)].filter(Boolean).join(' · ') || 'Ticket'
157 const lines = [str(t.title) || 'No title.']
158 if (str(t.kind)) lines.push(`Kind: ${str(t.kind)}`)
159 if (str(t.assignee)) lines.push(`Assignee: ${str(t.assignee)}`)
160 if (str(src.goal)) lines.push(str(src.goal))
161 return finish('card', id, title, lines)
162}
163
164// An agent's detail. The clock stops at endedAt once it's finished, and an
165// agent with no start time reads 0s rather than the age of the epoch.
166export function agentDetail(input) {
167 const src = obj(input)
168 const a = obj(src.agent)
169 const name = str(a.name)
170 const role = str(src.roleLabel) || str(a.role)
171 const title = [name, role].filter(Boolean).join(' · ') || 'Agent'
172 const end = isNum(a.endedAt) ? a.endedAt : src.now
173 const running = isNum(a.startedAt) && isNum(end) ? elapsed(end - a.startedAt) : '0s'
174 const lines = [
175 `Model: ${str(a.model) || 'unknown'}`,
176 `Working: ${str(a.item) || str(a.ticket) || 'nothing yet'}`,
177 `Now: ${str(a.activity) || str(a.state) || 'unknown'}`,
178 `Tokens: ${kTokens(a.tokens)}`,
179 `Running: ${running}`,
180 ]
181 // Memory is the tail of the agent's ledger, newest last, so the last lines are the ones to keep.
182 const memory = list(src.memory).filter((l) => typeof l === 'string')
183 if (memory.length) lines.push('', 'Working memory', ...memory.slice(-MAX_MEMORY))
184 return finish('agent', a.id, title, lines)
185}
186
187// A message's detail: what it says, what it is about, and the start of the file it came from.
188export function messageDetail(input) {
189 const src = obj(input)
190 const m = obj(src.message)
191 const title = `${str(m.from) || '?'} → ${str(m.to) || '?'}`
192 const lines = [str(m.text) || 'No text.']
193 if (str(m.item)) lines.push(`About: ${str(m.item)}`)
194 if (str(m.file)) lines.push(`From file: ${str(m.file)}`)
195 lines.push(...findingLines(src.findings))
196 // Blank lines in the file are kept: they are part of how it reads.
197 const body = list(src.body).filter((l) => typeof l === 'string')
198 if (body.length) lines.push('', ...body.slice(0, MAX_BODY))
199 return finish('message', m.id, title, lines)
200}
201hooks/board/lib/dish.mjs 406 lines1// Turns what agents leave on disk (plans, reports, verdicts, briefs, ledgers) into short notes
2// the board can list. Pure text in, plain objects out: no file reading happens here.
3
4// Pulls the lines between the opening `---` and the next `---`, or null when there is no
5// frontmatter at the top of the text.
6function frontmatterLines(text) {
7 const lines = String(text ?? '').split(/\r?\n/)
8 if (lines.length === 0 || lines[0].trim() !== '---') return null
9 const end = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
10 if (end === -1) return null
11 return lines.slice(1, end)
12}
13
14// Drops one pair of matching quotes around a value, if it has them.
15function unquote(value) {
16 if (value.length >= 2) {
17 const first = value[0]
18 const last = value[value.length - 1]
19 if ((first === '"' || first === "'") && first === last) return value.slice(1, -1)
20 }
21 return value
22}
23
24// The top-level `key: value` pairs of the frontmatter as strings. Nested lines (indented or
25// list items) and keys without a value are left out. Values can hold colons of their own:
26// only the first `: ` splits the line.
27export function envelope(text) {
28 const lines = frontmatterLines(text)
29 const out = {}
30 if (!lines) return out
31 for (const line of lines) {
32 if (line.startsWith(' ') || line.startsWith('-')) continue
33 const at = line.indexOf(': ')
34 if (at <= 0) continue
35 const key = line.slice(0, at).trim()
36 const value = unquote(line.slice(at + 2).trim())
37 if (!key || value === '') continue
38 out[key] = value
39 }
40 return out
41}
42
43// The dish, its ticket, and each item's slug and status from a PLAN.md.
44export function planInfo(text) {
45 const env = envelope(text)
46 const items = []
47 const itemLine = /^\s*-\s*\{\s*slug:\s*([^,}\s]+)\s*,\s*status:\s*([^,}\s]+)/
48 for (const line of frontmatterLines(text) ?? []) {
49 const match = itemLine.exec(line)
50 if (match) items.push({ slug: match[1], status: match[2] })
51 }
52 return { dish: env.dish ?? '', ticket: env.ticket ?? '', items }
53}
54
55// What each kind of artifact says in one glance.
56function gistFor(kind, env) {
57 if (kind === 'report') return env.status ?? ''
58 if (kind === 'verdict') return env.verdict ?? ''
59 if (kind === 'brief') return 'confidence ' + (env.confidence ?? '')
60 if (kind === 'ledger') return 'memory updated'
61 return ''
62}
63
64// A count from a frontmatter value: a plain run of digits, or null for anything else.
65function wholeNumber(value) {
66 if (typeof value !== 'string' || !/^\d+$/.test(value)) return null
67 const n = Number(value)
68 return Number.isSafeInteger(n) ? n : null
69}
70
71// How many lines a caller asked for: a whole number, never negative, or the fallback when the
72// request isn't a number at all.
73function capOf(limit, fallback) {
74 const n = Math.floor(Number(limit))
75 return Number.isFinite(n) ? Math.max(0, n) : fallback
76}
77
78// The fields of one `{ key: value, ... }` flow mapping in `text`. Quoted values keep their commas
79// and colons and lose their quotes. Reading stops at the closing `}` or the end of the text, and
80// walks each character once, so a hostile line costs no more than its length.
81function flowFields(text) {
82 const fields = Object.create(null)
83 const n = text.length
84 let i = text.indexOf('{')
85 if (i === -1) return fields
86 i += 1
87 while (i < n) {
88 const keyStart = i
89 while (i < n && text[i] !== ':' && text[i] !== ',' && text[i] !== '}') i++
90 if (i >= n || text[i] === '}') break
91 if (text[i] === ',') {
92 i++
93 continue
94 }
95 const key = text.slice(keyStart, i).trim()
96 i++
97 while (i < n && (text[i] === ' ' || text[i] === '\t')) i++
98 let value
99 if (text[i] === '"' || text[i] === "'") {
100 const close = text.indexOf(text[i], i + 1)
101 const end = close === -1 ? n : close
102 value = text.slice(i + 1, end)
103 i = end + 1
104 while (i < n && text[i] !== ',' && text[i] !== '}') i++
105 } else {
106 const valueStart = i
107 while (i < n && text[i] !== ',' && text[i] !== '}') i++
108 value = text.slice(valueStart, i).trim()
109 }
110 if (key) fields[key] = value
111 if (i < n && text[i] === '}') break
112 i++
113 }
114 return fields
115}
116
117// One finding from its joined `- { id: ... }` text, or null when it has no id.
118function findingFrom(entry) {
119 const fields = flowFields(entry)
120 if (fields.id === undefined) return null
121 return { id: fields.id, severity: fields.severity ?? '', summary: fields.summary ?? '' }
122}
123
124// The findings under a top-level `findings:` key, in one pass over the frontmatter lines. Each
125// entry starts at a `- {` line; the indented lines after it are the same entry wrapped, so they
126// get joined on before the entry is read.
127function findingsIn(lines) {
128 const found = []
129 let inList = false
130 let entry = null
131 const finish = () => {
132 if (entry) {
133 const finding = findingFrom(entry.join(' '))
134 if (finding) found.push(finding)
135 }
136 entry = null
137 }
138 for (const line of lines) {
139 const topLevel = line !== '' && !/^[\s-]/.test(line)
140 if (topLevel) {
141 finish()
142 inList = line.trimEnd() === 'findings:'
143 continue
144 }
145 if (!inList) continue
146 const trimmed = line.trim()
147 if (trimmed.startsWith('-')) {
148 finish()
149 if (trimmed.slice(1).trimStart().startsWith('{')) entry = [trimmed]
150 } else if (entry && trimmed) {
151 entry.push(trimmed)
152 }
153 }
154 finish()
155 return found
156}
157
158// Every finding a verdict's frontmatter lists, in order, as { id, severity, summary }.
159export function findingsOf(text) {
160 return findingsIn(frontmatterLines(text) ?? [])
161}
162
163// How many items a flow list like `[a, "b, c"]` holds. Commas inside quotes or nested brackets
164// don't split it.
165function flowCount(value) {
166 let count = 0
167 let filled = false
168 let quote = null
169 let depth = 0
170 for (let i = 1; i < value.length; i++) {
171 const ch = value[i]
172 if (quote) {
173 if (ch === quote) quote = null
174 continue
175 }
176 if (ch === '"' || ch === "'") quote = ch
177 else if (ch === '[' || ch === '{') depth++
178 else if ((ch === ']' || ch === '}') && depth > 0) depth--
179 else if (ch === ']') break
180 else if (ch === ',' && depth === 0) {
181 if (filled) count++
182 filled = false
183 continue
184 }
185 if (ch !== ' ' && ch !== '\t') filled = true
186 }
187 return filled ? count + 1 : count
188}
189
190// How many blocking items a plan check names: `blocking:` holds either a number or a list, with
191// one `- ` entry per item on the lines below it (an entry's wrapped lines sit further in). Null
192// when the key is missing or holds something that is neither.
193function blockingIn(lines) {
194 let count = null
195 let indent = -1
196 for (const line of lines) {
197 if (count === null) {
198 if (!line.startsWith('blocking:')) continue
199 const value = line.slice('blocking:'.length).trim()
200 if (value.startsWith('[')) return flowCount(value)
201 if (value !== '') return wholeNumber(value)
202 count = 0
203 continue
204 }
205 const rest = line.trimStart()
206 if (rest === '') continue
207 const pad = line.length - rest.length
208 const isEntry = rest === '-' || rest.startsWith('- ')
209 if (indent === -1) {
210 if (!isEntry) break
211 indent = pad
212 } else if (pad > indent) {
213 continue
214 } else if (pad < indent || !isEntry) {
215 break
216 }
217 count++
218 }
219 return count
220}
221
222// The attempt a report or verdict is about, 1 when it doesn't say.
223function attemptOf(env) {
224 return wholeNumber(env.attempt ?? env.attempt_reviewed) ?? 1
225}
226
227// The longest note summary, message text and learning line. All three come from files agents
228// wrote, so without a cap one runaway line could fill the board.
229const TEXT_MAX = 240
230
231// Cuts text longer than `max` characters to `max`, the last one an ellipsis. A character made of
232// two halves is never cut in two.
233function capped(text, max) {
234 if (text.length <= max) return text
235 let cut = text.slice(0, Math.max(0, max - 1))
236 const last = cut.charCodeAt(cut.length - 1)
237 if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1)
238 return cut + '…'
239}
240
241// One board note for an artifact, or null when the file doesn't say what kind of doc it is.
242// `file` is where the artifact sits, relative to the dish folder, so a message can point at it.
243// For a plan check `findings` is its blocking count, or null when it doesn't list one.
244export function noteFrom(text, mtimeMs, file = null) {
245 const env = envelope(text)
246 const kind = env.doc
247 if (!kind) return null
248 const lines = frontmatterLines(text) ?? []
249 const found = kind === 'plan_check' ? [] : findingsIn(lines)
250 let summary = ''
251 if (kind === 'verdict') summary = found.length > 0 ? found[0].summary : ''
252 else if (kind === 'brief') summary = env.question ?? ''
253 return {
254 at: mtimeMs,
255 dish: env.dish ?? '',
256 item: env.item ?? '',
257 role: env.role ?? '',
258 kind,
259 gist: gistFor(kind, env),
260 findings: kind === 'plan_check' ? blockingIn(lines) : found.length,
261 summary: capped(summary, TEXT_MAX),
262 attempt: attemptOf(env),
263 file,
264 }
265}
266
267const COOK_ROLES = new Set(['cook', 'heavy'])
268const INSPECTOR_ROLES = new Set(['inspector'])
269
270// A string field of something agents wrote, or '' when it isn't a string.
271function textOf(value) {
272 return typeof value === 'string' ? value : ''
273}
274
275// The roster name of the one agent in `roles` working on `item`. With none, or more than one,
276// we can't tell who it was, so the role word stands in.
277function nameFor(agents, roles, item, word) {
278 if (!item) return word
279 let match = null
280 let matches = 0
281 for (const agent of agents) {
282 if (!agent || typeof agent !== 'object' || !roles.has(agent.role) || agent.item !== item) continue
283 match = agent
284 matches++
285 }
286 return matches === 1 && typeof match.name === 'string' && match.name ? match.name : word
287}
288
289// The message one note stands for, or null when its kind and gist don't say anything to anyone.
290function messageFor(note, agents) {
291 const kind = textOf(note.kind)
292 const gist = textOf(note.gist)
293 const item = textOf(note.item)
294 const summary = textOf(note.summary)
295 const count = Number.isSafeInteger(note.findings) && note.findings >= 0 ? note.findings : null
296 const label = item || textOf(note.dish) || 'item'
297 let from
298 let to
299 let said
300 if (kind === 'report' && gist === 'done') {
301 from = nameFor(agents, COOK_ROLES, item, 'cook')
302 to = 'inspector'
303 said = `${label} ready for review`
304 } else if (kind === 'report' && gist === 'blocked') {
305 from = nameFor(agents, COOK_ROLES, item, 'cook')
306 to = 'planner'
307 said = `${label} is blocked`
308 } else if (kind === 'verdict' && gist === 'FAIL') {
309 from = nameFor(agents, INSPECTOR_ROLES, item, 'inspector')
310 to = nameFor(agents, COOK_ROLES, item, 'cook')
311 const more = count !== null && count > 1 ? ` (+${count - 1} more)` : ''
312 // A long summary is cut short so the count of other findings still fits on the end.
313 const lead = `${label} sent back: `
314 const room = TEXT_MAX - lead.length - more.length
315 said = summary ? lead + capped(summary, Math.max(1, room)) + more : `${label} sent back`
316 } else if (kind === 'verdict' && gist === 'PASS') {
317 from = nameFor(agents, INSPECTOR_ROLES, item, 'inspector')
318 to = 'planner'
319 // Always `notes`, even for one: other parts of the board match on this exact wording.
320 const notes = count !== null && count > 0 ? `, ${count} notes` : ''
321 said = `${label} passed review${notes}`
322 } else if (kind === 'brief') {
323 from = 'scout'
324 to = 'planner'
325 said = summary ? `answered: ${summary}` : 'brief written'
326 } else if (kind === 'plan_check') {
327 from = 'inspector'
328 to = 'planner'
329 said = count === null ? 'plan check written' : `plan check: ${count} blocking`
330 } else {
331 return null
332 }
333 const at = Number.isFinite(note.at) ? note.at : 0
334 const file = typeof note.file === 'string' && note.file ? note.file : null
335 return {
336 id: `${kind}:${textOf(note.dish)}:${item}:${at}:${file ?? ''}`,
337 at,
338 from,
339 to,
340 item,
341 text: capped(said, TEXT_MAX),
342 file,
343 }
344}
345
346// The notes as messages between agents, newest first, at most `limit`. `agents` is the roster;
347// a sender or receiver gets a name only when the roster pins it to exactly one agent.
348export function messagesFrom(notes, agents, limit = 20) {
349 const roster = Array.isArray(agents) ? agents : []
350 const messages = []
351 for (const note of Array.isArray(notes) ? notes : []) {
352 if (!note || typeof note !== 'object') continue
353 const message = messageFor(note, roster)
354 if (message) messages.push(message)
355 }
356 return latest(messages, capOf(limit, 20))
357}
358
359const DATED = /^\d{4}-\d{2}-\d{2}/
360
361// The repo's learnings from a LEARNINGS.md, newest first, at most `limit` lines; `total` counts
362// them all. A `## ` heading is one learning, unless it starts with a date: then it's a retro
363// section, and each of its top-level `- ` bullets is one learning, cut at its first sentence.
364export function learningsFrom(text, limit = 5) {
365 const all = []
366 let dated = false
367 for (const line of String(text ?? '').split(/\r?\n/)) {
368 if (line.startsWith('## ')) {
369 const heading = line.slice(3).trim()
370 dated = DATED.test(heading)
371 if (!dated && heading) all.push(capped(heading, TEXT_MAX))
372 continue
373 }
374 if (!dated || !line.startsWith('- ')) continue
375 const bullet = line.slice(2)
376 const stop = bullet.indexOf('. ')
377 const learning = (stop === -1 ? bullet : bullet.slice(0, stop)).trim()
378 if (learning) all.push(capped(learning, TEXT_MAX))
379 }
380 const cap = capOf(limit, 5)
381 return { total: all.length, lines: cap === 0 ? [] : all.slice(-cap).reverse() }
382}
383
384// The newest `limit` notes, newest first. Array sort is stable, so equal times keep their order.
385export function latest(notes, limit) {
386 return [...(notes ?? [])].sort((a, b) => b.at - a.at).slice(0, Math.max(0, limit))
387}
388
389// The last `limit` live lines of a ledger's `## World state` section. Struck-through units
390// (anything with `~~`) are dead and get skipped; the section ends at the next `## ` heading.
391export function ledgerTail(text, limit) {
392 const live = []
393 let inWorld = false
394 for (const line of String(text ?? '').split(/\r?\n/)) {
395 if (line.startsWith('## ')) {
396 inWorld = line.trim() === '## World state'
397 continue
398 }
399 if (!inWorld) continue
400 if (!/^W\d+\./.test(line) || line.includes('~~')) continue
401 live.push(line)
402 }
403 if (limit <= 0) return []
404 return live.slice(-limit)
405}
406hooks/board/lib/fleet.mjs 580 lines1// Keeps the roster of agents seen in this session. Each agent is worked out from whatever the
2// engine tells us about it (a label, an agent type, the files it writes, the paths it touches)
3// and followed from its first sighting until it finishes. Every function here returns new
4// objects; nothing is mutated.
5import { freeName } from './sprites.mjs'
6
7// Agent types, checked in this order by substring. 'cook-heavy' and 'cook-opus' sit before
8// 'cook' so a heavy cook is never mistaken for a plain one.
9const TYPE_ROLES = [
10 ['planner', 'planner'],
11 ['cook-heavy', 'heavy'],
12 ['cook-opus', 'heavy'],
13 ['scout', 'scout'],
14 ['cook', 'cook'],
15 ['inspector', 'inspector'],
16 ['analyst', 'analyst'],
17]
18
19// The word in front of the first colon of a label like 'cook:board-tickets:0'.
20const LABEL_ROLES = { scout: 'scout', cook: 'cook', inspect: 'inspector' }
21
22// The Planner's own state file. It sits where item state files live but names no item.
23const PLANNER_STATE = /(?:^|\/)state\/planner\.md/
24
25// What a written file says about its writer, checked in this order. Only writes count: an
26// inspector reads the cook's report and ledger all the time, and that doesn't make it a cook.
27const WRITTEN_ROLES = [
28 [/-verdict|plan-check|acceptance/, 'inspector'],
29 [/reports\/[a-z0-9-]+-cook/, 'cook'],
30 [/\/briefs\//, 'scout'],
31 [/analyst|retro/, 'analyst'],
32 [/\.brigade\/worktrees\//, 'cook'],
33]
34
35// The tools that write the file named in their file path.
36const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
37
38// An item's ledger. A shell command that writes one is a cook keeping its memory.
39const ITEM_STATE = /\/state\/[a-z0-9-]+\.md$/
40
41// How much of a shell command we look at. This runs on every shell call of every agent, and a
42// command can be any length, so anything past this point never decides a role.
43const COMMAND_LIMIT = 4_000
44
45// Splits a command into the single commands of its pipelines and lists.
46const COMMAND_BREAK = /[;&|\n]/
47
48// The words that make a command the steward's: making or removing a worktree, and landing a
49// branch. Each is found with one forward scan from a given point, so a long command full of
50// near misses still costs time in step with its length.
51const GIT = /\bgit\b/g
52const WORKTREE = /\bworktree\s+(?:add|remove)\b/g
53const MERGE = /\bmerge\b/g
54const FF_ONLY = /--ff-only\b/g
55
56// A shell word, quoted or bare. Quotes are dropped from what it captures.
57const WORD = String.raw`(?:"([^"]*)"|'([^']*)'|([^\s;&|<>()]+))`
58// '> path', '>> path', '2> path' or '&> path', but not '>&2' or '2>&1', which write no file.
59const REDIRECT = new RegExp(String.raw`>>?(?!&)\s*${WORD}`, 'g')
60// 'tee path…' up to the end of its command; every word that isn't an option is a file it writes.
61const TEE = /\btee\b([^;&|<>\n]*)/g
62
63// Path clues for the item, checked in this order within each piece of text. The worktree
64// folder is '<delivery>--<item>', and the delivery part never holds '--' itself.
65const PATH_ITEMS = [
66 /\.brigade\/worktrees\/(?:(?!--)[^/\s])*--([a-z0-9-]+)/,
67 /packets\/([a-z0-9-]+)\.md/,
68 /state\/([a-z0-9-]+)\.md/,
69 /reports\/([a-z0-9-]+)-(?:cook|verdict)/,
70]
71
72const DISH = /\.brigade\/dishes\/([a-z0-9-]+)\//
73
74// A label shaped '<word>:<slug>' or '<word>:<slug>:<digits>'.
75const LABEL = /^\s*([A-Za-z]+):([a-z0-9-]+)(?::\d+)?\s*$/
76
77function text(value) {
78 return typeof value === 'string' ? value : ''
79}
80
81// The prompt first, then each path, skipping anything that isn't text.
82function clues(evidence) {
83 const paths = Array.isArray(evidence.paths) ? evidence.paths : []
84 return [evidence.prompt, ...paths].map(text).filter(Boolean)
85}
86
87// Blanks out the Planner's state file so it can't pass for an item's state file.
88function withoutPlannerState(value) {
89 return value.replace(new RegExp(PLANNER_STATE.source, 'g'), ' ')
90}
91
92function roleFromType(subagentType) {
93 const type = text(subagentType).toLowerCase()
94 if (!type) return null
95 for (const [needle, role] of TYPE_ROLES) if (type.includes(needle)) return role
96 return null
97}
98
99function roleFromLabel(description) {
100 const label = text(description)
101 const colon = label.indexOf(':')
102 if (colon === -1) return null
103 const word = label.slice(0, colon).trim().toLowerCase()
104 return Object.hasOwn(LABEL_ROLES, word) ? LABEL_ROLES[word] : null
105}
106
107function roleFromWrittenPath(path) {
108 for (const [pattern, role] of WRITTEN_ROLES) if (pattern.test(path)) return role
109 return null
110}
111
112function unquote(value) {
113 return value.replace(/^(["'])(.*)\1$/, '$2')
114}
115
116// Every file a shell command writes with a redirect or with tee.
117function shellTargets(command) {
118 const targets = []
119 for (const match of command.matchAll(REDIRECT)) targets.push(match[1] ?? match[2] ?? match[3])
120 for (const match of command.matchAll(TEE)) {
121 for (const word of match[1].trim().split(/\s+/)) {
122 if (word && !word.startsWith('-')) targets.push(unquote(word))
123 }
124 }
125 return targets.filter(Boolean)
126}
127
128// Where `pattern` next matches in `text` at or after `from`, or -1.
129function findFrom(pattern, text, from) {
130 pattern.lastIndex = from
131 const match = pattern.exec(text)
132 return match ? match.index + match[0].length : -1
133}
134
135// True when one command (no ';', '&', '|' or newline in it) is 'git … worktree add|remove' or
136// 'git … merge … --ff-only', with the words in that order.
137function isStewardCommand(piece) {
138 const git = findFrom(GIT, piece, 0)
139 if (git === -1) return false
140 if (findFrom(WORKTREE, piece, git) !== -1) return true
141 const merge = findFrom(MERGE, piece, git)
142 return merge !== -1 && findFrom(FF_ONLY, piece, merge) !== -1
143}
144
145function roleFromCommand(fullCommand) {
146 const command = fullCommand.slice(0, COMMAND_LIMIT)
147 if (command.split(COMMAND_BREAK).some(isStewardCommand)) return 'steward'
148 for (const target of shellTargets(command)) {
149 const role = roleFromWrittenPath(target)
150 if (role) return role
151 if (ITEM_STATE.test(target) && !PLANNER_STATE.test(target)) return 'cook'
152 }
153 return null
154}
155
156// The role one tool call proves, or null. `act` is { tool, filePath, command }, all optional.
157// Writing proves a role; reading, searching or just naming a path never does.
158export function roleFromAct(act) {
159 if (!act || typeof act !== 'object') return null
160 if (WRITE_TOOLS.has(act.tool)) return roleFromWrittenPath(text(act.filePath))
161 if (act.tool === 'Bash') return roleFromCommand(text(act.command))
162 return null
163}
164
165// How much of a shell command decides what its agent is doing. This runs on every tool call of
166// every agent, so a huge command must cost no more than a short one.
167const ACTIVITY_COMMAND_LIMIT = 400
168
169// The longest activity the board will ever be handed, and the longest file and program names
170// inside one.
171const ACTIVITY_MAX = 32
172const FILE_NAME_MAX = 24
173const PROGRAM_NAME_MAX = 16
174
175const SEARCH_TOOLS = new Set(['Grep', 'Glob'])
176const BRIEFING_TOOLS = new Set(['Agent', 'Task', 'Workflow'])
177const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
178
179// Bits of a shell command that mean it runs a test suite, found by plain substring search.
180const TEST_RUNS = [
181 'node --test', 'npm test', 'npm run test', 'pnpm test', 'yarn test', 'pytest', 'go test', 'cargo test',
182 'gradle test', 'gradlew test', 'mvn test', 'plugin test', 'regression.sh',
183]
184
185// Programs that run the script named by their first argument, so 'bash tests/run.sh' runs a
186// script under a test folder just as './tests/run.sh' does.
187const SCRIPT_RUNNERS = new Set(['sh', 'bash', 'zsh', 'node', 'python', 'python3'])
188
189// Characters that must never reach the board: control characters (newlines and escapes among
190// them), invisible formatting such as right-to-left overrides, line and paragraph separators,
191// and half of a broken surrogate pair. Each one becomes a space.
192const UNDRAWABLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/gu
193
194// Makes outside text safe to draw and at most `max` characters long. Only `max + 1` characters
195// are ever looked at, and a wide character made of two halves is never cut in two.
196function drawable(value, max) {
197 let out = value.slice(0, max + 1).replace(UNDRAWABLE, ' ').slice(0, max)
198 const last = out.charCodeAt(out.length - 1)
199 if (last >= 0xd800 && last <= 0xdbff) out = out.slice(0, -1)
200 return out
201}
202
203function isSeparator(code) {
204 return code === 0x2f || code === 0x5c
205}
206
207// The last part of a path, ignoring trailing slashes, so '/a/b/' gives 'b'. One backward scan.
208function lastSegment(path) {
209 let end = path.length
210 while (end > 0 && isSeparator(path.charCodeAt(end - 1))) end--
211 let start = end
212 while (start > 0 && !isSeparator(path.charCodeAt(start - 1))) start--
213 return path.slice(start, end)
214}
215
216// The drawable last part of a path clipped to `max`, or null when there is nothing to show.
217function nameOf(path, max) {
218 const name = drawable(lastSegment(path), max)
219 return name.trim() ? name : null
220}
221
222function unquoted(word) {
223 return word.replace(/["']/g, '')
224}
225
226// True when one command of a list runs a script under a 'test' or 'tests' folder, either
227// directly or through a shell, node or python.
228function runsTestScript(piece) {
229 const words = piece.trim().split(/\s+/)
230 let program = unquoted(words[0])
231 if (SCRIPT_RUNNERS.has(lastSegment(program))) {
232 program = unquoted(words.slice(1).find((word) => !word.startsWith('-')) ?? '')
233 }
234 const path = `/${program}`
235 return path.includes('/test/') || path.includes('/tests/')
236}
237
238// Commands that only set up the shell (change folder, set a variable, read a settings file).
239// They say nothing about the work, so the activity names the command after them instead.
240// Words that leave the shell or just give back a status, as in 'cd a || exit 1', are skipped the
241// same way, since naming them would read as if the agent were running a program called 'exit'.
242const SETUP_COMMANDS = new Set(['cd', 'export', 'set', 'source', '.', 'pushd', 'popd', 'exit', 'return', 'true', 'false', ':'])
243
244// Words that run the command after them, so the program is the word that follows.
245const WRAPPERS = new Set(['sudo', 'env', 'time', 'command', 'exec', 'nohup'])
246
247// Wrapper options that take the next word as their value, like the user in 'sudo -u root make',
248// so that word is skipped too instead of being read as the program.
249const OPTIONS_WITH_VALUE = { sudo: new Set(['-u', '-g']), env: new Set(['-u']) }
250
251// 'NAME=value', which sets a variable instead of running anything. One anchored run.
252const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
253
254// Splits a shell command into its single commands at '&&', '||', ';', '|' and newlines, in one
255// walk over the characters. Breaks inside quotes or after a backslash don't count, so
256// `export MSG="a; b"` stays one command. A lone '&' doesn't split either, which keeps '2>&1' whole.
257function commandsIn(text) {
258 const pieces = []
259 let start = 0
260 let quote = ''
261 for (let i = 0; i < text.length; i++) {
262 const ch = text[i]
263 if (quote) {
264 if (ch === quote) quote = ''
265 else if (ch === '\\' && quote === '"') i++
266 continue
267 }
268 if (ch === '\\') {
269 i++
270 continue
271 }
272 if (ch === '"' || ch === "'") {
273 quote = ch
274 continue
275 }
276 const width = ch === ';' || ch === '|' || ch === '\n' ? 1 : ch === '&' && text[i + 1] === '&' ? 2 : 0
277 if (width === 0) continue
278 pieces.push(text.slice(start, i))
279 i += width - 1
280 start = i + 1
281 }
282 pieces.push(text.slice(start))
283 return pieces
284}
285
286function isOpener(code) {
287 return code === 0x28 || code === 0x7b || code === 0x21 // ( { !
288}
289
290function isCloser(code) {
291 return code === 0x29 || code === 0x7d || code === 0x3b // ) } ;
292}
293
294// A word as the shell would see it for deciding what runs: no quotes, and none of the brackets
295// of a subshell or group around it, so '(cd' is 'cd' and 'ls)' is 'ls'.
296function bareWord(word) {
297 const w = unquoted(word)
298 let start = 0
299 let end = w.length
300 while (start < end && isOpener(w.charCodeAt(start))) start++
301 while (end > start && isCloser(w.charCodeAt(end - 1))) end--
302 return w.slice(start, end)
303}
304
305// True for a letter, digit or '+', the characters a program name ends on ('g++' keeps its pluses).
306function isNameChar(ch) {
307 return ch === '+' || /[\p{L}\p{N}]/u.test(ch)
308}
309
310// A program's name for the board: the last part of its path, with any punctuation trimmed from
311// both ends and clipped to its limit. Null when nothing is left.
312function programName(word) {
313 const name = lastSegment(word)
314 let start = 0
315 let end = name.length
316 while (start < end && !isNameChar(name[start])) start++
317 while (end > start && !isNameChar(name[end - 1])) end--
318 return nameOf(name.slice(start, end), PROGRAM_NAME_MAX)
319}
320
321// Finds the program one command runs: the first word that isn't an assignment, a wrapper like
322// sudo, or an option given to a wrapper (with its value, for the few that take one). Returns its
323// bare form and the words from it on, or 'setup' when the command only sets up the shell, or
324// null when it holds no word at all.
325function programOf(piece) {
326 const words = piece.trim().split(/\s+/)
327 let seen = false
328 let wrapper = ''
329 for (let i = 0; i < words.length; i++) {
330 const word = bareWord(words[i])
331 if (word === '') continue
332 seen = true
333 if (ASSIGNMENT.test(word)) continue
334 if (WRAPPERS.has(word)) {
335 wrapper = word
336 continue
337 }
338 if (wrapper && word.startsWith('-')) {
339 if (OPTIONS_WITH_VALUE[wrapper]?.has(word)) i++
340 continue
341 }
342 if (SETUP_COMMANDS.has(word)) return 'setup'
343 return { word, line: [word, ...words.slice(i + 1)].join(' ') }
344 }
345 return seen ? 'setup' : null
346}
347
348// What one shell command is doing. Only its first 400 characters are read, and every step below
349// is a single walk over them, so a huge or hostile command costs no more than a short one.
350// A test run anywhere in those characters wins, so 'npm run build && npm test' is running tests.
351// Otherwise the first program found after any leading 'cd', 'export', assignments or wrappers
352// names the activity, and a command that is nothing but those is 'in the shell'.
353function commandActivity(fullCommand) {
354 const raw = fullCommand.slice(0, ACTIVITY_COMMAND_LIMIT)
355 let setup = false
356 let first
357 // Split before scrubbing, since a newline ends a command just as ';' does.
358 for (const piece of commandsIn(raw)) {
359 const found = programOf(piece.replace(UNDRAWABLE, ' '))
360 if (found === null) continue
361 if (found === 'setup') {
362 setup = true
363 continue
364 }
365 if (TEST_RUNS.some((run) => found.line.includes(run)) || runsTestScript(found.line)) return 'running tests'
366 first ??= found
367 }
368 if (!first) return setup ? 'in the shell' : null
369 const program = programName(first.word)
370 if (program === 'git') return 'running git'
371 return program ? `running ${program}` : null
372}
373
374// What one tool call looks like to a person watching, in two or three words, or null when it
375// says nothing useful. `act` is { tool, filePath, command }, all optional. The answer is short
376// and holds nothing but plain characters, because the board draws it as it is.
377export function activityOf(act) {
378 if (!act || typeof act !== 'object') return null
379 const tool = act.tool
380 if (WRITE_TOOLS.has(tool) || tool === 'Read') {
381 const name = nameOf(text(act.filePath), FILE_NAME_MAX)
382 if (!name) return null
383 return `${tool === 'Read' ? 'reading' : 'editing'} ${name}`
384 }
385 if (SEARCH_TOOLS.has(tool)) return 'searching'
386 if (tool === 'Bash') return commandActivity(text(act.command))
387 if (BRIEFING_TOOLS.has(tool)) return 'briefing agents'
388 if (WEB_TOOLS.has(tool)) return 'on the web'
389 return null
390}
391
392function itemFromClues(pieces) {
393 for (const piece of pieces) {
394 const cleaned = withoutPlannerState(piece)
395 for (const pattern of PATH_ITEMS) {
396 const match = cleaned.match(pattern)
397 if (match) return match[1]
398 }
399 }
400 return null
401}
402
403function dishFromClues(pieces) {
404 for (const piece of pieces) {
405 const match = piece.match(DISH)
406 if (match) return match[1]
407 }
408 return null
409}
410
411// Works out who an agent is from loose evidence: { description, subagentType, name, prompt,
412// paths, act }, every field optional. Returns { role, dish, item }; dish and item may be null.
413// The role comes from the agent type, then the label, then what `act` writes. The prompt and
414// paths only give the dish and item. The slug in a label never decides the role either, so
415// 'inspect:cook-roster:1' is an inspector.
416export function identify(evidence) {
417 const ev = evidence && typeof evidence === 'object' ? evidence : {}
418 const pieces = clues(ev)
419 const role = roleFromType(ev.subagentType) ?? roleFromLabel(ev.description) ?? roleFromAct(ev.act) ?? 'agent'
420 const label = text(ev.description).match(LABEL)
421 const item = label ? label[2] : itemFromClues(pieces)
422 return { role, dish: dishFromClues(pieces), item }
423}
424
425// A fresh, empty roster.
426export function emptyFleet() {
427 return { agents: {}, order: [] }
428}
429
430// Copies the roster one level deep, so changing one agent never touches the caller's copy.
431function copyFleet(fleet) {
432 const source = fleet && typeof fleet === 'object' ? fleet : emptyFleet()
433 const agents = {}
434 for (const [id, agent] of Object.entries(source.agents ?? {})) agents[id] = { ...agent }
435 return { agents, order: [...(source.order ?? [])] }
436}
437
438function blank(value) {
439 return value === null || value === undefined || value === ''
440}
441
442// Adds a new agent to an already-copied roster, taking identity from whatever evidence the
443// event carries (often none). Its name is one nobody on the roster has right now; counting the
444// roster instead would hand out a name twice once an agent has been pruned.
445function addAgent(fleet, event) {
446 const who = identify(event)
447 const taken = fleet.order.map((id) => fleet.agents[id]?.name).filter(Boolean)
448 fleet.agents[event.id] = {
449 id: event.id,
450 name: freeName(taken),
451 role: who.role,
452 dish: who.dish,
453 item: who.item,
454 model: blank(event.model) ? null : event.model,
455 description: blank(event.description) ? null : event.description,
456 subagentType: blank(event.subagentType) ? null : event.subagentType,
457 state: 'working',
458 tokens: 0,
459 startedAt: event.at ?? null,
460 endedAt: null,
461 ticket: null,
462 lane: null,
463 activity: null,
464 }
465 fleet.order.push(event.id)
466 return fleet.agents[event.id]
467}
468
469// The main session's id on the roster. A helper agent works one item of one dish and finishes,
470// but the main session moves from dish to dish, so it follows whatever it touched last.
471const MAIN = 'main'
472
473// Fills in what's still unknown about an agent from fresh evidence. A role counts as unknown
474// while it is still the catch-all 'agent'. A helper keeps the first dish and item it showed. The
475// main session takes any new dish it names, dropping the item it had there, and any new item.
476function fillIdentity(agent, who) {
477 if (agent.role === 'agent') agent.role = who.role
478 if (agent.id === MAIN) {
479 if (!blank(who.dish) && who.dish !== agent.dish) {
480 agent.dish = who.dish
481 agent.item = null
482 }
483 if (!blank(who.item)) agent.item = who.item
484 return
485 }
486 if (blank(agent.dish)) agent.dish = who.dish
487 if (blank(agent.item)) agent.item = who.item
488}
489
490// Returns a new roster with one event applied: 'spawn', 'step', 'tool', 'activity' or
491// 'complete'. A 'tool' event is { type, id, at, paths, act }; its paths fill the dish and item,
492// its act the role. An 'activity' event is { type, id, text } and says what a working agent is
493// doing now; it never adds an agent or wakes a finished one.
494// Any event that reaches an agent and carries a time stamps it as the agent's `seenAt`. An event
495// without a time leaves the stamp alone, so a check that applies an event without one only to see
496// whether anything changed still finds nothing new.
497// Events without an id, or of a type we don't know, give back an unchanged copy.
498export function applyEvent(fleet, event) {
499 const next = copyFleet(fleet)
500 if (!event || typeof event !== 'object' || blank(event.id)) return next
501 const agent = applyTo(next, event)
502 if (agent && !blank(event.at)) agent.seenAt = event.at
503 return next
504}
505
506// Applies one event to an already-copied roster and returns the agent it reached, or null when
507// it reached nobody.
508function applyTo(next, event) {
509 const known = Object.hasOwn(next.agents, event.id)
510
511 if (event.type === 'spawn') {
512 if (!known) return addAgent(next, event)
513 const agent = next.agents[event.id]
514 fillIdentity(agent, identify(event))
515 for (const field of ['model', 'description', 'subagentType']) {
516 if (blank(agent[field]) && !blank(event[field])) agent[field] = event[field]
517 }
518 if (blank(agent.startedAt) && !blank(event.at)) agent.startedAt = event.at
519 return agent
520 }
521
522 if (event.type === 'step') {
523 const agent = known ? next.agents[event.id] : addAgent(next, { id: event.id, at: event.at })
524 const tokens = Number(event.tokens)
525 if (Number.isFinite(tokens)) agent.tokens += tokens
526 if (!blank(event.model)) agent.model = event.model
527 return agent
528 }
529
530 if (event.type === 'tool') {
531 const agent = known ? next.agents[event.id] : addAgent(next, { id: event.id, at: event.at })
532 fillIdentity(agent, identify({ paths: event.paths, act: event.act }))
533 return agent
534 }
535
536 if (event.type === 'complete') {
537 if (!known) return null
538 const agent = next.agents[event.id]
539 agent.endedAt = event.at ?? null
540 agent.state = event.reason === 'error' || event.reason === 'aborted' ? 'failed' : 'done'
541 agent.activity = null
542 return agent
543 }
544
545 if (event.type === 'activity') {
546 if (!known) return null
547 const agent = next.agents[event.id]
548 if (agent.state !== 'working') return agent
549 // The text is drawn on the board as it is, so it is made safe here too, whoever sent it.
550 const said = typeof event.text === 'string' ? drawable(event.text, ACTIVITY_MAX) : ''
551 agent.activity = said.trim() ? said : null
552 return agent
553 }
554
555 return null
556}
557
558// Times may arrive as epoch milliseconds or as date strings; both become milliseconds.
559function toMs(value) {
560 if (typeof value === 'number') return value
561 if (value instanceof Date) return value.getTime()
562 if (typeof value === 'string') return Date.parse(value)
563 return NaN
564}
565
566// Drops agents that ended more than keepMs before now. Agents still working always stay.
567export function prune(fleet, now, keepMs) {
568 const next = copyFleet(fleet)
569 const cutoff = toMs(now) - keepMs
570 const keep = next.order.filter((id) => {
571 const agent = next.agents[id]
572 if (!agent || blank(agent.endedAt)) return true
573 const ended = toMs(agent.endedAt)
574 return !(Number.isFinite(ended) && ended < cutoff)
575 })
576 const agents = {}
577 for (const id of keep) if (next.agents[id]) agents[id] = next.agents[id]
578 return { agents, order: keep }
579}
580hooks/board/lib/sprites.mjs 126 lines1// The board's pixel sprites, its palette, and the rules for naming and colouring agents.
2// Each sprite is two animation frames of '#' (lit) and '.' (empty) rows, one set per model size.
3
4// The sprites the terminal draws: 3 cells wide and one text row tall (two pixel rows), so a board
5// with thirty agents on it still fits in one pane. Every size is the same box; the colour says
6// which model an agent runs on, and the shape only hints at it.
7export const SPRITES = {
8 s: [['.#.', '###'], ['.#.', '#.#']],
9 m: [['###', '#.#'], ['###', '.#.']],
10 l: [['#.#', '###'], ['###', '#.#']],
11 xl: [['###', '###'], ['###', '#.#']],
12}
13
14// Cells a terminal sprite covers: width is the bitmap width, height is half its pixel rows, since
15// one cell row shows two pixel rows.
16export const SIZES = { s: { w: 3, h: 1 }, m: { w: 3, h: 1 }, l: { w: 3, h: 1 }, xl: { w: 3, h: 1 } }
17
18// The detailed sprites, for a surface that can draw real pixels, like the desktop picture. They
19// grow with the model: 7x6, 9x8, 11x10 and 13x12 pixels.
20export const ART = {
21 s: [
22 ['..#.#..', '.#####.', '##.#.##', '#######', '.#.#.#.', '#.....#'],
23 ['..#.#..', '.#####.', '##.#.##', '#######', '.#...#.', '..#.#..'],
24 ],
25 m: [
26 ['..#...#..', '...#.#...', '..#####..', '.##.#.##.', '#########', '#.#####.#', '#.#...#.#', '...#.#...'],
27 ['..#...#..', '#..#.#..#', '#.#####.#', '###.#.###', '#########', '.#######.', '.#.....#.', '#.......#'],
28 ],
29 l: [
30 ['...#...#...', '....#.#....', '..#######..', '.#########.', '###..#..###', '###########', '###########', '..##...##..', '.##.###.##.', '##.......##'],
31 ['...#...#...', '#...#.#...#', '#.#######.#', '###########', '###..#..###', '###########', '.#########.', '..##...##..', '.#..###..#.', '..#.....#..'],
32 ],
33 xl: [
34 ['....#...#....', '.....#.#.....', '...#######...', '..#########..', '.###.###.###.', '#############', '#############', '###.#####.###', '..###...###..', '.##..###..##.', '##.........##', '.#.........#.'],
35 ['....#...#....', '#....#.#....#', '#..#######..#', '#.#########.#', '####.###.####', '#############', '.###########.', '..#.#####.#..', '..###...###..', '..#..###..#..', '.#.........#.', '#...........#'],
36 ],
37}
38// Which size a model draws at, from its id, case-insensitive substring match:
39// 'haiku' -> 's', 'sonnet' -> 'm', 'opus' -> 'l', 'fable' or 'mythos' -> 'xl', anything else -> 'm'.
40export function sizeOf(modelId) {
41 const id = String(modelId ?? '').toLowerCase()
42 if (id.includes('haiku')) return 's'
43 if (id.includes('sonnet')) return 'm'
44 if (id.includes('opus')) return 'l'
45 if (id.includes('fable') || id.includes('mythos')) return 'xl'
46 return 'm'
47}
48export const PALETTE = {
49 field: '#0f1020', ink: '#e8e6d9', dim: '#6b7089', header: '#8be9fd',
50 chip: '#e8e6d9', chipInk: '#0f1020', alert: '#ff3b30',
51 kinds: { feature: '#ffd166', bug: '#c77dff', chore: '#06d6a0', docs: '#4cc9f0', research: '#b388ff', contract: '#ffffff' },
52 // A card's background and border, and the lightning in the context meter's cloud.
53 card: '#1a1c2e', cardEdge: '#3a3f5c', bolt: '#ffd166',
54}
55// The model families in the order the legend lists them, each with the colour its sprites draw in.
56export const FAMILIES = [
57 { key: 'haiku', label: 'haiku', color: '#4cc9f0' },
58 { key: 'sonnet', label: 'sonnet', color: '#06d6a0' },
59 { key: 'opus', label: 'opus', color: '#ffd166' },
60 { key: 'fable', label: 'fable', color: '#ff7ab6' },
61]
62// Which family a model id belongs to, case-insensitive substring match: 'haiku', 'sonnet', 'opus',
63// or 'fable' (a 'mythos' id counts as fable too). Anything else, including a value that isn't a
64// string at all, is null, since the id comes from outside and can be any shape.
65export function familyOf(model) {
66 if (typeof model !== 'string') return null
67 const id = model.toLowerCase()
68 if (id.includes('haiku')) return 'haiku'
69 if (id.includes('sonnet')) return 'sonnet'
70 if (id.includes('opus')) return 'opus'
71 if (id.includes('fable') || id.includes('mythos')) return 'fable'
72 return null
73}
74// The little storm cloud next to the context meter: '#' is cloud, '*' is lightning, '.' is empty.
75// Eight pixels wide and four tall, so it fills 8 cells by 2 rows in half blocks: a dome on top,
76// a flat base, then the lightning and rain gaps hanging underneath.
77export const CLOUD = [
78 '..####..',
79 '.######.',
80 '########',
81 '..*..*..',
82]
83// Role -> the mark shown before the name, the label for the hover card, the sprite colour.
84export const ROLES = {
85 planner: { mark: '✦', label: 'planner', color: '#fff3b0' },
86 scout: { mark: '⌕', label: 'scout', color: '#4cc9f0' },
87 cook: { mark: '♨', label: 'cook', color: '#06d6a0' },
88 heavy: { mark: '♨', label: 'heavy cook', color: '#ffd166' },
89 inspector: { mark: '✓', label: 'inspector', color: '#b388ff' },
90 analyst: { mark: '∴', label: 'analyst', color: '#ff9f1c' },
91 steward: { mark: '⚑', label: 'steward', color: '#8be9fd' },
92 agent: { mark: '•', label: 'agent', color: '#e8e6d9' },
93}
94export const NAMES = ['Basil', 'Sage', 'Miso', 'Nori', 'Clove', 'Fennel', 'Juniper', 'Olive', 'Pepper', 'Rye', 'Saffron', 'Tamarind']
95// The n-th agent's name, n from 0: NAMES[n] for the first twelve, then 'Basil 2', 'Sage 2', ...
96export function rosterName(n) {
97 const name = NAMES[n % NAMES.length]
98 const round = Math.floor(n / NAMES.length) + 1
99 return round === 1 ? name : `${name} ${round}`
100}
101// The first name nobody in `taken` already has. Goes through all twelve names, then 'Basil 2',
102// 'Sage 2' and so on, so a name freed by an agent that left gets used again before a new round.
103export function freeName(taken) {
104 const used = new Set(Array.isArray(taken) ? taken : [])
105 // Each round adds twelve fresh names, so this always ends once the rounds outnumber `used`.
106 for (let n = 0; ; n++) {
107 const name = rosterName(n)
108 if (!used.has(name)) return name
109 }
110}
111// The colour a sprite draws in. State wins: PALETTE.alert when 'failed', PALETTE.dim when 'done'.
112// Given a model, the colour of its family, or plain ink when the model names no known family, so
113// every colour on the board means what the legend says. Without a model, the role's colour (an
114// unknown role uses ROLES.agent), which is how older callers still get what they always got.
115export function colorOf(role, state, model) {
116 if (state === 'failed') return PALETTE.alert
117 if (state === 'done') return PALETTE.dim
118 if (model !== undefined) {
119 const family = FAMILIES.find((f) => f.key === familyOf(model))
120 return family ? family.color : PALETTE.ink
121 }
122 // Own keys only, so a role like 'toString' can't pick up an Object method.
123 const known = Object.hasOwn(ROLES, role) ? ROLES[role] : ROLES.agent
124 return known.color
125}
126hooks/board/lib/stage.mjs 189 lines1// The board's stage: where each agent sprite stands, which one the pointer is
2// over, and what its hover card says. Pure functions only, so the pane can
3// call them on every tick without any setup.
4
5// Moves value toward target by at most `step`, landing exactly on it when close.
6function stepToward(value, target, step) {
7 const gap = target - value
8 if (Math.abs(gap) <= step) return target
9 return value + Math.sign(gap) * step
10}
11
12function isCell(p) {
13 return p != null && Number.isFinite(p.x) && Number.isFinite(p.y)
14}
15
16function isBox(b) {
17 return isCell(b) && Number.isFinite(b.w) && Number.isFinite(b.h)
18}
19
20// Two boxes overlap when they share at least one cell.
21function overlaps(a, b) {
22 return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
23}
24
25// A sprite counts as on the board once any cell of its box reaches x = 0.
26// Until then it is waiting in the wings and can't be in anyone's way.
27function onBoard(box) {
28 return box.x + box.w > 0
29}
30
31// How many ticks a sprite needs to walk home with nothing in its way.
32function distance(p, home) {
33 return Math.max(Math.ceil(Math.abs(home.x - p.x) / 2), Math.abs(home.y - p.y))
34}
35
36// A sprite that has been stuck this many ticks in a row jumps straight home.
37const PATIENCE = 8
38
39// Takes one step for every sprite that has a home, without ever drawing two
40// sprites on top of each other or a sprite over a name line (the obstacles).
41//
42// A sprite we haven't seen before walks in from just off the left edge, level
43// with its home. Each step is up to 2 cells across and 1 up or down; when the
44// full step is blocked the sprite tries just the across part, then just the up
45// or down part, and otherwise waits. Sprites move one at a time, nearest home
46// first (ties by id), so each one sees where the earlier ones ended up.
47//
48// Waiting can't last for ever. A sprite stuck for 8 ticks jumps home as soon
49// as nobody is standing there, and a tick in which nobody moves at all while
50// someone has been stuck that long sends everyone home at once, which is what
51// untangles two sprites that need each other's place. Homes never overlap, so
52// a jump always lands somewhere clear.
53//
54// How long a sprite has been stuck rides along as a `wait` field on its
55// position; callers just hand back what they got. Sprites whose agent has no
56// home any more simply disappear. The inputs are left alone.
57export function advance(positions, homes, obstacles = []) {
58 const now = positions ?? {}
59 const where = homes ?? {}
60 const walls = Array.isArray(obstacles) ? obstacles.filter(isBox) : []
61 const ids = Object.keys(where)
62
63 // Where every sprite stands right now. As each one moves this gets its new
64 // spot, so later sprites dodge the moved ones and the not-yet-moved ones.
65 const at = {}
66 for (const id of ids) {
67 const home = where[id]
68 const seen = Object.hasOwn(now, id) && isCell(now[id])
69 const from = seen ? now[id] : { x: -home.w, y: home.y }
70 const wait = seen && Number.isInteger(from.wait) && from.wait > 0 ? from.wait : 0
71 at[id] = { x: from.x, y: from.y, wait }
72 }
73
74 // True when sprite `id` could stand at `p` without touching anyone else
75 // or any name line.
76 const isFree = (id, p) => {
77 const box = { x: p.x, y: p.y, w: where[id].w, h: where[id].h }
78 if (!onBoard(box)) return true
79 if (walls.some((wall) => overlaps(box, wall))) return false
80 return ids.every((other) => {
81 if (other === id) return true
82 const theirs = { x: at[other].x, y: at[other].y, w: where[other].w, h: where[other].h }
83 return !onBoard(theirs) || !overlaps(box, theirs)
84 })
85 }
86
87 const order = ids.slice().sort((a, b) =>
88 distance(at[a], where[a]) - distance(at[b], where[b]) || (a < b ? -1 : a > b ? 1 : 0))
89
90 let moved = false
91 for (const id of order) {
92 const home = where[id]
93 const from = at[id]
94 if (from.x === home.x && from.y === home.y) {
95 at[id] = { x: home.x, y: home.y, wait: 0 }
96 continue
97 }
98 const to = { x: stepToward(from.x, home.x, 2), y: stepToward(from.y, home.y, 1) }
99 const tries = [to, { x: to.x, y: from.y }, { x: from.x, y: to.y }]
100 const step = tries.find((p) => (p.x !== from.x || p.y !== from.y) && isFree(id, p))
101 if (step) {
102 at[id] = { x: step.x, y: step.y, wait: 0 }
103 moved = true
104 continue
105 }
106 const wait = from.wait + 1
107 // A sprite handed to us already overlapping something (the layout moved
108 // under it) can't wait where it is, so it goes home now if home is clear.
109 if ((wait >= PATIENCE || !isFree(id, from)) && isFree(id, home)) {
110 at[id] = { x: home.x, y: home.y, wait: 0 }
111 moved = true
112 continue
113 }
114 at[id] = { x: from.x, y: from.y, wait }
115 }
116
117 // Nobody moved and somebody has run out of patience: everyone is waiting
118 // on someone else, so they all go home together. The same happens in the
119 // rare tick that would otherwise end with an overlap left over from the
120 // input, because everyone at home is always a clear board.
121 const jammed = !moved && ids.some((id) => at[id].wait >= PATIENCE)
122 if (jammed || ids.some((id) => !isFree(id, at[id]))) {
123 for (const id of ids) at[id] = { x: where[id].x, y: where[id].y, wait: 0 }
124 }
125
126 return Object.fromEntries(ids.map((id) => {
127 const { x, y, wait } = at[id]
128 return [id, wait > 0 ? { x, y, wait } : { x, y }]
129 }))
130}
131
132// True once every sprite with a home is standing exactly on it.
133export function settled(positions, homes) {
134 const now = positions ?? {}
135 return Object.keys(homes ?? {}).every((id) => {
136 const at = Object.hasOwn(now, id) ? now[id] : null
137 return at != null && at.x === homes[id].x && at.y === homes[id].y
138 })
139}
140
141// Finds the sprite under a cell. Regions drawn later sit on top, so the last
142// match wins. A region covers x from its left edge up to, but not including,
143// x + w (same for y), so neighbours that touch never both claim a cell.
144export function hitTest(regions, x, y) {
145 const list = regions ?? []
146 for (let i = list.length - 1; i >= 0; i--) {
147 const r = list[i]
148 if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return r.id
149 }
150 return null
151}
152
153// A short token count: plain under a thousand, whole thousands as 'k', and
154// millions with one decimal as 'M'. Anything that isn't a real number reads '0'.
155export function kTokens(n) {
156 if (typeof n !== 'number' || !Number.isFinite(n)) return '0'
157 const sign = n < 0 ? '-' : ''
158 const abs = Math.abs(n)
159 if (Math.round(abs) < 1000) return sign + String(Math.round(abs))
160 // 999,500 and up would round to '1000k', so it reads as millions instead.
161 if (Math.round(abs / 1000) < 1000) return `${sign}${Math.round(abs / 1000)}k`
162 return `${sign}${(abs / 1e6).toFixed(1).replace(/\.0$/, '')}M`
163}
164
165// How long something has been running, in its largest sensible unit:
166// '40s', '12m', or '1h05m'. Partial units are dropped, never rounded up.
167export function elapsed(ms) {
168 if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '0s'
169 const secs = Math.floor(ms / 1000)
170 if (secs < 60) return `${secs}s`
171 const mins = Math.floor(secs / 60)
172 if (mins < 60) return `${mins}m`
173 const hours = Math.floor(mins / 60)
174 return `${hours}h${String(mins % 60).padStart(2, '0')}m`
175}
176
177// The four lines on an agent's hover card. A working inspector says
178// 'reviewing' because that's what it is actually doing; everyone else shows
179// their state as is. The clock stops at endedAt once the agent is finished.
180export function cardLines(agent, roleLabel, now) {
181 const word = agent.state === 'working' && agent.role === 'inspector' ? 'reviewing' : agent.state
182 return [
183 `${agent.name} · ${roleLabel}`,
184 agent.model,
185 agent.item == null ? 'item —' : `item ${agent.item}`,
186 `${word} · ${kTokens(agent.tokens)} tokens · ${elapsed((agent.endedAt ?? now) - agent.startedAt)}`,
187 ]
188}
189hooks/board/lib/weather.mjs 48 lines1// Turns how full the session's context window is into a weather reading for the board.
2// Clear skies mean plenty of room; a storm means compaction is coming.
3
4const NO_READING = { level: 0, label: 'NO READING', glyph: '·', percent: null }
5
6const isNumber = (value) => typeof value === 'number' && Number.isFinite(value)
7
8// Works out the fill percent from what the engine reported, or null when it can't.
9// A reported percent wins; otherwise we divide tokens by the window size.
10function percentFrom(context) {
11 if (!context || typeof context !== 'object') return null
12 if (isNumber(context.percent)) return context.percent
13 if (isNumber(context.tokens) && isNumber(context.window) && context.window > 0) {
14 return (context.tokens / context.window) * 100
15 }
16 return null
17}
18
19/**
20 * Reads the context figures and says what the weather is like.
21 * @param context what the engine reports: { tokens?, window, percent? }
22 * @return { level, label, glyph, percent }, with percent a whole number from 0 to 100 or null
23 */
24export function forecast(context) {
25 const raw = percentFrom(context)
26 if (raw === null) return { ...NO_READING }
27 const percent = Math.round(Math.min(100, Math.max(0, raw)))
28 if (percent < 25) return { level: 0, label: 'CLEAR', glyph: '☀', percent }
29 if (percent < 50) return { level: 1, label: 'CLOUDY', glyph: '☁', percent }
30 if (percent < 75) return { level: 2, label: 'SHOWERS', glyph: '☂', percent }
31 if (percent < 90) return { level: 3, label: 'STORM', glyph: '☇', percent }
32 return { level: 4, label: 'COMPACT SOON', glyph: '↯', percent }
33}
34
35/**
36 * Draws a little fill bar: solid cells for the used share, hollow ones for the rest.
37 * @param percent how full, 0 to 100, or null for no reading (an empty bar)
38 * @param width how many cells wide; anything below 1 gives an empty string
39 * @return the bar as a string
40 */
41export function gauge(percent, width) {
42 const cells = isNumber(width) ? Math.floor(width) : 0
43 if (cells < 1) return ''
44 const share = isNumber(percent) ? Math.min(100, Math.max(0, percent)) : 0
45 const filled = Math.round((share / 100) * cells)
46 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
47}
48