SLOPSHOPPER

Brigade

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…

newpaneguardcommandtimeragents
v0.43.0MITupdated 2026-10-03jimador/brigade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brigade
│ ┃ Brigade board ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./screen.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /brigade-board │ ⎿ brigade: Board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brigade board
▣ client module ./screen.tsx
README

<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

Why

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:

  • Minimum installable surface — three skills, seven agent files, no MCP server, no runtime daemon, no database.
  • Cheap execution — the session plans and never explores or implements; token-heavy work runs on the tier's cheap models.
  • Trustable — an adversarial review gate, real command output required as evidence, deterministic branch and worktree hygiene, and blocked work that comes back as a decision-ready question instead of a guessed value.
  • Self-improving — an analyst pass at handoff feeds concrete failures back into the process.

How a dish runs

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 roleBrigade form
requirementsgrooming + two-stage grilling
analysisscouts
design + design reviewdecomposition + blind plan check
implementationcooks in worktrees
code reviewinspector gate
integrationserialized rebase + fast-forward-only landing
CI runnerthe workflow scripts
QAverification gate + per-criterion acceptance pass
releaseone human-review PR
retrospectiveanalyst

Service tiers

Pick how much model you buy per dish.

★★★ "brigade heavy"★★ (default)★ "brigade light"
planningfrontieropussonnet
first-attempt cookheavy cook (sonnet)cook (haiku)cook (haiku)
scouts per dish≤ 6≤ 4≤ 2
plan checkalwayson triggersnever
analyst retroevery dish (intensive) + every 10 items (standard)every dishevery 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.

Working memory

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.

Configuration and overrides

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.

The board

<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:

  • To do holds items the plan has not dispatched yet; a heavy item carries a heavy pill.
  • Cooking holds an item a cook is working, or one the plan has dispatched; when a cook works an item a review has already failed, the card says second pass.
  • In review holds an item an inspector is working, or whose newest cook report no verdict has answered yet, or that the plan marks in review.
  • Rework holds an item whose newest verdict is FAIL with no report since, with a sent back · N findings pill, or whose newest report says blocked; the plan's rework and blocked statuses land here too.
  • Done holds an item whose newest verdict is PASS, unless the plan has sent it round again, or that the plan marks done.

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.

What ships

PathWhat
skills/brigade/SKILL.mdthe Planner's router: standing rules, the dish checklist, and pointers into the phase companions
skills/brigade/DECOMPOSE.md · EXECUTE.md · HANDOFF.mdthe 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.mdthe Claude/Codex dish lease and wire contract; settings layers and prompt overrides
skills/brigade/SCHEMAS.mdtyped artifact registry — every plan, brief, report, and verdict has a fixed envelope and authority rule
skills/brigade/TIERS.mdservice-tier reference and difficult-planning triggers
skills/brigade/GRAPHITE.mdoptional 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.mdboard-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-statuszero-token dish-state summary; --json for tooling
scripts/brigade-configresolves the config layers and prompt-override stacks; doctor validates
scripts/brigade-coordatomic per-dish Claude/Codex ownership and handoff leases
scripts/brigade-validatezero-token schema conformance checker for dish artifacts
scripts/brigade-evidencezero-token verification-scope classifier — stops a targeted pass being read as repo green
scripts/brigade-bundleregenerates workflows/brigade-*.js; --check catches drift
scripts/board-demoregenerates 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.mdwhat the plugin optimizes for, and the log of hypotheses tested — result and decision per experiment

Writing rules

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.

Requirements

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).

Naming

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.

License

MIT. See LICENSE.

Source 15 files
hooks/board/register.tsx 1198 lines
1import { 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}
1198
hooks/board/lib/board.mjs 138 lines
1// 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}
138
hooks/board/lib/board-layout.mjs 457 lines
1// 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}
457
hooks/board/lib/board-paint.mjs 295 lines
1// 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}
295
hooks/board/lib/board-svg.mjs 316 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
62    .replace(/(on[a-z0-9_-]*\s*)=/gi, '$1&#61;')
63    .replace(/h(ref)/gi, (all, rest) => `&#${all.charCodeAt(0)};${rest}`)
64    .replace(/(url\s*)\(/gi, '$1&#40;')
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}
316
hooks/board/lib/canvas.mjs 186 lines
1// 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}
186
hooks/board/lib/detail.mjs 201 lines
1// 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}
201
hooks/board/lib/dish.mjs 406 lines
1// 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}
406
hooks/board/lib/fleet.mjs 580 lines
1// 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}
580
hooks/board/lib/sprites.mjs 126 lines
1// 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}
126
hooks/board/lib/stage.mjs 189 lines
1// 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}
189
hooks/board/lib/weather.mjs 48 lines
1// 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