Skip to content

bob viz

bob warp viz shows one warp-drive session. bob viz shows BoB: every project on every fleet machine. It shows what BoB is doing now, what it did, what it cost, and what it needs from Paul. Capability #2026.

This page was written with the fleet feed (#2069). The capability’s last requirement (#2074) added its design section.

bob viz is one browser page, meant to stay open, that answers “what is BoB up to?” across the fleet. The dashboard server serves it at /bob (#2070). Nothing new listens on a port: it is the same server, on the same port, as a project’s /warp monitor. Like that monitor, it is reachable on the tailnet and nowhere else (exposure posture).

It does not replace bob warp viz. Each project’s single-session graph is one card, and every tile links to it. The page stays small multiples: a region that shows a project shows every project the same way, so they can be compared at a glance.

Terminal window
bob viz # prints the /bob URL and opens it
bob viz --tailnet # served on this machine's Tailscale address; open the URL from a laptop

On a tailnet bind it prints the MagicDNS URL first, then labelled MagicDNS: and tailnet IP: lines. On a localhost bind of a machine that has a tailnet address, it says local only — not reachable from other machines and names bob viz --tailnet. It never prints a URL that won’t open (#2918).

On a machine that declares a serve block in machines.json (#2407), the dashboard binds loopback and bob serve puts it behind the machine’s one MagicDNS address: bob viz prints and opens http(s)://<machine>.<tailnet>.ts.net/bob, with the local: URL on the next line. While tailscaled does not hold the route yet, the first line stays the local URL and a serve: line gives the MagicDNS address marked not live yet — run: bob serve --apply. bob dash serve --list marks such a server the same way. Setting it up: Warp Drive → One address for the whole machine.

Behind bob serve the server answers under /<project> (/<project>/warp, /<project>/fleet.json, …). It strips that mount, and every absolute path its pages use (fetches, module imports, links, redirects) carries it, so a page opened at the MagicDNS address never reaches outside its own project’s route (scripts/dashboard/mount.js). The /bob route is the fleet project’s dashboard, mounted the same way.

Every route has a server behind it after a reboot. The fleet project is the BoB source checkout’s always-on service (#3001). Every other routed project runs from a unit of its own, rendered from the same templates, and bob serve --apply installs it. There is one server per project, each on its own ledger port. When deployed code changes, each server hands over to its manager, so none of them keeps serving code from before the deploy (#1603). See Project units.

bob viz starts or reuses a dashboard server exactly as bob warp viz does: the project’s ledger port, the deploy-fresh check (server-fresh.sh) before reuse, and the server’s live self-update. It then opens /bob instead of /warp. Run inside a project, it uses that project’s server. Run anywhere else, it uses the BoB source checkout’s ($BOB_SOURCE, default ~/projects/bigbrain).

To keep that server running without a terminal, install it as a service: bob viz --install-service sets up a launchd agent (macOS) or a systemd user unit (Linux) that starts it at login and restarts it when it exits (#3001). With the service installed, bob viz always goes to the service’s server, wherever you run it. File locations, the reuse rule and the bob doctor line: bob viz Dashboard Service.

The page opens on what is moving across the fleet (#2928). The Active now strip sits above the machine sections. It holds one compact card for every project that is running or needs attention on any machine, then the most recently active projects fleet-wide, so “what is BoB doing right now?” is the first thing on the page instead of a scan through every machine’s cap.

  • What it lists. The feed decides, as active_now[] in bob-fleet-feed/1: every running project, then every attention project, then every project an open todo waits on (#3038; one card per repo, never from a stale machine), then the rest by last_active_at, newest first, up to DASH_ACTIVE_NOW_MAX (default 6). The cap bounds only the recent fill-ins, so a running, attention or todo project is never cut. Ties break by machine, then name, so the order is the same on every host.
  • The card. Each card is the grid’s compact card (#2925) with one more row, on, which gives its state in a word and then its machine: running · The Farm, attention · The Farm, todo · The Farm, recent · 16" MacBook Pro. The state comes first, so a narrow card cuts the machine, never the word. details expands the card in place, as on a tile. The strip card has its own key (@<machine>/<name>), so it opens on its own and never shares an element with its tile, and it reads the same /bob/project/<machine>/<name>.json document.
  • Honest for other machines. A stale snapshot’s project is never running. It appears only as attention (stale), and only when a fact of its own (a run, a blocked preflight, a dead run, a failing bob ready) puts it there. A stale machine therefore does not flood the strip with every project it has, and its idle projects never fill in.
  • No double counting. A project in the strip keeps its tile in its machine section. The strip’s head counts its own cards, 1 running, 2 need attention, 1 needs you, 3 recent (N need you is the cards an open todo waits on, #3038), and never sums the notices waiting on them. Each card’s N need you is that project’s alone. No machine head changes because the strip is there.
  • Page controls. The strip is a collapsible region like a machine (#2922), open by default. Collapsed, it still says N running, M need attention, coloured when something needs attention. While the pointer is in the strip or one of its cards is expanded, a push keeps the cards where they are and the head offers order changed, re-sort, as a machine section does (#2923). The name filter (#2927) narrows it too.

The Active now strip above the grid in the light theme

The Active now strip above the grid in the dark theme

At 390 wide the cards stack in one column (Notices still comes first on a phone):

The Active now strip at 390 wide in the light theme

A warp-drive run is not the only thing BoB runs in a checkout (#2978). A decomposition (bob decompose) or an hour of unstructured work (bob do) runs as a machine on the shared engine. A groom (bob groom), a flight-plan build (bob plan build) or a ritual’s evidence packet (bob ritual prepare) runs as a script. Each of them writes its state in one contract, bob-session-state/1 (schemas/bob-session-state.schema.json), and bob viz shows them the way it shows a coding run.

Field Meaning
kind warp-drive, decompose, do, groom, plan or ritual. A document with no kind is a warp-drive run: its status document predates the contract and validates against it unchanged.
active, phase The writer’s claim that the session runs, and where it is: a machine’s phase, or a run’s running / finished.
target What it works on: {issue, area, scope}, each null when not recorded.
started_at, updated_at, finished_at When it started, last moved, and ended (null while it runs).
outcome How it ended: {status: ok | failed | blocked | timeout | stopped, summary, exit}. summary is the words a tile shows, such as applied 3.
tokens, host, pid What it spent (null = not measured), where it runs, and the process the liveness probe checks.
  • Where each kind writes. A machine writes into its own state file (.claude/.decompose-state.json, .claude/.do-state.json), and machine-engine.js stamps the fields on every write. A PM run writes .claude/.session-runs/<kind>.json when it starts and when it exits. That directory is declared in scripts/lib/derived-paths.json, so it is ignored and never counts as in-flight work. A record that cannot be written is a warning, never a failed run.
  • One reader. scripts/lib/session-state.js sessions(root) reads every session file of a checkout through the contract. It returns active, stale and last, the most recently finished run. The engine’s liveness rule applies (pid, 24 h, recent-write grace): a session whose process is gone reads stale, never live (#2950). project-live.js sessionsCondition() calls the reader for the feed and for audit.js snapshot alike. A groom on farm-01 therefore reaches this page within one refresh interval of the snapshot that carries it.
  • The session row. A tile gains a runs row whenever something other than the warp run is live, such as decompose #2733 · deduping · 4m, or groom area:fleet · running (dead run) for one whose process is gone. With nothing running, the row shows the last finished run instead: last: groom area:fleet 2h ago · applied 3. A live session makes the project running and puts it in Active now. A dead one is a dead-run attention fact. The feed computes the elapsed time and the last-run age, never the page, and a figure it did not read renders as not measured.
  • On the dashboard server. /sessions serves every session of the project root and each worktree (bob-sessions-view/1), and ?root=<worktree> narrows it to one checkout. /sessions/events streams event: sessions whenever any declared session file of that root changes. It watches .claude/ and .claude/.session-runs/, with a 1 s mtime poll behind the watch. /warp is unchanged.
  • warp viz for a machine. The warp viz header has a kind switch. /warp?kind=decompose (or ?kind=do) draws that machine’s phase graph, taken from its own definition, and its run, from the same page controls. bob decompose status and bob do status end with a viz: line giving that URL, or the command that starts the dashboard. bob warp status prints the same line for /warp (#2639).
  • Every machine has a screen (#3815). A machine on the shared engine gets its session screen from one entry in scripts/lib/session-machines.json: its name, the module that exports its MACHINE, and its state file. With that entry /warp?kind=<name> shows the machine’s declared phases in order, the current one marked, and its transition history, the kind switch offers it, and /sessions lists its run. The machine needs no page code. A ?kind= that names no kind answers HTTP 404 and names the kinds that exist.

The grid has two layouts. It opens by project (#2929): one tile per repo, whichever machines have it checked out (By project). By machine is the alternate: one section per machine and one tile per checkout, described here. The by project / by machine toggle in the page header switches between them. The rest of this section describes the by-machine grid, and By project says what changes.

By machine, the grid has one section per machine, in machines.json order, and inside it one tile per project, in the order of the grid’s sort by select (#2477). The machine header shows the source (live, a snapshot and its age, a stale snapshot, or the reason nothing was published) and the machine’s counts in words. Only the machine serving the page opens by default, because its rows are the live ones. Every other machine starts collapsed, and its head still says N projects, X running, Y need attention, Z need you (Z counts the projects an open todo waits on, #3038, and shows only when there is one). Each tile is a compact card (#2925), laid out around the questions asked while scanning: is it moving, is it OK, does it need me, what does it cost. It has a head and four rows:

Row Shows From
head The project name, its state mark, parked or archived, the needs-you mark (○ todo, ○ 2 todos · 1 p1) when an open todo waits on it, and N need you when notices wait on it. A tap anywhere on the head selects the project across the page (Selection); the selected tile is outlined and says selected The feed’s state, reasons, portfolio and needs_you; the count from /notices.json
now coding · #2918 · chunk 2/4 · 12m for a live run (the chunk counted from 1). no run for session: none, the phase and (dead run) for session: stale The feed’s phase, issue, chunk and elapsed_s
todo Only while an open todo waits (#3038): the oldest one’s priority, its words and how long it has waited (p1 · TODO: rotate the token · waiting 2d), then its exact command in a .cmd block — the Notices row’s command. When the todo has a handoff record (#3058), its one-line text and command instead, so the card, the banner and the phone push say the same thing The feed’s needs_you.oldest
last 3h · feat(#2899): bob tour --fleet…: the last commit’s age and subject, cut to the tile’s width, whole in the tooltip with the short SHA. With no readable commit, when the project last moved (#2923) last_commit, else active_age_s
health ready, or the failing verdict and its first failing check (blocked: labels), then the verdict’s age (2h ago). A verdict older than a day (READY_STALE_SECONDS, 86400) reads stale verdict, 2d old instead (#3099). bob ready and every warp preflight refresh the verdict. stale 26h follows only when the machine’s snapshot is stale project_ready (with age_s and stale), and the machine’s stale and age_s
spend 30d usd_per_day as a sparkline and its 30-day total, ≤ when it is an upper bound /series.json (#2072)

Below the rows, details expands the card in place (Expanded card). The link beside it opens the project’s bob warp viz card in a new tab. A project with no known link says so, and its tooltip gives the reason.

Tokens this run, tokens 30d and the snapshot age of a fresh snapshot moved to the expanded card. The compact card has no CI mark. The feed is pushed to every open page and never calls gh, so the last CI run is read only when a card is expanded (health.ci.last_run in bob-project-detail/1).

A stale snapshot’s tiles are dimmed, and every null reads not measured. The N need you count shows nothing when no notice waits, or while /notices.json is unread. The spend row is read on the machine that serves the page, so a tile on another machine shows not measured there, with the reason as its tooltip.

The state mark (#2477). Each tile carries the feed’s state as data-state. A running tile has a dot before its name in --state-active, and an attention tile has one in --state-bad. The dot’s tooltip names the feed’s reasons[], for example needs attention: dead run, stale snapshot. An idle tile has no mark and no fill. The dot is never the only signal: the tile’s own rows already give the phase, the verdict and a stale snapshot’s age in words.

The needs-you mark (#3038). A project an open todo waits on — “run deploy.sh”, “add the secret”, the thing only Paul can clear — carries a hollow ring and a word after its name: ○ todo, ○ 2 todos, ○ 2 todos · 1 p1. The fact is the feed’s: needs_you {count, p1, oldest} per project, derived from the same todo notices the Notices column lists, and its state is needs-you when nothing is running or wrong there. A todo never outranks a blocked or live run: those keep their dot and still show the ring. The ring is plain ink, never a state colour — a todo waits, it is not wrong — so it reads the same in both themes and never looks like attention. The mark clears on the feed refresh after the todo gets completed or is closed, with no viewer action: the server restamps the feed as soon as a notices build sees the todo set change (at most one notices refresh, DASH_NOTICES_REFRESH_S, 60 s by default, while a viewer is connected). While the todo source is unread the fact is null, not measured, and no mark shows.

The tile sort (#2477). One sort by select above the grid orders the tiles within each machine section:

Key Order
recent (default) Running tiles first, then the feed’s last_active_at, newest first (#2923). A project whose recency is not measured sorts last, and its active row reads not measured
name By project name
state attention, then running, then needs-you, then idle (#3038): a todo never outranks a blocked run, and always outranks an idle project
tokens (this run) The run’s tokens, largest first
spend 30d usd_per_day from /series.json, largest first. Until the series are read, the grid sorts by name and says so beside the select

A tie keeps name order, and a value that was not measured sorts last, never as zero. So the snapshot test and a browser render the same bytes.

Stable under pushes (#2923). A push can reorder tiles under recent or state, but it never moves a tile out from under the viewer. While the pointer is over a machine section (its tiles or its head), or one of its cards is expanded (.tile.expanded, set while its expanded card is open), a repaint keeps that machine’s tiles in the order on screen: a tile the push added joins at the end, and one it removed is dropped. When the live order differs, the section head shows order changed, re-sort. Clicking it, choosing a cap, or moving the pointer out of the section applies the new order. The head is inside the hold, so the button stays put while the pointer reaches it (#2956). The renderer takes the held order as hold (holdTiles in bob-tiles.js), so the snapshot test covers it.

The cap (#2923). The grid opens on what is active. Each machine section shows 6 tiles by default. A segmented 6 / 12 / all control in the section head (shown once a choice would change anything) switches it. The choice persists per viewer under bob-viz-view/1: a click sets that machine (cap["machine:<id>"]) and becomes the default for every machine the viewer has not chosen yet (cap.all). While tiles are hidden, the head leads with 6 of 23 projects.

The cap never hides a problem. A tile whose state is running or attention, or that an open todo waits on (#3038), is always shown, even past the cap. The head then names how many are, for example 8 of 23 projects (2 over cap: running/attention/todo). Under recent a running tile already leads, so it is usually an attention tile that sits past the cap.

Parked and archived projects step aside. A project whose feed row carries portfolio: park or archive (the manifest’s _meta.portfolio, written by bob ritual decide) sorts after every active project under every sort key. It is shown only under all, with a parked or archived word after its name. A parked project with a live run or a problem is still shown, like any other pinned tile.

The tiles are rendered by scripts/dashboard/bob-tiles.js. That is a pure function of the feed: it reads no clock and computes no figure. The page imports it from /bob/tiles.js, and tests/test-bob-viz-page.js snapshots the same module’s output. The page takes event: fleet pushes on /warp/events, and re-reads /fleet.json once per refresh_s, because a push leaves ages out. Light and dark themes come from scripts/dashboard/templates/design-tokens.js.

The same repo is often checked out on more than one machine: bigbrain on farm-01 and on the laptops, for example. By machine, it reads as several unrelated tiles, with its notices and spend split between them and no clear answer to which machine is doing the work. By project (#2929), the default, merges them into one tile per repo.

  • Repo identity comes from the feed. Each checkout’s repo is owner/name from its origin remote, normalised across the SSH and HTTPS forms, so a checkout under another directory name on another machine still joins its repo. The feed groups them as projects_by_repo and carries each repo’s combined figures in repos (Fleet feed). A checkout with no origin remote (repo: null) is never merged: it keeps its own tile, in a section for the machine that has it, below the repos.
  • The tile. The head is the repo’s name (owner/name in its tooltip), its combined state mark, N machines, and N need you. The combined state is running when any checkout runs, else the worst of the rest, so a laptop’s stale copy does not paint a live repo attention. Then there is one row per machine: the run (live run · coding · #2929 · chunk 1/2 · 12m on the machine holding it, no run elsewhere), last activity, the checked-out branch, and live or the snapshot’s age.
  • Divergence is flagged. When one checkout’s default branch is behind another’s, its row says behind The Farm by 3. The feed works this out from the SHAs the snapshots carry (divergence), with no fetch. When they do not carry enough, because the branches diverged or are more than 50 commits apart, the row says not measured.
  • Figures are shown once, or per machine, never summed by the page. Forge facts about the repo are counted once. The N need you count is every notice across its checkouts, each counted once. Local facts (the run, tokens, bob ready) stay on each machine’s row. spend 30d is shown per machine (spend 30d · The Farm), because /series.json supplies no per-repo total, and the page never adds them up.
  • The other controls work the same. sort by orders repo tiles. recent uses the newest activity across the repo’s checkouts, and tokens uses the live run’s. spend 30d has no per-repo value, so it sorts by name and says so. The 6 / 12 / all cap counts repos over the whole fleet (6 of 14 repos), not projects per machine, and still never hides a running or attention repo. The name filter matches a repo by its name or any checkout’s. A push holds the order on screen as a machine section does.
  • The Active now strip lists repos. By project, each repo appears once in the strip, where its first checkout stands in active_now[], so the strip and the grid never repeat one repo per machine.
  • The expanded card lists every checkout. details opens the card of the primary checkout: the one holding the live run, else the one that moved last. Above its sections is a Checkouts list with every checkout’s machine, directory name, run, branch, snapshot age and divergence. A #card=<machine>/<name> deep link or a notice’s project link reaches the repo tile that holds that checkout.
  • The choice persists. The layout is stored per viewer in bob-viz-view/1 and mirrored in the URL fragment as #layout=machine (the default is left out), so a pasted link opens the same grid. A feed older than #2929 carries no projects_by_repo, and the grid then renders by machine whichever layout is chosen.

Each card’s details control opens it in place, into a detailed view drawn from bob-project-detail/1 (#2925). The compact rows stay at the top of the card. Below them, the expanded view opens each row up and adds the facts and links that would otherwise cost a click into GitHub, a terminal or warp viz:

Section Shows From the detail document
Now The phase, the issue number and title (linked), chunk N/M, elapsed, tokens this run, the branch, the host session (tmux or iTerm, with its window), the flight plan (linked, done/total) and the bob warp viz card link. A project with no run says no run and still links its card session
Recent The last 5 commits (each linked, with its age and subject), then the open PRs with their CI conclusion and a draft word. A project on another machine shows only its last commit and says why activity
Needs you This project’s notices with their commands, rendered by the Notices column’s own renderNoticeList (bob-notices.js), so the card and the column cannot word a notice differently /notices.json (projectNotices)
Queue The approved count (linked to the approved issues), the next item (linked, from the flight plan or the approved queue), open bugs, open p1s queue
Health The bob ready verdict and each failing check with its fix command, a blocked preflight with its todo and fixes, the CI locus (label, verdict, drift and its fix), the last CI run (linked), harness defects (linked) and open dependency advisories by severity health
Cost Spend 30d and tokens 30d sparklines, $ / merged req, $ / run and fix : feat cost (this server’s bob-series/1)
Reports The latest session report and tick digest (each linked, with its time and a tick’s verdict) and the latest stored briefing reports

Below the sections sits a links row: repo · approved issues · PRs · Actions · warp viz · dev · prod · docs. A link the document has no URL for is left out, never shown dead. A provenance footer names the machine, live or the snapshot’s age (marked stale), and when the detail was read, as an absolute time, since the renderer reads no clock.

Every gap says why. A section the document marks not-measured or unavailable renders that word with its reason. A field it could not read renders not measured, with the document’s not_measured reason as its tooltip. A degraded forge puts one line at the top, naming the gh reason, its fix, and whether an earlier copy is shown. An unread queue is never a zero, and nothing needs you is printed only when the notices were read.

Opening a card. details is a button with aria-expanded, so it works from the keyboard. Keyboard focus stays on it across every repaint: the document landing, a push or a re-read (#2963). On the first open the page fetches /bob/project/<machine>/<name>.json, and the card says loading until the document lands. A failed read says so in the card. While the card stays open, the page re-reads the document every refresh_s (the server rebuilds it at most once per 120 s by default), and a failed re-read keeps the last good document. Several cards may be open at once. An open card spans two grid columns, or the full row below 56rem wide. A push over /warp/events re-renders an open card from the new feed row and its last document, so the card never collapses. Its machine’s tiles also hold their order while it is open (Stable under pushes). The cap never hides an open card, though the head does not count it as running or attention.

Deep link. #card=<machine>/<name> opens the page with that card expanded and scrolls it into view. It scrolls again once the card’s document lands, because what loaded above it in the meantime (Notices, on a phone) has moved it down. The fragment can be repeated (&card=…) and mixed with the sort keys. #card=/<name>, with no machine, opens the project’s first tile on any machine, since a todo names a repo and not a machine. A notice’s machine / project link carries that fragment as its href. Clicking it opens the card and reaches the tile, and a copied link opens the card directly. A Telegram notification can point at the same URL: http://<host>:<port>/bob#card=<machine>/<name>. parseCardHash and cardHash in bob-controls.js read and write the fragment.

The expanded view is rendered by scripts/dashboard/bob-card.js (renderCard), a pure function of the feed row, the detail document and the project’s notices. tests/test-bob-card.js snapshots it (tests/fixtures/bob-viz/card.html) from a detail document captured on farm-01, including a remote stale snapshot, a degraded forge, a blocked run and no run. The page serves it at /bob/card.js. tests/test-bob-viz-page.js snapshots the grid with a card open (tiles-expanded.html).

bob viz with an expanded card, dark theme

bob viz with an expanded card at 390 wide, light theme

Dispatch from the card (#3003). Above the card is a chat input. Type an instruction such as brief, groom area viz-dispatch, or a warp run on one issue (the operation name followed by the issue number), read what it resolves to, press Run to start it here in tmux, and cancel it from its session row. Every part of the reply comes from the server’s operations table, and the page decides none of it. The replies, the keys and the trust boundary are in the reference.

Which harness runs it (#3005). BoB is meant to run in more than one agent harness (#498): Claude Code today, and later Codex CLI, Gemini CLI or Cursor. Dispatch is where that choice becomes visible. The chat input has a harness selector, and the preview names the harness the operation will run in. Only Claude Code can be picked. The others are listed but disabled, and each one names the issues it waits on: #499, which splits BoB into a harness-neutral core with one adapter per harness, and #504, which checks that each harness keeps BoB’s guardrails before it may run unattended.

The seam is one file, scripts/dashboard/harnesses.json. The operations table never names a harness, and the launcher builds no command of its own. Both take the harness’s command from its registry entry. So the step to a second harness is one entry turned on plus its adapter, with no change to the page, the server or launch.sh. When #499’s adapter registry exists, the selector becomes a view over it. The server refuses a request that names a disabled harness, so the page cannot start one by any route. A shell operation such as bob brief runs no harness, and its preview says none. The fields are in the reference.

While anything waits on Paul, a banner spans the top of /bob, above every pane (#3058). It covers any open todo on any fleet project and any run blocked at preflight. The banner shows:

  • how many things wait, e.g. 3 things need you across the fleet, in the state colour
  • the most urgent item’s one-line text in large type, with its machine and project. A blocked run leads, then the item with the highest priority, then the longest wait.
  • why it matters
  • its copy-paste command in a .cmd block
  • a link that opens the todo
  • how many more wait, and that they are listed under Notices

The words are the handoff record’s: the same line Paul’s phone push and the run’s terminal handoff show. A todo with no handoff record falls back to its title and its completed command. A flight plan drained on a todo is labelled flight plan waiting on you, so a stalled plan never sits silently.

The banner reads the same /notices.json as the Notices column. It clears with the notice, within one refresh of the todo getting completed or being closed. It builds on the per-card Needs you mark (#3038) and does not replace it. It is loud by size and placement, not by fill or border: its count is .bad, the page’s one state colour (see Design). scripts/dashboard/bob-handoff.js renders it for both pages, and the bob warp viz page draws the same banner for one run (see warp viz).

Under the header, /bob prints one line about the GitHub quota (#3190):

GitHub quota: graphql 4,120 of 5,000 left · core 4,990 of 5,000 left · resets 10:47 · 1,480 points spent this hour · 412 calls this hour (bigbrain 300, example-web/groom 96, flows 16) · rate-limited this week: 0

Every session on every machine that holds one GitHub token spends one quota. The gh shim keeps it from running out (see The quota budget). The line shows that it is working.

  • What is left comes from the freshest reading in the fleet. Machines that share a token read one server-side number, so the newest reading stands for all of them. The line’s tooltip names the machine that read it.
  • Who spent the hour is summed across machines: calls in the last hour, and the three callers that made the most.
  • Rate-limited this week is summed across machines too. The target is 0. Any other number is drawn in the state colour and named a forge defect.

The line is quiet. It uses the state colour in two cases only: a quota under its floor, where unattended calls are waiting for the reset, and a rate-limited answer this week. It reads the fleet feed’s quota key, so it costs no forge call. A feed that carries no quota draws nothing. scripts/dashboard/bob-quota.js renders it.

Beside the grid, the Notices column lists only what needs Paul, oldest wait first. It is built from the bob-notices/1 document (#2071):

Kind What needs Paul Action
todo An open todo without completed Its link, plus the command that adds completed
blocked A warp run held on a failed preflight, on any machine The failed checks’ fixes, plus the todo
decision An issue whose latest comment is a ## Decision Needed The comment
release A stable tag newer than this machine’s deployed version bob update

Each entry shows its kind, machine and project, how long it has waited, and its command (selected in one click) or link. A notice is gone soon after its source clears.

Filter (#2477). Beside the sort, one select per key narrows the column: kind, machine and priority. The project is not one of them: it is the page-wide selection (#3039), and the column’s own filters compose with it. The choices are the values the notices carry, in codepoint order, and priority is the source issue’s p1-critical..p4-low. Filters combine. Each active filter shows a chip, such as kind: todo ×, that clears it. When a filter or the selection hides any notice, the head reads 2 of 5 need you. Its blocked count still covers every notice, so a filter never hides a blocked run. “nothing needs you” appears only when nothing is waiting at all, whatever the filter. A filter lasts for the page view only: it is neither stored nor put in the URL.

Linked selection (#2477). Colour marks state, never identity, so a project is followed by selection instead: see Selection. A notice’s machine / project is a link that selects the project and reaches its tile. It opens the machine’s section if it is collapsed, scrolls the tile into view and outlines it briefly in --border, with no fill. A todo or a decision lives on the forge, not on a machine, so it reaches the project’s tile on whichever machine lists it.

Below the entries, one did you know (#2077) names a tool provisioned in the server’s project that has never been invoked there, taken from bob tour. It shows the command to try it and bob tour --park <item>, which dismisses it for good. The item changes once a day, is never one Paul has parked, and is not counted as something that needs him. Discovery happens on the page Paul already reads. The server rebuilds /notices.json once per refresh_s (DASH_NOTICES_REFRESH_S, default 60 s), and the page reads it every 10 s.

Under the entries, each source that was not fully read says so:

  • An unreadable forge source shows its gh reason and the fix, such as gh auth login or restarting a server that can no longer run gh.
  • Bridge questions show not measured, because the worker has no read endpoint yet.
  • Machines that published no snapshot are named.

“nothing needs you” appears only when every source was read. If a source failed and nothing was found, the column says so instead. It is never blank. The column is rendered by scripts/dashboard/bob-notices.js, the same kind of pure function as the tiles.

Under the grid, the Activity pane lists every project in the fleet by its last commit, newest first (#2154). Each row shows:

Column What it is
Name The project. It selects the project across the page and reaches its tile, like a Reports row
Sparkline 30 bars, one per day, oldest on the left. A day with commits is a bar scaled from 0 to the busiest day; a day with none is a one-unit tick on the baseline, so a quiet project still reads as a timeline. The tooltip names the range, the total and the busiest day
Branch The checked-out branch, and the word dirty when the tree has uncommitted changes
Age How long before the source read the project its last commit was made
Subject The last commit’s subject, cut with an ellipsis and whole in its tooltip

The page does not scan git. Every figure comes from the fleet feed it already reads for the tiles: last_commit and branch, and activity: {dirty, commits_by_day}, which each machine’s fleet snapshot records with one git log per checkout. A project checked out on several machines is one row: the checkout with the newest last commit, which links to that machine.

Ages are measured from the time the source read the project: the snapshot’s time for another machine, the feed’s for the serving one. A stale snapshot therefore reads as stale. Its row is dimmed and says stale snapshot, and its age is as of that snapshot, never extrapolated from the browser clock.

A project with no activity data (a missing checkout, a directory outside git, or a snapshot published before #2154) is still listed, under a no activity data divider at the end, whatever the sort. The pane sorts by last commit (default) or name, folds after 6 rows and follows the filter box and the page-wide selection. It is rendered by scripts/dashboard/bob-activity.js, served at /bob/activity.js, and Control’s Fleet page carries it too.

Under the grid, the churn strip shows where the money goes and how the work churns, one row per project registered on the serving machine, over 30 days. It is built from the bob-series/1 document (#2072):

Column What it shows
$ / merged req USD per issue a default-branch commit referenced, priced on the sessions that name it. The figure token-report.js --issue N reports for the same window
$ / run USD per warp-drive session
fix : feat fix commits per feat commit, per 7-day bucket and for the window
harness defects Recurrences recorded on the project’s warp-drive state file now: a count, not a trend
flaky reruns Recurrences of checks quarantine-report.js filed, by the day last seen
CI duration Seconds per completed gh run list run, and the median
releases vX.Y.Z tags cut per day, and the count

Every dollar comes from token-report.js’s rate table (#1805), through the same functions the CLI calls, never re-priced in the page. A figure priced on an upper bound, because a session record carries no model, reads ≤$.

Sorting (#3097). Every column sorts. Each header, project included, is a button: clicking it sorts the table by that column, and clicking the active column’s header again reverses it. The active header carries aria-sort and an arrow, and no other header does. The sort by select above the table offers the same keys plus spend 30d, which has no column, and it stays in step with the headers. The table opens sorted by $ / merged req, most expensive first, so it leads with where the money is least productive. A numeric column sorts largest first, and a value that was not measured sorts last in either direction. A reversed sort is carried as the column’s key with its direction, #sort.churn=usd-per-merged-req-asc or #sort.churn=project-desc, in the same stored view and URL fragment as every other sort (#2922).

Each cell is a sparkline and its value. A series whose source could not be read (no token log, no GitHub origin, a gh failure) reads not measured, with the reason as its tooltip. It is never an empty chart that looks like a zero. The server rebuilds /series.json at most once per refresh_s (DASH_SERIES_REFRESH_S, default 300 s), and the page re-reads it on that interval. The strip and the tile rows are rendered by scripts/dashboard/bob-series.js, the same kind of pure function as the tiles.

Under the churn strip, the Reports pane lists the fleet’s reports of the last 14 days, newest first. It is built from the bob-reports/1 document (#2073):

Kind What it is Link
tick A bob tick digest: its verdict, and its markdown (taken, merged, blocked, bugs filed, budget, steps) rendered in place The plan issue the tick ran, which carries the digest comment
session A session-report issue a warp-drive run filed The issue
status A status-report issue /status --file filed The issue

Each entry shows its kind, machine and project, and its date. The newest digest is open; the rest fold. Reports are a view here, not a scroll through GitHub.

A tick writes its digest only on the machine that ran it. The serving machine’s digests come from $BOB_HOME/fleet/ticks/. Every other machine’s newest digest arrives as its snapshot’s last_tick, so a tick on farm-01 shows up on a laptop within one snapshot interval. A machine that published no snapshot, or whose snapshot predates last_tick, is named under the entries as not measured, never read as a machine that ran no tick.

When gh cannot be read, each issue source shows its gh reason and the fix, and the digests still list. “no reports in the last 14 days” appears only when every source was read. The server rebuilds /reports.json at most once per refresh_s (DASH_REPORTS_REFRESH_S, default 120 s), and the page re-reads it on that interval. The pane is rendered by scripts/dashboard/bob-reports.js, the same kind of pure function as the tiles. It renders digest markdown from escaped text, so an issue title inside a digest cannot inject markup.

Under Reports, the Briefings pane lists BoB’s written answers to “what happened and what needs me”, newest first (#2926). It is built from /bob/briefings.json: the bob-briefings/1 index of stored portfolio briefings, extended with the open area briefs and the open project briefs (#3040).

Kind What it is Needs you Links
portfolio A stored briefing/1 document from bob brief --write / --send. The newest is marked latest and is always shown, even past the fold (#2967) Its counts.needs_you Its view on /briefings (/briefings?b=<file>)
area An open brief-labelled issue that bob brief --area keeps (<!-- brief:area area:<slug> -->), with its brief: status word The todos count in the brief’s evidence table The issue, and the published page when the evidence block links one
project An open brief-labelled issue that bob brief --project keeps in the project’s own repo (<!-- brief:project <name> -->), shown with its repo#number and brief: status word The todos count in the brief’s evidence table The issue, and the published page when the evidence block links one

An area brief’s date, published link and counts (open, approved, todos, PRs) come from the evidence block bob brief --area writes into the issue. They are read, never recomputed, so the pane says what the brief says. A brief regenerated more than 7 days ago says its age in words, such as regenerated 9d ago.

The area briefs are one gh issue list --label brief read in the BoB source checkout. The project briefs live in each project’s own repo, so they are one gh search issues --owner <owner> --label brief --state open read over the owner of the BoB checkout’s origin, deduplicated against the first read (the BoB repo’s own project brief arrives from both) — sources["project-briefs"] in the document. When gh cannot be read, the pane names the gh reason and the fix that clears it for that source, and the portfolio briefings still list. The pane sorts by newest, kind, needs you or project (the area label or the project name; a portfolio briefing sorts last). With a project selected (#3039) the pane keeps only that project’s own briefs, listed first whatever the sort: its project brief, the area briefs filed in its repo, and its area-completion briefings. A portfolio briefing covers every project, so it stays, and its link opens /briefings?b=<file>&project=<name>, which shows only that project’s rows, each section counting what it shows. With nothing selected, a filter query that names a project exactly lists its brief first. “no briefings” appears only when every source was read. The server rebuilds /bob/briefings.json at most once per refresh_s (DASH_BRIEFINGS_REFRESH_S, default 300 s), and the page re-reads it on that interval. The pane is rendered by renderBriefingsPane in scripts/dashboard/bob-briefings.js, the same module the /briefings page renders a briefing with. A stale brief’s row is dimmed and still listed, like a stale snapshot’s report.

tests/test-bob-viz-page.js snapshots the pane from committed fixtures (tests/fixtures/bob-viz/briefings-pane.json → briefings-pane.html), including gh unreachable, an empty pane with every source read, and an empty pane with gh unreachable:

The Briefings pane in the light theme, rendered from the committed fixtures

The Briefings pane in the dark theme, rendered from the committed fixtures

A tile’s link comes from /bob/links.json. That is navigation only, built from machines.json and the port ledger, with nothing probed, the same rule as the monitor’s fleet links:

  • this server’s own project links to /warp on the same origin
  • another project on this machine links to its ledger port, on the host the page was opened on
  • a project on another machine links to its tailnet address and ledger port
  • a project with no ledger port, or on a machine with no tailnet address, gets no link, and the tile says which is missing
  • a project routed by a machine’s serve block (#2407) links to that machine’s MagicDNS address instead, http(s)://<machine>.<tailnet>.ts.net/<project>/warp: HTTPS certificates are issued for the name, not the IP. On this machine the link is used only when tailscaled holds the route; another machine’s is taken from its declaration, and fails on click like any other link if that machine has not applied it. The monitor’s fleet row follows the same rule.

A project is selected once, and the whole page follows it (#3039, building on #2477). There is one selection, view.project, and the grid and every project-scoped pane read it:

  • Select. Tap anywhere on a tile’s head (its name, state mark, needs-you mark or N need you count). One tap is enough at phone width, and nothing depends on hover. The tile’s details control and its bob warp viz link sit in the foot and keep their jobs. A row’s machine / project link in Notices, Reports or Briefings selects too, then reaches the tile, so selection is the same from the grid or from a pane. By project (#2929), the selection is a project name, not a machine: every checkout of the repo selects together.
  • What follows it. The selected tile is outlined (2 px, in --text-muted) and says selected in a word, in both themes. Notices, Cost and churn, Reports and Briefings keep only that project’s rows. The match is exact: big selects nothing, where the name filter would match bigbrain. A pane with nothing for the project says so in words: no notices for <project>, no reports for <project> in the last 14 days, no cost or churn for <project> on <machine>, no area or project brief for <project>. The Notices filters (kind, machine, priority) stay the column’s own and compose with it. The filter projects box shows selected: <project> as its placeholder.
  • Clear. One chip in the page header, selected: <project> ×, clears the selection in every pane at once. It appears once, never per pane.
  • Keep and share. The selection persists per viewer in the stored view (bob-viz-view/1), beside the tile cap and the sort choices (#2923), and rides in the URL as ?project=<name>. A link with ?project= opens the page pre-selected and wins over the stored choice. A selection restored from storage is written back into the URL, so the copied address carries it. A ?project= naming a project on no machine in the feed is reported beside the chip (<name> is not on any machine), never silently ignored.

Each renderer takes the selection as opts.project (the grid as opts.selected), so the panes are tested like the query. The --- project selection: end to end in headless Chrome --- section of tests/test-bob-viz-page.js drives the real page, served by the dashboard server with fixture data, at 390 px with touch taps. It checks a pre-selected link, the chip clearing every pane, a tile-head tap selecting and persisting across a reload, a Reports row selecting, a project on no machine, and the outline in both themes. It snapshots the panes in tests/fixtures/bob-viz/selection.html. It needs a headless Chrome ($CHROME, or the Playwright chrome-headless-shell) and says SKIP without one.

Every region is collapsible, every list can be sorted, and a long list folds (#2922). The three controls are built once, in scripts/dashboard/bob-controls.js, and every renderer uses them. A region added later, such as the #2477 grid controls, the Briefings pane or a card, reuses them instead of building its own.

  • Collapse. Each region is a native <details>: Active now, each machine, Notices, Activity, Cost and churn, Reports and Briefings. Its head opens and closes it by click or from the keyboard. A collapsed head still gives its count in words, such as Notices 4 need you, 1 blocked, Reports 12 in the last 14 days or The Farm 3 projects, 1 running, 1 needs attention. The count is coloured only when it holds something running or something wrong, so collapsing a region never hides a problem. collapse all in the page header closes every region. Once they are all closed it becomes expand all.

  • Sort. Each list has a sort by select, and each list declares its own keys. The grid: recent (default), name, state, tokens (this run), spend 30d (The grid). Notices: oldest wait (default), kind, project, machine. Activity: last commit (default), name (Activity). Reports: newest (default), kind, project. Briefings: newest (default), kind, needs you. Cost and churn: $ / merged req (default), project, spend 30d and every other column, each also sortable by clicking its header (Cost and churn). The sort is stable and in codepoint order. A value that was not measured sorts last in either direction and is never treated as zero.

  • Fold. A list longer than 6 rows shows the first 6 of the sorted list and ends in show 10 more (N hidden). The last click names what remains, for example show 4 more (4 hidden). Once every row is shown the footer offers show fewer. A push over /warp/events or a re-poll keeps the number of rows the viewer revealed.

  • Pinned rows. A fold never hides what needs Paul. In Notices, a blocked run and a p1 todo or decision are always shown, even past the fold. In Briefings, the latest portfolio briefing is. The footer counts only the rows still hidden.

  • Filter by name. The filter projects box in the page header narrows the page to the projects whose name contains what is typed, case-insensitive (#2927). / focuses it from anywhere but a text field, Esc clears it, and a name: <query> × chip beside it clears it too. It applies to every machine section, Notices, Cost and churn and Reports, each by its rows’ project name, and to the area and project briefs in Briefings by the area’s or the project’s name — a query naming a project exactly also lists its brief first. A portfolio briefing covers every project, so it always stays. While the query hides rows, a head reads N of M: 2 of 23 projects, 1 of 4 need you, 3 of 14 in the last 14 days. A machine’s running and attention counts stay over every project, so a collapsed section still counts a running or attention project that matches, and a filter never hides a problem. In Notices, nothing needs you is never shown while the query hides a notice, and the query composes with the Notices filter (#2477). A query lifts the 6 / 12 / all tile cap (#2923): every match shows, and the cap control is hidden while the query is set. The query is a renderer option (opts.query), so tests/fixtures/bob-viz/query.html snapshots every region under it.

  • Layout. The by project / by machine toggle in the page header picks the grid’s layout (#2929): one tile per repo (the default) or one per checkout under its machine (By project).

The view is the viewer’s own. Which regions are collapsed, each list’s sort, the grid’s layout (#2929) and the grid’s tile caps (#2923) persist in localStorage under the versioned key bob-viz-view/1. Only a choice that differs from a region’s default is stored, so a machine the viewer opened stays open on the next visit. Every read and write of it is guarded, so with storage blocked the page still renders with its default view: the serving machine and every other region open. The sort keys are also mirrored in the URL fragment, as in /bob#sort.notices=kind&sort.churn=spend-30d, so a link pasted into Telegram or iBoB opens the same view. When the fragment and storage disagree, the fragment wins. The number of rows revealed is kept only for the current page view. The name filter is mirrored in the fragment too, as /bob#q=bob, so a link carries it, but it is never written to localStorage: a fresh visit starts unfiltered.

At 390 wide, the iBoB width (#2293), the heads wrap and every button and select is a touch-sized target:

bob viz at 390 wide in the light theme

bob viz at 390 wide in the dark theme

Filtered by bo, the box holds the query and the chip beside it clears it. Each machine says how many of its projects match, and a machine with none says so. At 390 wide the box takes the full row and is touch-sized:

bob viz filtered by name in the light theme

bob viz filtered by name at 390 wide in the dark theme

/briefings keeps its own fold: 8 items per section, then the rest under one native N more disclosure (FOLD_AFTER in bob-briefings.js). A briefing is a stored document, read once from top to bottom on a phone. It has no re-poll to survive and no per-viewer state to keep, so a single disclosure serves it better than 6 rows plus 10 more.

The page is meant to be read at a glance and studied for a long time. #2074 reviewed every region against Tufte’s principles, using the dataviz and frontend-design skills. The rules it settled are kept as checkboxes in the design checklist. A new pane cites that list, and tests/test-bob-viz-page.js enforces what can be checked mechanically.

bob viz in the light theme, rendered from the committed fixtures

bob viz in the dark theme, rendered from the committed fixtures

Both images are rendered from the committed pane fixtures, not from live data: node scripts/dashboard/bob-viz-shots.js regenerates them.

Ink that carries no data is removed. A tile had a border and a card fill. Every churn row, notice and report had a rule under it. The machine tag was a pill, and each command sat in a box. None of those said anything the text did not already say, so all are gone:

  • Whitespace separates. Tiles sit in a gutter, and list entries are spaced apart.
  • One hairline rule (--hairline in --border) sits under each section head and each table head. It marks where a region starts, and nothing else does.
  • Fills are limited to the page ground, the theme button, and a hovered tile, which shows that the tile is a link.

The grid compares projects, so every tile is the same shape. The width is fixed by the grid, the rows are the same and in the same order, and an unread value keeps its row as not measured instead of dropping it. Machines follow machines.json. Projects follow the viewer’s sort key, with name as the tiebreak. Under name a tile’s position is stable from one refresh to the next, so a change in a value is the only thing that moves. Under recent or state, a tile moves only when its activity or state changes, and that move is itself the news. A stale machine keeps its tiles, dimmed, so its absence from the live state is visible.

The dataviz skill’s line spec (2px lines, markers at least 8px, a legend for two or more series) is for charts. /bob draws only word-sized sparklines, Tufte’s term for a chart about the size of a word. Those follow the skill’s rules for a single series:

  • one series, so no legend: the column head or row label names it
  • a line in neutral ink (--viz-line) with no fill, axis or grid
  • the last known point marked with a dot in --viz-point, and the value beside it as the only label
  • scaled from 0 (or the lowest value, when negative), so a flat series is not magnified
  • a gap for a missing point, and not measured for an unread series

The sizes are 64×16 in the churn table and 40×12 on a tile. At that size a 2px line fills the row, so the stroke is one unit and the end dot has radius 1.5. That is the one departure from the skill’s chart spec.

Colour marks only the two states that need a look:

  • --state-active: a run in flight
  • --state-bad: a failed verdict, a stale snapshot or an unreadable source

The normal case, ready, is plain ink. Links are ink with a quiet underline, so blue always means a live run. Every coloured value is also a word, so the page never relies on colour alone.

The tile’s state mark is the one non-text use of a state token (#2477). It is a dot, coloured through the same .active and .bad rules, with the reasons in its tooltip. The ask was a tinted tile background. A fill would break the data-ink rule above, a per-project hue would carry identity rather than state, and identity is already carried by the linked selection between a tile and its notices.

The dataviz palette validator chose this pair. The first design coloured ready green. Red against that green falls in the colour-blind floor band in dark (deutan ΔE 7.4) and fails in light (5.5), and the dark green was outside the lightness band. Blue and red pass every check in both themes (ΔE 26.7 dark, 29.4 light). The state tokens are separate from --accent because a dashboard theme can replace the accent: forest makes it a green that is outside the dark lightness band. No theme overrides the state tokens, and the page test runs the validator against every theme.

The server substitutes only the design tokens into the page, so the page paints its own ground, ink and font. Before #2074 it did not. The dark theme drew light text on the browser’s white canvas, in its default serif font, and the light theme was readable only because its ink happens to be dark. The page test now checks for the ground, and checks that every state colour and sparkline ink clears 3:1 on it in every theme, dark and light.

Every figure comes from a declared data contract that the CLI also emits. No figure is computed only in the page.

every fleet machine the machine serving the page
─────────────────── ────────────────────────────
project-live.js ─► audit.js snapshot ─► $BOB_FLEET_SNAPSHOTS/<id>.json ──┐
(on a timer, #2067) │
├─► fleet-feed.js ─┬─► /fleet.json
project-live.js (this machine, read live) ─────────────────────────────────┘ bob-fleet-feed/1 └─► event: fleet on /warp/events
  • One reader. scripts/fleet/project-live.js reads one project from disk. It returns the warp run (session, phase, start, tokens), every other session through bob-session-state/1 (Every session), the last commit, and the last bob ready verdict. The machine serving the page calls it for its own projects. Every other machine calls it inside audit.js snapshot. A live row and a remote row therefore always come from the same code.
  • The transport is the snapshot. Each machine publishes a snapshot on a timer (Fleet Snapshot Timer) to $BOB_FLEET_SNAPSHOTS. Remote state reaches the page only through that directory. A machine that does not share the directory, or whose timer is not running, shows up as not having published, never as live. The server never pulls over SSH to fill a gap. A down machine would stall every refresh, and a pull on every tab would grow with the number of viewers.
  • The feed. scripts/dashboard/fleet-feed.js merges the two sources into bob-fleet-feed/1 (reference). The server serves it at /fleet.json and pushes it as event: fleet on /warp/events, the stream the /warp page already holds open. The server rebuilds it at most once per refresh interval (refresh_s, default 10 s), and only while a stream is connected, however many tabs are open. It pushes only when the feed has changed: the clock ticking is not a change. A snapshot that lands in the directory reaches every open stream within one refresh interval.
  • State is derived in the feed (#2477). Each project row carries state: running for a live session, attention for a blocked preflight, a dead run, a failing bob ready or a stale snapshot, else idle. It also carries reasons[], which names the facts that set it. Each machine carries counts: {projects, running, attention}. The page renders these values and never re-derives them. state is part of the push signature, so a project that turns to attention reaches every open page within one refresh interval (Fleet feed).
  • So is recency (#2923). Each project row carries last_active_at, the newest of its last commit and, while a run is alive, that run’s last phase transition, and active_from, which names the fact that won. It is null when neither is readable, and the grid never reads a null as old. The row also carries portfolio, the manifest’s _meta.portfolio, so a parked or archived project can step aside. The reader adds the run’s last_transition_at and portfolioOf(manifest), and audit.js snapshot carries portfolio, so a remote row has it too. Both keys are part of the push signature, so a project that moves re-sorts every open page.
  • So is repo identity (#2929). The reader records each checkout’s repo (owner/name from its origin remote) and where its default branch stands, and audit.js snapshot carries both. The feed groups the checkouts of one repo across machines as projects_by_repo, puts each repo’s combined state, recency and live machine in repos, and sets each checkout’s divergence (behind <machine> by N, current or not-measured) from the SHAs the snapshots carry. Nothing is fetched to say it.
  • An expanded card fetches its own document (#2924). What a card shows beyond the feed, including the run’s issue and flight plan, recent commits and PRs, the queue, health, cost, reports and links, is bob-project-detail/1 (reference). The server serves it at /bob/project/<machine>/<name>.json. It is never pushed: a card fetches it when opened, and the server caches it per project for DASH_DETAIL_REFRESH_S (default 120 s), so only an opened project costs gh reads. A project on another machine is built from its snapshot’s fields, and the forge parts are read on the serving machine. When the forge is down, the last good document is served with its age and the gh reason.

The page’s first job is not to lie about state. That rule dates from #1548, “the page never lies about a dead loop”. #1737 extended it across machines, and the feed carries it in its data:

  1. Every machine row says where its data came from and how old it is. It is either live (read now), a snapshot with age_s, a stale snapshot (older than stale_after_s, the same 24 h window audit.js view uses), or no snapshot at all, with the reason.
  2. Not measured is not zero. A value the source did not record is null and renders as not measured. It never shows as 0 tokens, an idle phase or a blank commit. A snapshot published before #2069 has no warp data, so its projects show not measured for them.
  3. “No run” is a measurement. session: none means the state file was checked and no run exists. session: stale means a state file exists but the engine’s own liveness probe says the run is dead. The card shows where the run stopped, never that it is running.
  4. Elapsed time is as of the snapshot. A remote run’s elapsed_s counts up to when that machine read it, not up to now. Age and elapsed time are shown together and never added.
  5. The registry decides the fleet. Machines come from machines.json. A snapshot from an undeclared machine is ignored, and a declared machine with no snapshot is still listed.
Region What it means Fed by Status
GitHub quota One line: what is left and when it resets, who spent the last hour, rate-limited answers this week against a target of 0 /fleet.json: each machine’s quota (bob-forge-quota/1), read from the files the gh shim keeps Built (#3190): see GitHub quota
Needs you banner The most urgent thing waiting on Paul, in the handoff record’s words: the count, the one-line text, why it matters, the command and the todo link, above every pane; a drained flight plan reads waiting on you /notices.json: the todo and blocked notices, each carrying its handoff record from the fleet feed’s handoffs Built (#3058): see Needs you banner
Active now One compact card per project that is running or needs attention on any machine, then the most recently active fleet-wide, above the machine sections (#2928). Each card names its state and machine and expands in place; a stale snapshot’s project shows only as attention (stale); a project here keeps its tile in its machine section, and no head counts twice The fleet feed’s active_now[] ({machine, name} references, DASH_ACTIVE_NOW_MAX, default 6); the expanded card from /bob/project/<machine>/<name>.json Built (#2928): see Active now
Machine and project grid By project (the default, #2929): one tile per repo with a row per machine that has it checked out, the combined state, behind <machine> by N on a lagging checkout, and a card that lists every checkout; a checkout with no repo keeps its own tile under its machine. By machine: one compact card per project on every machine (#2925): a head with the name, state mark and N need you, then now, last, health and spend 30d, under a machine header that shows source, age and counts. Each card expands in place into the detailed view, and #card=<machine>/<name> opens one expanded. A stale snapshot’s tiles are dimmed. Each tile carries a state mark, the tiles sort by recent (default) / name / state / tokens / spend 30d, and only the serving machine opens by default. Each machine shows 6 / 12 / all tiles, never hiding a running or attention tile; parked and archived projects step aside, and a push never moves a hovered or expanded tile The fleet feed (state, reasons, counts, last_active_at, portfolio, issue, chunk, repo, divergence, projects_by_repo, repos); the expanded card from /bob/project/<machine>/<name>.json (bob-project-detail/1) Built (#2070): /bob. At-a-glance controls (#2477). Opens on what is active (#2923). Compact and expanded card (#2925). By project (#2929)
Session card One run’s phase graph and history, bob warp viz as a card /warp/state.json, /warp/events Exists (#1552). Each grid tile links to its card (#2070)
Notices Only what needs Paul: todos awaiting completed, blocked runs, decisions waiting, stable releases ready to bob update. Each notice carries its exact command or link and disappears when its source clears. It filters by kind, machine and priority, and follows the page-wide project selection (#2477, #3039) /notices.json (bob-notices/1): forge searches, the fleet feed’s preflight, tags and .bob-version Built (#2071): the Notices column on /bob, with a daily did-you-know from bob tour below it (#2077). Bridge questions are not measured until the worker exposes a read endpoint
Activity Every project by its last commit, newest first: name, a 30-day commit sparkline, branch, a dirty marker, age and subject. One row per project at its freshest checkout; ages as of each source’s read; no activity data listed last under a divider The fleet feed’s last_commit, branch and activity ({dirty, commits_by_day}, recorded by audit.js snapshot) Built (#2154): see Activity
Cost and churn Tokens and USD per project, run and merged requirement. Fix-to-feat ratio, defect recurrence, flaky reruns, CI duration and releases, all as sparklines /series.json (bob-series/1): token log (#1585, #1806) priced by the cost model (#1805), git history and tags, the state file, quarantine issues, gh run list Built (#2072): spend and token rows on each tile, and the churn strip on /bob
Reports Tick digests, session summaries and status reports of the last 14 days, newest first, linked to their issues; each digest’s markdown rendered in place /reports.json (bob-reports/1): $BOB_HOME/fleet/ticks/, each snapshot’s last_tick, session-report and status-report issue searches Built (#2073): the Reports pane on /bob
Briefings The latest briefing — Needs you first, then Done, Doing, Will do, each head with its count, long lists folded, every item linked — and the index of past ones; read on a phone, so one column at 400 wide. On /bob, the Briefings pane under Reports lists recent portfolio briefings and open area briefs, newest first, each with N need you, the newest portfolio briefing marked latest, a brief regenerated over 7 days ago saying its age /briefings.json (bob-briefings/1): the stored briefing/1 documents under $BOB_BRIEFINGS, else $BOB_HOME/briefings (what bob brief --write / --send stores, #2243 / #2245; kept on a schedule by #2246). The pane reads /bob/briefings.json: the same index plus the open brief-labelled area-brief issues (bob brief --area) Built (#2244): its own page /briefings, linked from the /bob header and bob viz --page briefings; see Briefing → The page. The Briefings pane on /bob (#2926): see Briefings
Design Tufte pass: data-ink, small multiples, no decorative colour, legible in both themes scripts/dashboard/templates/design-tokens.js (the dense type scale, --hairline, --state-active, --state-bad, --viz-line, --viz-point) Built (#2074): see Design. tests/test-bob-viz-page.js enforces the stylesheet rules in every theme

A region whose source has no data says so in the same words as the feed: not measured, never an empty chart that looks like a zero.

This table is the page’s pane registry. A new region adds a row here and cites the design checklist: it copies the checkboxes into its requirement and ticks them before merging. It is built on the page controls: it collapses, and its lists sort and fold.