Help us shape the future of Go! Take the 2026 Go Developer Survey

buildermesh

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 17, 2026 License: MIT Imports: 1 Imported by: 0

README

buildermesh (bmesh)

A NATS-based mesh for coordinating coding agents: lead and executor threads report events, exchange inbox messages and are woken when they go quiet; routines run a repo's checks (shell commands or headless claude -p sessions) against commits and record results by tree, and a lead's done is checked against them. A repo's work starts as proposal rounds (bmesh ideate): two engineer threads write ranked half-page specs into its ledger, the daemon pushes the pitch, the operator's reply (bmesh choose-ideas) lands the verdicts on main, and bmesh build dispatches an accepted spec to a lead; bmesh scaffold gives a repo the sections and routine kinds a build expects.

Quickstart

A machine with Go and Claude Code (claude on PATH) gets a working single-box mesh in four commands:

go install github.com/rickdiculousli/buildermesh/cmd/bmesh@latest   # or, from a checkout, GOBIN=~/.local/bin go install ./cmd/bmesh
cd ~/repos/your-repo && bmesh init     # asks single or multi-box, writes config.toml, registers this repo, picks the claude runner, says what it set
bmesh serve                            # the hub (embedded on a single box), the daemon and the map at http://127.0.0.1:3774/; keep it running
bmesh repos                            # the registry; then bmesh scaffold your-repo, bmesh ideate your-repo, bmesh choose-ideas, bmesh build your-repo 001, bmesh mesh-status

Recommended, optional: the pstack and method Claude Code plugins. The lead, executor and round skills say, at fixed moments, "if pstack:<skill> is in this session's skill list, run it; otherwise " (poteto-mode as the working style, architect and attack-the-premise when the lead decides the shape, tdd as the executor's default, figure-it-out before any bmesh ask, blast-radius before landing, control-ui for a lead's browser check, exhaust-the-design-space for a round's engineers) and name method:writing-plans for an L spec the same way. The condition is the session's skill list, not the plugin's install: a plugin installed but not invocable in the session counts as absent. Without them a node works from the defaults, and nothing in the binary looks for either.

bmesh init asks for what its flags don't say (--role, --hub, --token-file, --runner, --no-repo), reading answers from stdin (an empty line, or no stdin, takes the default), so a script can drive it; --service also writes the unit that keeps bmesh serve running (a systemd user unit on Linux, a launchd agent on macOS), and with [hub] role set the one that keeps bmesh hub running, and says how to enable each. Neither unit restarts on exit 78, a config the binary refused (Config): the systemd units carry RestartPreventExitStatus=78, and serve's launchd agent, launchd having no per-code stop, ThrottleInterval 300, so a bad config restarts it at most every 5 minutes, never in a tight loop. A unit already there with the same bytes is left alone (unchanged <file>); one that differs (tuned by hand, or written by an older bmesh) is kept as it is, with the new body written beside it as <file>.new (kept <file> (it differs from what init writes); the new one is <file>.new: diff them, and move it over to take it), and no enable command is printed for it; --force replaces it. On Windows it writes nothing and prints the two commands that set it up, to run in cmd (Windows below). It writes the setup flag last (<config dir>/.setup-complete); every other command but help, version, hosts --here and update --notice, finding neither the flag nor a config.toml, prints one line and runs init first when run from a terminal, and is refused (exit 3, run `bmesh init` ) when not. On an existing config.toml it edits nothing without --force (a registry row it already has is left too), but still writes the flag and, with --service, a unit that is missing. Pick the multi-box answer on a box of a mesh of several, and say whether it runs the hub, a leaf of it, or is a client of it (see Several boxes).

The repo it registers must have a main branch, since bmesh builds on main: on origin as last fetched when origin has been fetched, else locally (an unborn main in a fresh git init -b main counts); no network is used. A repo without one is refused before anything is written (exit 3, bmesh: <path> has no main branch; bmesh builds on main (rename the branch, or see https://github.com/rickdiculousli/buildermesh/issues/1; --no-repo sets up without it)), and so is a name whose node token another row already has (Names). config.toml carries what each key it writes means as a comment beside it. Last, before Next:, it prints every setting in effect, read back from the loaded config so the defaults it didn't write show too, each with its value, what it means and the key that changes it; on an existing config, the same block for what is there:

What this box is set to:
  setup: single box. This box runs everything; `bmesh init --force --hub <url>` joins a hub other boxes share instead ([mesh] nats)
  hub: embedded on 127.0.0.1:4222. bmesh serve runs the message hub itself; nothing to install ([mesh] nats, listen)
  runner: claude. Each lead and proposal round is a claude session started for you ([runner] kind)
  repo: your-repo at /home/you/repos/your-repo. Registered: bmesh ideate your-repo proposes changes to it, bmesh build your-repo <NNN> builds one (repos.toml)
  worktrees: /home/you/.bmesh/worktrees. Where bmesh makes the separate checkouts its nodes work in, so yours is never touched ([runner] worktrees)
  map: http://127.0.0.1:3774/. A page showing every running node; open it while bmesh serve runs ([serve] listen)
  proposers: claude-opus-5-5 at xhigh effort. The two sessions of a proposal round, which read the repo and write specs ([models] proposer, proposer_effort)
  leads: claude-opus-5-5 at high effort, unless the spec's judge names another. The session that builds one spec and lands it on main ([models] lead, lead_effort)
  executors: chosen by each lead. Helpers a lead starts for pieces of its spec (bmesh executor new --model)
  updates: notice every 6h, release. The daemon checks for bmesh's newest release and each session says when this binary is behind; bmesh update takes it ([update] check_every, upstream, repo, track)
  service: none. Run bmesh serve yourself and keep it running, or have the OS do it (bmesh init --service)
  routines: on. This box runs any pushed commit's routine commands for every box on the mesh ([runner] checkouts)

One line per registered repo (repo: none with none); a multi-box config shows setup: several boxes and the hub's URL (a password in it as xxxxx) with its token file; service names the unit or plist when the file --service writes exists.

On a single box [mesh] nats stays "": serve embeds the hub on [mesh] listen (default 127.0.0.1:4222), and every other command dials it there, nats://<listen> (an unspecified host, 0.0.0.0 or ::, as 127.0.0.1), in JetStream domain hub with the same token handling. With nothing listening a command fails with bmesh: no hub at 127.0.0.1:4222: is bmesh serve running?; a listen on port 0, where serve picks a free port, is refused, since the CLI can't know which.

The config lives in the OS config dir (os.UserConfigDir: ~/.config/bmesh on Linux, ~/Library/Application Support/bmesh on macOS, %AppData%\bmesh on Windows; an existing ~/.config/bmesh is kept on any OS), the embedded hub's store in the OS cache dir (os.UserCacheDir); see Config. A configured command line runs through the platform's shell (sh on Unix; cmd /C on Windows, where every argument is quoted and one holding a quote or a % is refused) and nothing assumes tmux. Linux and macOS are run; Windows is built and vetted on every change (GOOS=windows go vet ./... is part of verify), and run by users.

Keeping serve running. bmesh serve --restart is a supervisor on any OS (a Linux box without systemd can use it too): it runs bmesh serve as a child, with its own arguments less --restart and --detach (a --config given before serve passed on) and BMESH_SUPERVISED=1 in its environment, from the binary path it read at start, and starts it again whenever it exits, whatever the exit code but 78: a child that exits 78 (it refused the config, see Config) is not started again, the supervisor logs serve exited 78 (config check failed); not restarting: fix the config and start it again and exits 78 itself. Otherwise 5 seconds after each exit (as RestartSec=5), 60 seconds once five runs in a row lasted under a minute each, until a run outlasts that. Ctrl-C or a stop signal (SIGTERM) stops the child (SIGTERM on Unix, killed after 30 s; on Windows it is killed, and the jobs it held go back to the inbox when their ack wait runs out), then the supervisor. It logs its own lines, <time> bmesh serve --restart: started <exe> (pid N), serve exited (exit N), starting again in 5s, beside the child's output: on stderr on Unix, and on Windows, which has no journal, in <state dir>/serve.log (the state dir is $XDG_STATE_HOME/bmesh, else ~/.local/state/bmesh), moved to serve.prev.log (replacing the one there) before it passes 10 MB. There the child gets a console of its own with no window. --detach (Windows only, with --restart; --detach alone is a usage error, exit 2) starts the supervisor again with CREATE_NO_WINDOW (out of the starting process's job where the job allows it), prints bmesh serve: supervisor started with no window (pid N); log in <path> and returns at once.

Windows. bmesh init --service ends with three lines for cmd (not PowerShell, which reads the quotes differently):

schtasks /Create /TN buildermesh /SC ONLOGON /RL LIMITED /F /TR "\"C:\Users\you\go\bin\bmesh.exe\" serve --restart --detach --config \"C:\Users\you\AppData\Roaming\bmesh\config.toml\""
schtasks /Run /TN buildermesh
rem in cmd: bmesh serve then starts at every logon with no window; its log is C:\Users\you\.local\state\bmesh\serve.log

The first makes a task that runs the supervisor at every logon with the user's rights, the second starts it now; the config path goes in --config because a task sets no environment. Each path is quoted for schtasks, so one with spaces passes; cmd has no escape for the \" that quoting takes, so to cmd each path stands outside quotes, and a path holding a character it reads there (", %, &, ^, |, < or >) is refused, init --service: "<path>" cannot be passed through cmd (it holds one of " % & ^ | < >); move it and run init --service again, and so is a task command longer than the 261 characters /TR takes. The task's own process returns at once (--detach), so it leaves no console window open and the task's run time limit never applies. To stop it, taskkill /IM bmesh.exe /F (which ends every bmesh on the box); schtasks /Delete /TN buildermesh /F removes the task.

Updates

What a box follows. [update] track (default release) names the commit every update path below goes to: the daemon's self-update, the notice and bmesh update all read the same target. release follows the newest release, the highest vX.Y.Z tag in main's history (see Releases; a pre-release such as v0.2.0-rc1 is passed over). latest follows main's code head, the newest commit that changes anything outside the spec directory (the commit build verifies and release tags), so even latest never rebuilds for a ledger commit (a dispatch, review or done record). Under release a repo with no release tag yet is followed at its code head too, which the daemon logs once, update: no release tag on main yet; following its code head. A binary that is the target is current; one that has the target in its history (built from main, or by update --latest, past the newest release) is current too, and is never moved back to an older release; one the target doesn't contain (a rollback, or another line) is left alone. A binary installed as go install <module>/cmd/bmesh@vX.Y.Z names a version and no commit: every path resolves that tag to its commit in the repo it reads (fetched with its v* tags) before comparing. A tag that repo doesn't have is logged once by the daemon, update: this binary's release is not on main; left alone, while bmesh update installs the target as for a binary of unknown revision. Updates name both ends by release: v0.1.0 for a tagged commit, v0.1.0+2 (<rev8>) two commits past it, <rev8> with no release in its history.

bmesh version prints the commit the binary was built from, bmesh <rev8> <vcs.time> [modified] from its build info, else from the revision and commit time a self-update stamped into it, else from the main module's pseudo-version, as go install …/cmd/bmesh@latest builds (v0.0.0-20261016043719-ab7e0b543677 prints bmesh ab7e0b54 2026-10-16T04:37:19Z: the 12-hex suffix is the revision and the timestamp the time); bmesh unknown when built with none of them ((devel) names no commit). A tagged module install (go install …/cmd/bmesh@v0.2.0, or @latest once the module has a release, which go resolves to its tag) names a version and no commit, and prints the release alone, bmesh v0.2.0; an update resolves it to its commit (see Updates). A build a self-update stamped at or past a release tag (see Releases) prints the release first: bmesh v0.1.0 (9a5e181a 2026-10-16T22:38:04Z) on the tagged commit, bmesh v0.1.0+3 (<rev8> <time>) three commits past it. The stamp is git describe --tags --long --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*' --exclude '*+*' of the commit at build time, the nearest vX.Y.Z tag in its history (a pre-release such as v0.2.0-rc1 is passed over), and goes with the stamped commit only (a vcs revision that is another commit prints none). Revisions compare by prefix wherever the hint and the updates below compare them, so a module install's 12 hex match the full commit. serve publishes bm.daemon.<host>.started on every start, text the full revision (unknown) and body its commit time (RFC 3339; empty when unknown), and its digest title carries the revision (bmesh <host> <rev8>: …).

With [update] repo = "<registry name>" (default "": off) the daemon keeps itself current: on each kind rescan (once a minute; it needs [runner] checkouts) it compares what [update] track follows on that repo's main checkout, <[runner] checkouts>/<repo directory name>/main at origin/main, with the binary at its path (its version, so a newer binary a bmesh update --latest already swapped in is never built over; its own revision, or release tag resolved there, when that prints none). When the target contains that revision and is ahead it builds ./cmd/bmesh at the target, the checkout moved there for the build and back after (go build -ldflags "-X <module>/internal/selfupdate.stampRevision=<commit> -X <module>/internal/selfupdate.stampTime=<commit time, RFC 3339 UTC> [-X <module>/internal/selfupdate.stampRelease=<vX.Y.Z[+N]>]" -o <temporary file beside the binary>, since go writes no VCS stamp in a git worktree, the release only when a tag is in the commit's history; in a transient systemd-run --user --scope of its own when it runs in a systemd unit, so the build doesn't share the unit's MemoryMax; inline otherwise), runs the build's version and refuses it unless it names the target's commit (update: built binary reports <x>, not <new8>; keeping this binary, logged once per target, the running binary kept), then moves the running binary to bmesh.prev beside it and the build to its path, publishes bm.daemon.<host>.update with text <old> -> <new> (v0.1.0 -> v0.2.0; commit the new revision) and logs update: v0.1.0 -> v0.2.0, then drains: every kind's feed stops taking jobs, every running job's process group is killed, and every job it held or ran goes back to the inbox (a NAK) for the restarted daemon to take at once (see Jobs), then serve stops as on SIGTERM (map, daemon, client, embedded hub) and re-execs the binary with the same arguments (exec on Unix, so the service manager sees the same process; on Windows, which has no exec, it exits 0 and serve --restart starts the new binary 5 seconds later). On Windows a running binary can be renamed but not removed, so the swap works there as on Unix; bmesh.prev that can't be removed (the supervisor's own image, which the update before moved there) is moved to bmesh.prev.old first. A Windows daemon not under serve --restart (no BMESH_SUPERVISED=1) refuses [update] repo, logged once at start, update: [update] repo needs bmesh serve --restart on Windows, which starts the new binary (init --service sets it up); off, since its exit 0 would stop it for good; the notice still runs from the row. Clients reconnect to an embedded hub as after any restart. A build that fails is logged once per target and the daemon keeps running the binary it has; a target that does not contain the running revision (a rollback, or another line) is logged once and left alone; a binary with neither a revision nor a release tag never updates. Before the swap it checks the new binary's config and upgrade walk, and holds the update when either needs the operator (see Upgrading). So a box on release restarts once per release, and one on latest once per code landing. A daemon whose newest update has no started after it and named another revision than its own (the new build crashed at start and bmesh.prev was put back by hand) logs it and pushes bmesh update failed on <host> (priority high) through [notify] before publishing started.

bmesh update [--repo R] [--latest] [--answer KEY=VALUE]... [--defaults] [--apply [--yes]] | --plan [--json] does the same build and swap on demand, on any box and without a daemon: R defaults to [update] repo, else the registry row whose go.mod declares this binary's module. It fetches the repo's main into a bmesh-owned checkout, never the operator's: the daemon's <[runner] checkouts>/<repo directory name>/main when checkouts are configured, else a clone (of the row's origin, of its path without one) under <user cache dir>/bmesh/update/<R>; the daemon and update each hold the advisory lock <checkout>.lock while they move that checkout and build from it, so one never builds from a tree the other is moving (on Unix: on Windows the lock does nothing, so a bmesh update run by hand while the daemon updates can race it). It goes to what [update] track follows; --latest goes to the code head this once, whatever track says, to get a fix before the batch's release, and the next update follows track again without going back: a binary past the newest release prints ahead of v0.2.0, up to date. It prints <old> -> <new> (v0.1.0 -> v0.2.0; under --latest, v0.2.0 -> v0.2.0+2 (<rev8>) when two commits have landed since the release), or up to date; a target that does not contain this binary's revision is refused (exit 3), and so is a build whose version does not name the target's commit (bmesh: update: built binary reports <x>, not <new8>; keeping this binary, exit 3, the binary kept). With no --repo, [update] repo or own-module row (an install from the Quickstart), it reads the module's main instead: it fetches https://<module path>'s refs/heads/main and its v* tags into the bare repo <user cache dir>/bmesh/update/upstream (under the same lock), refuses a target that does not contain this binary's revision as above, runs GOBIN=<temporary dir beside the binary> go install -ldflags "<the same -X stamp of the target's commit and its time>" <module>/cmd/bmesh@<the release tag, or the code head's commit> (scoped as the daemon's build is; stamped because a tagged build names no revision), and refuses or swaps in the result as above, keeping bmesh.prev. Between the build and the swap, and after the swap, the new binary walks the upgrade notes of the releases in between (see Upgrading): a config it refuses, or an ask with no answer, keeps the old binary. After an upstream swap it prints <old> -> <new> and one more line, update the buildermesh plugin too: claude plugin marketplace update buildermesh && claude plugin update buildermesh@buildermesh (then restart Claude Code), since the skills name commands only a matching binary has. A module that can't be read (a binary that doesn't say its module) is refused with no repo to update from: pass --repo or set [update] repo (exit 3). Once this box's binary is current (up to date, ahead of …, or after a swap from either source) it removes the notice file below, so the next session opens without it. Then, when it reaches the hub and this host's own daemon's newest bm.daemon.<host>.started names another revision than the binary now at the path, it prints one more line, this box's daemon still runs <old8>; restart it so it runs <new8>: <command>, <command> the restart of the unit init --service writes: systemctl --user restart bmesh on Linux, launchctl kickstart -k gui/$(id -u)/com.buildermesh.bmesh on macOS, and on Windows, whose supervisor runs detached from the logon task, taskkill /F /IM bmesh.exe & schtasks /Run /TN buildermesh (for cmd; it stops every bmesh.exe, the supervisor and its serve included, then starts the task again); another OS gets stop bmesh serve and start it again. update never restarts the daemon itself: a restart kills the daemon's running jobs, and only its own drain (the self-update above) hands them back. A daemon with [update] repo takes the new target by itself within a couple of minutes; one without (a Mac's) runs the old code until it is restarted. No hub, no started for this host, or a daemon of unknown revision: no line. A daemon run by hand rather than under the unit gets a command that doesn't apply; stop it and start it again. Every command that connects to the hub reads this host's own daemon's newest bm.daemon.<host>.started once (the hub's newest bm.daemon.*.started only when this host has none: it runs no daemon, so a Mac daemon that doesn't self-update never tells another box's leads to update) and, when that daemon runs another revision than this binary, prints one line on stderr, bmesh: k16 runs 1a2b3c4d, this is 9e8f7a6b; run bmesh update, once, after the command's own output (and after its error, if any), never changing its exit code. When it is this host's own daemon and its commit is older than this binary's (by the commit time started carries against the one the binary is stamped with), the binary is current and the daemon is what lags, so the line reads bmesh: this box's daemon runs 1a2b3c4d, this binary is 9e8f7a6b; restart the daemon: <command>, <command> as update names it above; another host's daemon, a newer one, or a commit time either side doesn't know (a started from a daemon older than this rule carries none) keeps run bmesh update. A binary or daemon of unknown revision gets no hint, commands that reach no hub (help, version, init, repos, spec list|next-id|set|template, scaffold) none either, and update none: it prints its own restart line instead.

The notice. A daemon that isn't self-updating from [update] repo checks what [update] track follows on main every [update] check_every (default 6h; 0 turns the check off; it runs in the routine loop, so it needs [runner] checkouts): from [update] repo's main checkout when that row is set and has a checkout here, else from a fetch of the module's main and its tags into <user cache dir>/bmesh/update/upstream, as bmesh update does. It compares the target with the binary at its own path (its version, so a bmesh update that already swapped in a current binary reads as current; its own revision, or release tag, when that prints none; a tag resolved to its commit). When the target contains that revision and is ahead, it writes <state dir>/update.txt (through a temporary file and a rename), one line. For a release it is bmesh: v0.2.0 is out (you run v0.1.0: 046, 047); run bmesh update, then update the buildermesh plugin. For a code head (track = "latest", or no release yet) it is bmesh: 9 commits behind main (landed since: 032 The Quickstart works as written, 033 …); run bmesh update, then update the buildermesh plugin, counting only the commits in <own>..<code head> that change anything outside the spec directory, each spec with the title of docs/proposals/NNN-*.md on main (the id alone without one). The specs are those of the NNN: done at <sha8> subjects in <own>..main whose merged commit <sha8> the target has and the binary hasn't (or that the repo doesn't know), oldest first. It removes the file when the binary is current or ahead, or not in the target's history (a binary of another line, or of unknown revision); with no check (check_every = 0, self-updating from a row, or no checkouts root) it removes it at start. A failed fetch is logged and tried again at the next check. bmesh update --notice prints that file and does nothing else: it never touches the network, never runs or asks for init, and prints nothing (exit 0) when the file is absent; the plugin's session hook runs it after hosts --here (see The skills), and mesh-status prints the line last but for the hub line (see Several boxes).

With [update] upstream = true (and no [update] repo) the check also installs: when the target is ahead it runs the same go install and swap as bmesh update, then publishes update, drains and re-execs exactly as the self-update from a row does. A failed or refused install is logged once per target (update: install failed; keeping this binary), leaves the notice written, and is tried again on the next check (the module proxy can lag a fresh commit by minutes). On Windows it runs only under serve --restart; a daemon without it logs once at start, update: [update] upstream needs bmesh serve --restart on Windows, which starts the new binary (init --service sets it up); notice only, and is left on the notice. It is also left on the notice, logged once, for a binary without a revision or without a path to replace; with check_every = 0 it is off.

Upgrading

From before v0.1.0: start over. v0.1.0 (tagged at 61587e32) was cut before releases carried upgrade notes, so its notes, docs/upgrading/v0.1.0.toml, were written after it and are the one exception to stepping a box forward: an install from before v0.1.0 is uninstalled and set up again, NATS included. In order:

  1. Check that no lead or executor is live (bmesh mesh-status --all), and finish or forget (bmesh node forget <path>) any that are.
  2. Stop and remove the bmesh and bmesh-hub services and any nats-server service, hub or leaf: the systemd user units, the launchd agents, the Windows logon task. A bmesh serve or nats-server run by hand is stopped by hand.
  3. Remove the nats-server installed for bmesh, with its <config dir>/nats.conf and the JetStream store_dir that file names.
  4. Move the config dir aside as <config dir>.pre-0.1.0 (its repos.toml is kept, to publish again) and remove the state dir and the embedded hub's store.
  5. Install go install github.com/rickdiculousli/buildermesh/cmd/bmesh@v0.1.0 (or the release you go to; bmesh update has already done this when it walks the notes).
  6. Run bmesh init --service on the hub box first (several boxes, role hub), then bmesh init --hosts --admin there; then on every other box bmesh init --service (leaf or client) and bmesh init --hosts with an invite from the admin box (bmesh hosts invite <box>).
  7. Put the kept repos.toml back, and have the admin publish it (bmesh repos publish).

A fresh start loses the results and events in the old JetStream store, and the roster and its keys; every spec's verified line keeps its record in git. Every release after v0.1.0 is incremental: its notes hold only the steps an install at the release before needs.

The notes walk. bmesh update walks a box through the notes of every release between the binary it runs and the one it installs, oldest first (a box that jumps from v0.1.0 to v0.3.0 walks v0.2.0's notes, then v0.3.0's), each read from docs/upgrading/<tag>.toml on the update repo's main, not the tag, so notes written after a tag still apply (see Releases, Upgrade notes, for the format). Which releases are between is read from the running binary's revision, not guessed: the release tags in the target's history that the running revision's history lacks. A revision without v0.1.0's commit in its history predates it, and its walk starts with v0.1.0's start-over. The binary being replaced may predate the walk, so the new one does the walking: once update has built the new binary, and before it swaps it in, it runs <new> update --walk --before-swap --from <running revision> --to <target> --dir <main's repo>, which

  • checks the config: it loads this box's config.toml and repos.toml with the new binary (bmesh config check); a config it refuses keeps the old binary, bmesh: config check failed: config <path>: [runner] t3_server is empty: ... then bmesh: keeping this binary (exit 3), so a box never restarts into a binary that won't load its config (a start-over walk skips the check: it moves the config aside);
  • answers the asks: one line each at a terminal, track: boxes now update to releases, not every landing. Follow releases or every landing? [release]/latest, where Enter takes the default and an answer outside the choices is asked again; with no terminal every ask needs --answer <key>=<value> (the key as the ask names it, [update] track, or track) or --defaults, else nothing is written and update refuses, 1 ask waits on an answer ([update] track): pass --answer <key>=<value> for each, or --defaults; nothing changed (exit 3), keeping the old binary. Answers go into config.toml (the old file kept as config.toml.prev, and put back when the new binary then refuses it) or, for a registry ask, into every row of repos.toml (kept as repos.toml.prev), which the admin then publishes (bmesh repos publish) or another box proposes.

The walk starts with <from> -> <to>: 1 ask, 1 step (a start-over adds <release> starts this box over; it loses ...). After the swap and the usual <old> -> <new>, the new binary runs update --walk for the rest. Each run or check step that applies to this box (its os, and its when against the config as it now is; one whose when doesn't hold prints skip <release>#<n>: ...) is printed, as v0.2.0#2 restart bmesh-hub: and its command, and run in sh -c (cmd /C on Windows) with BMESH_CONFIG, BMESH_CONFIG_DIR, BMESH_STATE_DIR, BMESH_STORE (the embedded hub's store, as the config names it), BMESH_FROM and BMESH_TO in its environment. With --apply it runs without asking; at a terminal without it, each asks Run it? [Y/n]; with neither, the walk stops there, the steps from v0.2.0#2 wait: run bmesh update --apply (with --yes for the ones that ask first) (exit 0). A confirm step asks Run it? [y/N] at a terminal and needs --yes without one (step v0.1.0#1 asks first: pass --yes to run it, exit 3). A check's output must show its expect. A step that fails stops the walk, step v0.3.0#1 failed: ...; fix it and run bmesh update again, which goes on from there (exit 1). <state dir>/upgrade.json holds the walk (its ends and the steps done), so bmesh update run again, now on the new binary and up to date, resumes after the last step done. A finished walk writes <state dir>/installed and ends at v0.2.0.

Driven by an agent. bmesh update --plan --json prints the walk and changes nothing: {"from","to","releases","start_over","config","asks","steps","confirm","plan"}, config being ok, the refusal, or start-over, and plan every step for this box's OS with its id (v0.2.0#1), release, kind and fields, done, its answer once given, and applies (whether its when holds now); without --json, <from> -> <to>: 1 ask, 1 step and a line per step. Then bmesh update --defaults --apply --yes (or --answer for each ask) walks it without a terminal. bmesh update --walk --from <revision or release> [--to <revision>] [--dir <repo>] walks a range by hand with this binary (--to defaults to this binary's revision, --dir to the repo update reads main from); --plan takes the same --from, --to and --dir.

Where a box stands. bmesh init writes <state dir>/installed (<version> <revision> <time>, the version being this binary's release or <rev8>), and so does every finished walk; it is a record, not the source of truth. A binary swapped in without a walk (by a binary up to and including v0.1.0, whose update and self-update know no walk, or by the daemon below) finds at its first start that notes are pending when its release is past the record's (v0.1.0 with no record): serve prints, and every command run at a terminal (and bmesh update --notice, which the session hook runs) ends with, bmesh: upgrade notes pending since v0.1.0: run bmesh update; the daemon logs it and pushes it once through [notify] (the line pushed is kept in <state dir>/pending-pushed). That update, finding the binary current, walks the pending notes from the record's revision, else from <state dir>/previous (the revision of the binary a swap replaced, written by update and the daemon), else from v0.1.0. An update that finds the binary current with nothing pending and no record writes one. A box from before v0.1.0 that updated with its own old code is walked from v0.1.0, never through the start-over, since nothing recorded where it started: that is why this section leads with the start-over, and why serve checks its config at start (exit 78 naming the key, which its unit doesn't restart; see Config).

The daemon's self-update ([update] repo, or upstream) keeps its own build and swap and never walks: before the swap it runs the new binary's config check and update --plan --json --from <running> --to <target> --dir <main's checkout>. A refused config is logged and pushed (update to v0.2.0: config check failed: ...; keeping this binary), and so is a walk with an ask or a confirm step, update to v0.2.0 waits on bmesh update (2 asks), rather than guessing an answer; either keeps the running binary, is written as the notice (bmesh update --notice), and is acted on once per target until bmesh update takes it. A walk with only plain steps is swapped in and left pending for the new binary to name; one with nothing to do writes installed for the new binary.

The hub keeps up. bmesh hub is its own process and runs the binary it started with until its service is restarted, so bmesh update (after its other lines), bmesh hosts --here and mesh-status name a hub on this box running an older binary than serve: hub runs v0.1.0, serve v0.2.0: restart bmesh-hub (see Several boxes). A release's notes can restart it with a run step.

Several boxes

A mesh spans boxes through one NATS hub. bmesh embeds a hub only for a single box ([mesh] nats = ""); for several, bmesh hub runs the hub on one box and, where one is used, a leaf on another: the same nats-server bmesh embeds (v2.15.1, as a library), configured from [hub] (Config), so there is nothing to install and no nats.conf to write. It is its own process, apart from bmesh serve: a daemon restarts for every update (with [update] repo, on every release, or every code landing under track = "latest"), and a hub inside it would drop every box each time. bmesh hub runs the binary it started with until its service is restarted by hand; an update never restarts it. So bmesh hub and bmesh serve (the daemon, not the --restart supervisor) each write <state dir>/hub.json and <state dir>/serve.json at start, {"pid","revision","release","time"} of the binary they run, and remove it on a clean stop; when the hub's pid is alive and its binary isn't serve's commit and is older (by commit time, when both have one; else any difference), bmesh hosts --here and mesh-status print hub runs v0.1.0, serve v0.2.0: restart bmesh-hub (each binary its release when stamped, else its revision's first eight characters). Every box runs its own bmesh serve: its daemon serves the leads, routines and map on that box.

bmesh init's multi-box answer asks this box's part, hub, leaf or client (--role says it), and writes [mesh] and [hub] to match; bmesh init --service then writes the hub's unit beside serve's, when [hub] role is set: a systemd user unit bmesh-hub.service (ExecStart=<bmesh> hub, Restart=always, RestartSec=5, RestartPreventExitStatus=78) or a launchd agent com.buildermesh.bmesh-hub (KeepAlive, its log ~/Library/Logs/bmesh-hub.log), restarting always, since the tailnet address may not exist yet at boot (each unit on its own: one that differs from what init writes is kept, with .new beside it, as init says above, so a hand-tuned serve unit survives adding the hub's), and prints how to enable it (systemctl --user daemon-reload && systemctl --user enable --now bmesh-hub, or launchctl bootstrap gui/$(id -u) <plist>). Windows has no unit for it: run bmesh hub yourself.

The hub runs on a box that is always on, listening on its address on a tailnet (Tailscale, say), never on a LAN or public address: the tailnet is the boundary, and a token shared by every box is the only auth. init asks for the address (<tailnet ip>:4222, its default this box's address in 100.64.0.0/10 when it has one) and the leaf port (<the same ip>:7422), and for the token file (default <config dir>/nats.token), which it makes when there is none: 24 random bytes in hex, mode 0600. It writes:

[mesh]
nats = "nats://<tailnet ip>:4222"
token_file = "<config dir>/nats.token"
[hub]
role = "hub"
listen = "<tailnet ip>:4222"
leaf_listen = "<tailnet ip>:7422"

bmesh hub there serves clients on listen with the token, JetStream in domain [mesh] domain (default hub) with its store in [hub] store (default <state dir>/hub/jetstream) up to max_file_store and max_memory_store, and leaves on leaf_listen (user leaf, the token as password, since a leaf port takes a user and password, not a bare token); the server's name is [host] name. It logs nats-server's lines on stderr (the journal under systemd) and stops on SIGTERM or Ctrl-C, letting JetStream flush.

A box joins in one of two ways. As a client, init asks for the hub's URL and the same token file and writes them to [mesh] (--hub and --token-file say them; --hub alone is a client). As a leaf, init asks for the hub's leaf URL and writes:

[mesh]
nats = "nats://127.0.0.1:4222"
token_file = "<config dir>/nats.token"   # the hub's token, copied here (mode 0600); init says so when it isn't there yet
[hub]
role = "leaf"
listen = "127.0.0.1:4222"
remote = "nats://<hub tailnet ip>:7422"

and bmesh hub there runs a server with no JetStream of its own on 127.0.0.1:4222, which dials remote and redials it every second, forever, whenever the link drops; JetStream requests cross the link to the hub's domain. A leaf suits a box that sleeps or roams: its clients always find a local server, and the leaf reconnects to the hub by itself when the box wakes or rejoins the tailnet.

bmesh hub refuses to start (exit 3), naming the key, with [hub] role unset, a listen (or a hub's leaf_listen) that is unset or on every interface (0.0.0.0, :: or no host), a leaf without remote, or [mesh] token_file unset, missing or empty. bmesh serve with [hub] role set and [mesh] nats empty is refused too (exit 3): it would embed a second server where bmesh hub runs this box's.

Instead of bmesh hub, a nats-server of your own does the same job, from a nats.conf with the token in it: listen, authorization { token }, jetstream { domain: hub, store_dir } and leafnodes { listen, authorization { user: leaf, password: <token> } } on the hub, and leafnodes { remotes = [ { url: "nats://leaf:<token>@<hub>:7422" } ] } with no jetstream on a leaf; bmesh only needs [mesh] nats and token_file to reach it.

The token is one random value, the same on every box, in [mesh] token_file, owner-only. Sharing the hub lets boxes see each other's nodes; to act on each other (no SSH between them), the boxes join the admin's roster with bmesh init --hosts (see Hosts).

Joining the roster. On the box the operator works at, bmesh init --hosts --admin makes it the admin: it holds the admin key that signs the roster, and only it invites (bmesh hosts invite <name>, a one-time code good for 10 minutes) and revokes. On each other box, bmesh init --hosts asks for that code at its prompt. Each box answers whether it is secure: a secure box gets an approve key, and what other boxes ask of it waits for the operator's approval at its screen; mark secure the box whose checkouts and plugins matter most, typically the laptop the operator types on. The admin and approve keys are the two keys no agent can use. On macOS they live in the login Keychain, and every use raises the macOS password dialog: bmesh first prints Keychain dialog follows for: <action>; deny any dialog that wasn't announced, and choose "Allow", never "Always Allow" (bmesh refuses a key whose item an app was added to). Elsewhere each is a file sealed with a passphrase asked on the terminal. Either way, admin and approve commands run at the box's own screen, not over SSH. Restart bmesh serve after init --hosts.

What secure covers. secure covers roster ops: level holds and the approve key (see Hosts). It does not cover routines: a box with routines on runs any pushed commit's kind command for any mesh member. Routines are on by default (config.toml's [runner] checkouts, default ~/.bmesh/checkouts; "" turns them off). bmesh init --hosts asks whether the box runs routines, with no the default on a secure box, so a secure Mac that is to run a repo's os = "darwin" kinds answers yes there.

Sleeping boxes. A laptop that sleeps drops off the mesh and comes back on its own: its leaf reconnects, its daemon tells the wake from the gap between its minute ticks, re-reads the roster and takes the jobs it missed, in order (a job lives 24h; an expired one is answered and logged, not pushed). Meanwhile bmesh hosts and every other box's session line show it asleep with how long ago it was seen, and bmesh hosts ping answers for it asleep: queued until <expires>. Keep the hub on a box that doesn't sleep.

Which box a session is on. With [hosts] on, the daemon writes the session line each minute and the plugin's session hook prints it, so every session opens with, for example, this box is hub-box; secure: no; on the mesh: laptop (asleep 2h, secure, admin). A box's own guidance for sessions goes in <config dir>/notes.md (up to 16 KB), which travels signed in its presence; bmesh hosts --notes prints every box's (see Hosts).

Build and test

GOBIN=~/.local/bin go install ./cmd/bmesh
TMPDIR=$HOME/.cache/bmesh-scratch/tmp go test ./...
GOOS=windows go vet ./...    # type-checks the Windows-only files a Linux go vet skips

Tests never use /tmp: meshtest.Start fails when TMPDIR is /tmp.

Testing

Runner tests drive shell fakes instead of tmux and claude, and a fake T3 (below). BMESH_TMUX_COMMAND, when set, is split on spaces and replaces the tmux binary (sh <repo>/testdata/fake-tmux.sh). BMESH_T3_TIMEOUT (a Go duration) replaces the t3 runner's 120 s per-call timeout. BMESH_CLAUDE_COMMAND replaces [runner] claude the same way (sh <repo>/testdata/fake-claude.sh), for routines and for the claude runner's tests. BMESH_SSH_COMMAND replaces the ssh that bmesh ideate reaches another host with (sh <repo>/testdata/fake-ssh.sh). BMESH_GO_COMMAND replaces the go an update builds with (sh <repo>/testdata/fake-go.sh), and BMESH_UPSTREAM_URL replaces https://<module path> as where an update without a row and the notice read the module's main (a local repo in the tests; the cmd/bmesh tests set it, and XDG_STATE_HOME, for the whole package, so no test reaches the network or touches the box's notice). BMESH_SECURITY_COMMAND replaces the macOS security the Keychain key store runs (sh <repo>/testdata/fake-security.sh, which keeps items under $FAKE_SECURITY_DIR and prints the access list $FAKE_SECURITY_ACL names). A level's plugin refresh runs claude through BMESH_CLAUDE_COMMAND (sh <repo>/testdata/fake-claude-plugin.sh) and codex through BMESH_CODEX_COMMAND (sh <repo>/testdata/fake-codex.sh; unset, codex runs only when it is on PATH). BMESH_KEY_STORE (keychain or file) replaces the key store's default when [hosts] key_store is unset; the cmd/bmesh tests set it to file, and BMESH_SECURITY_COMMAND, BMESH_CLAUDE_COMMAND and BMESH_CODEX_COMMAND to the fakes, for the whole package (a test sets its own over them), so no test reaches the real Keychain and its dialog, or the box's own claude and codex. BMESH_CACHE_DIR, an absolute path, replaces the user cache dir (os.UserCacheDir: ~/.cache or $XDG_CACHE_HOME on Linux, always ~/Library/Caches on macOS) where briefs and a claude session's files go; the cmd/bmesh tests set it for the whole package, so they stay out of the box's cache. Leave all ten unset outside tests; the real Keychain dialog is checked by hand on a Mac. The t3 runner, bmesh t3 login, the expiry pushes and the lead start, build, ideate and node forget tests run against internal/runner/t3mcp/t3fake, an httptest T3 0.1.0 (the environment document, the MCP OAuth endpoints and the MCP tools below) answering with the shapes recorded from a live 0.1.0 server, kept in testdata/t3v2/.

scripts/seed.sh [seeder args] starts a throwaway bmesh serve in the background (embedded NATS and HTTP on free loopback ports, routines and ntfy off) with a seeded mesh holding every state the map draws: each node state (working, asking, blocked, silent, held, done, idle), a done lead on the shelf, a round and its engineers, routines with done instances, a queued job, leases, a digest and quota lines. It prints the map's URL, the check command for it and a CLI config for poking the mesh, and returns; serve keeps running. State lives under ~/.cache/bmesh-scratch/seed/<checkout directory name> (BMESH_SEED_DIR overrides it, BMESH_SEED_HTTP the listen address), wiped on each run, so two checkouts seed side by side; seed.pid there holds serve's pid, and a run stops its checkout's previous one first. scripts/seed.sh stop stops it by that pidfile; never pkill -f its command line, which matches the caller's own shell. The seeded serve reads the page from the checkout's internal/web/ on every request, so an edit to index.html, map.js or map.css shows on reload; a Go change needs a re-run. BMESH_WEB_DIR=<dir> does that for any bmesh serve (it logs bmesh serve: map page from <dir>); unset, the page is the one embedded at build.

node scripts/check.mjs <url> <width> <height> [png] (Node 22 or later) is the map's browser check: it loads url (adding ?check=1) in headless Chromium at that viewport over the DevTools protocol, waits for the self-check title to show a loaded tree on a live connection (trees= at least 1, conn= ending live), prints the title, writes a screenshot of the viewport to png when given, and exits 0, or 1 when no such title showed within 20 s (the last title printed all the same), 2 on a usage or browser error. A hash picks the view (/#/lead/<path>, /#/routines). Chromium is $CHROME, else the newest headless shell or Chromium in the Playwright cache ($PLAYWRIGHT_BROWSERS_PATH, ~/.cache/ms-playwright, ~/Library/Caches/ms-playwright); stderr names the one it runs. chromium --dump-dom is no substitute: the page's /events stream never ends, so it never returns. A UI change's check is scripts/seed.sh, then the printed node scripts/check.mjs ... 390 844 shot.png and the same at 1280 800, then scripts/seed.sh stop.

Go tests share fixtures in internal/meshtest: Start (an embedded server), Git(t, dir, args...) (git with a fixed identity and no global config, hooks or signing; its output, trimmed), CommitFiles(t, repo, files) (writes and commits them, returns the commit), SyncBuffer (a log sink goroutines and the test share), ForceState(t, c, path, mesh.State{...}) (writes a node's state past the transition table, for a fixture that needs done or silent in one step) and QueueJob(t, c, kind, from, job) (creates the kind's consumer, then sends the job, so it counts toward the kind's depth).

Runners

bmesh lead start --repo R --spec NNN --brief FILE [--model M] [--effort E] [--title T] [--path P] starts a lead through [runner] kind on --model (default [models] lead) at --effort (low, medium, high, xhigh or max; default [models] lead_effort, "" the runner's own) and registers it as node <repo>-<NNN> (<repo> the registry name's node token, see Names; state idle, the model and effort recorded on the node), printing {"path","thread","branch","worktree"}. A path whose node is not done or blocked is refused (exit 3); a done or blocked path is reused and its state reset to idle. The runner's start runs under the runner's own timeout, not the CLI's one-minute deadline; if it times out, or registration fails after it, the error names the thread id (with the t3 runner, which T3 names, the branch the thread would be on: start timed out; a thread on branch bmesh/<id> may exist) (and branch and worktree when known) so the session can be found.

  • t3: a thread in the box's T3 Code 0.1.0, [runner] t3_server (default http://127.0.0.1:3773; "" is a config error, naming the key). Each call reads <t3_server>/.well-known/t3/environment (a 2 is trusted for a minute) and goes on only for orchestrationProtocolVersion 2: a 1 (T3 before 0.1.0) is refused at start, status, send and stop with bmesh: T3 <version> at <t3_server> speaks protocol 1; bmesh needs T3 0.1.0 or later, any other version with an error naming it. A thread id that isn't T3 0.1.0's (mcp:<uuid>) is "no such thread". Every thread starts in [runner] t3_runtime_mode: auto (the default: Claude Code's auto permission mode, where a classifier denies risky actions instead of asking anyone) or full-access (bypassPermissions), the per-box fallback; config load refuses any other mode, since approval-required and auto-accept-edits stall an unattended lead. Each call is bounded at 120 s. send reads the status first and never sends to a running thread. It speaks T3's MCP tools at <t3_server>/mcp (streamable HTTP, JSON-RPC, the mcp-session-id header, re-initialized once when T3 has forgotten the session), with the sign-in below. start finds the T3 project whose workspaceRoot is the repo (t3_project_list), registering it (t3_project_create) when there is none, which only a full-access sign-in may do: with an auto one, add the repo as a project in T3 first. It then calls t3_thread_launch with the title, the brief as the first message, runtimeMode t3_runtime_mode, model {provider: "claudeAgent", model, options: {effort}} and workspace {type: "worktree", baseRef: <base or main>, branch: "bmesh/<thread id>"}, or for a checkout bmesh made {type: "existing_worktree", worktreePath, branch}. T3 names the thread (mcp:<uuid>, the node's thread) and the worktree (<T3 home>/worktrees/<repo>/bmesh-<thread id>). A launch has no retry key, so a reply lost or timed out is reconciled first: the newest threads of the project with that title (t3_thread_list) are read until one is on branch bmesh/<thread id>. status is t3_thread_read: running while activeRunId is set or the newest run is preparing, queued, starting, running or waiting; the reply is the newest assistant_message, the timeline read on from where the last read stopped. A newest run waiting with a pending request logs t3: <path> waits on a question or approval, and one failed on usage_limit logs t3: <path> hit a usage limit; it resets at <time>, each once per change. send is t3_thread_send in queue mode (never steering into a turn that started since the status read) with clientRequestId bmesh-<sha256 of thread, text and minute>, so a retried wake is sent once. stop is t3_thread_interrupt; an idle thread's no_active_run is success.
  • tmux: git worktree add -b <thread> <worktrees>/<repo>/<thread>, then claude [--model M] [--effort E] in window <thread> of session <tmux_session> (created when missing; the window gets BMESH_CONFIG when the CLI has it, so the lead reports to the same mesh). It watches the window until claude's prompt is up, answering the dialogs a fresh worktree raises (the folder-trust question with yes, the first-run screens with Enter, an offer to try something with Escape), then types the brief in (one second before the Enter, since claude takes a burst of text as a paste); after 90 s it types anyway. It can't tell whether claude is mid-turn.
  • claude: git worktree add -b <thread> <worktrees>/<repo>/<thread> <branch or main> (or the checkout bmesh made, for a round's thread), then each turn is a detached headless <claude> -p --output-format json --dangerously-skip-permissions in the worktree, the first with --session-id <thread> [--model M] [--effort E] and the brief on stdin, later ones with --resume <thread> and the same --model and --effort (a resume restores neither); no T3, no tmux. The session id is the thread id, so it must be a UUID (lead start passes one). Turns inherit the CLI's environment (so BMESH_CONFIG reaches the lead). State lives in <user cache dir>/bmesh/claude/<thread>/: worktree, model, effort, pid, turn, and turn-<n>.in, .json, .err per turn. A turn is running while its pid is alive and its .json is not yet claude's result; status replies with the newest result. send mid-turn queues the text under queue/ instead of refusing, and status starts the oldest queued send once the turn ends (the daemon polls status every minute, so queued sends drain). An interrupt kills the running turn's process group.

lead start refuses with exit 3 bmesh: N leads already working on this host when [runner] max_leads is set and that many leads registered on this host are not done or blocked. It counts working leads, not resident sessions: a finished lead's session (a tmux window, a T3 thread) keeps its memory until the operator closes it, and a lead is counted only once its runner has started it. scripts/memcap.sh N measures what a box can carry: it runs N leads on the tmux runner against a mesh of its own, each spawning three executors, and prints the peak memory.

T3 sign-in

The t3 runner signs in to T3 as an MCP client. Only T3's OAuth sign-in gives a session a runtime-mode ceiling above approval-required, and the operator picks it when approving, so a person approves each box's sign-in, and again every 30 days: the token has no refresh. bmesh t3 login [--server URL] [--callback-port 52299] [--timeout 1h] registers bmesh as a public client (<server>/.well-known/oauth-authorization-server: dynamic registration, PKCE S256, grant authorization_code), prints the authorize URL and how to approve it from another machine (the phone or a laptop):

  • ssh -N -L <port>:localhost:<port> <box> for the callback (and, when the server listens on loopback, its port too: ssh -N -L 3773:127.0.0.1:3773 -L 52299:localhost:52299 k16); the fixed default port keeps the command the same every month;
  • a pairing code from t3 auth pairing create on the box when that browser isn't paired with its T3;
  • the access level to choose: Auto, or Full access on a box whose t3_runtime_mode is full-access.

It takes the redirect on localhost:<port> (127.0.0.1 and ::1), or as the URL the browser ended on pasted into it, refusing a state of another login. T3 binds the code to the origin the browser approved it on and names it as iss; the exchange carries that origin, so a browser that reached T3 as localhost through a tunnel still signs in a bmesh that asks 127.0.0.1. The token, its expiry and its ceiling (read from the token's claims) are written to <config dir>/t3-token.json (beside config.toml, mode 0600: server, token, expiresAt, scopes, access), and it prints signed in: <host>: <access>, 30 days left; a ceiling below t3_runtime_mode exits 3 saying so. bmesh t3 login --status prints <host>: <access>, N days left, then the server, scopes and expiry (exit 3 with no sign-in or an expired one).

With the t3 runner, lead start, build and ideate refuse (exit 3, before anything is recorded) without a sign-in or with an expired one, bmesh: T3 sign-in missing or expired; run bmesh t3 login; with one for a server other than t3_server; and with one whose ceiling is below t3_runtime_mode (bmesh: T3 sign-in allows auto, below [runner] t3_runtime_mode full-access; run bmesh t3 login and approve full-access). A lapsed sign-in stops new leads on that box, not running ones, until the next login; the daemon's poll of a running one fails meanwhile. The daemon of the box whose config has the token pushes T3 sign-in on <host> expires in N days: bmesh t3 login (tag key) at 7, 3 and 1 days before it expires, once each: the pushes sent for that expiry are kept in <state dir>/t3-login-pushes.json ($XDG_STATE_HOME/bmesh, else ~/.local/state/bmesh), so a restart doesn't repeat one, a daemon that was down past a mark sends only the nearest one, and a new login starts over. It checks hourly, with [notify] ntfy or command set, whatever push lists. bmesh mesh-status closes with t3 sign-in on <host>: expires in N days (bmesh t3 login) under 7 days, or expired. The runner reads its token through one credential interface, so a source that renews itself could replace the login without touching the runner.

A runner may rename a lead's branch while it works (T3 does). Once a minute the daemon reads the current branch of the worktree of every local lead and engineer that is not done (git rev-parse --abbrev-ref HEAD in the node's worktree, when it exists on this host) and, when it is a branch that differs from the node's branch, rewrites the node and logs branch: <path> <old> -> <new>; a detached or missing worktree leaves the node as it is. bmesh mesh-status, the map and bm-nodes follow within the minute. The lead lands the branch its worktree is on (git branch --show-current), never the name in its brief.

Proposal rounds

bmesh ideate <repo> [--idea] [--model M] [--effort E] [--context <text|->] [--force]

A round is two threads on one ship repo, an engineer and a UI/UX engineer, each writing three to five ranked half-page specs into docs/proposals/ and landing them on main as proposed. The round is a node <repo>-round-<N> (<repo> the registry name's node token, see Names; role round, N one past the highest round registered for the repo in bm-nodes) with children <repo>-round-<N>.eng and .ux (role engineer, parent the round), each with an inbox, a thread and a bmesh worktree; bmesh mesh-status lists the round with its children and the map draws it like a lead with executors.

ideate takes the repo's registry row (a path without a row is refused: the row carries host, ux_host, verify and surfaces) and refuses (exit 3) a row whose mode is not ship, a repo with five or more proposed specs on main (naming them: they wait on review), a repo with a round still open (its round node neither done nor blocked: refused: <repo>-round-<N> is still open (eng working, ux working); wait for it, or pass --force to start another, since a second round from the same main numbers its specs from the same id and runs the repo's tests beside the first; --force starts it anyway), a host it cannot reach, and a main it cannot level with origin (pull --ff-only origin main when the main checkout is on main, else fetch origin main:main; never a reset or a stash). Then, from main, it makes <[runner] worktrees>/<repo>/<repo>-round-<N>-eng and -ux on branches of those names (a branch or a path that already exists is refused, untouched), renders the two briefs (the next free spec ID for the engineer, five past it for the UI/UX engineer, so the two never collide; the thread's UUID as proposed_by), registers the round (its started event opens it as working) and starts both threads through the runner on the row's host (the engineer) and ux_host (the UI/UX engineer; "" = host, "" = this box), with --model (default [models] proposer) at --effort (default [models] proposer_effort) and the title [ideate] <repo> round <N>: engineer|UI/UX. --context carries what the operator asked of the round (- reads it from stdin): both briefs get a section ## The operator's focus with the words as given, trimmed, before their closing, and a repo's own template without {{operator_context}} gets the section appended; no words, no section. It prints {"path","round","eng":{"path","thread","branch","worktree","host"},"ux":{...}}. --idea makes the round the operator's one idea, given in --context (refused without it, exit 2, in the full form and the child form): only the engineer starts (the ux_host is neither checked nor asked), its brief gets ## The operator's idea in place of the focus section (write that idea's spec, one half-pager or two or three in build order, and propose nothing else) and its opening, partner and numbering lines say so ({{round_ask}}, {{round_partner}}, {{ux_ids}}, which a repo's own engineer template may use), the round node carries "idea": true, the started text reads round <N> on <repo>: one idea, eng on <host>, and the output has no ux; the round settles on the engineer alone, its pitch the engineer's line. Briefs are kept under <user cache dir>/bmesh/briefs/<round>-<child>.md.

A host other than this box is reached as ssh -o BatchMode=yes <host> bash -lc 'bmesh ideate <repo> --child eng|ux --round N --next-id N --thread-id UUID --model M [--effort E] [--context <text>] [--idea]': the child form, which makes that one worktree, brief and node on the box it runs on (the registry is the same on every box) and prints its child's line. Both hosts are checked first (bmesh help over ssh), so an unreachable one is refused before anything starts. The child form refuses a child whose node is live or whose branch exists. Both children are started whatever happens to the other; one that does not start is marked blocked (did not start: <error>), the round reads blocked naming it (the started thread keeps running, and its pitch reaches the operator when the daemon resettles the round) and the command exits 1.

With the t3 runner a round's thread runs in the bmesh worktree, filed under the target repo's T3 project: T3 binds only a project's own worktrees.

Each thread's node reports as any node does (started, progress, ask, blocked, done); the daemon polls and wakes an engineer like a lead, and an engineer's done needs only --commit (the commit on main) and carries its ranked list as --text. Once a minute the daemon settles every open round: both children done moves the round to done with the merged pitch as its text (the engineer's list, then the UI/UX engineer's, each as reported) and publishes done from the round; one child blocked moves it to blocked (<child> blocked: <text>, then the other's pitch when it is done). A blocked round is looked at again each minute: when the other child's pitch arrives the round says blocked again with it, and when a blocked child is unblocked and both finish done, the round is done after all. A settlement's event carries a Nats-Msg-Id made of the round, the state and its text, so two daemons settling the same round store and push it once. The round's done is pushed as <repo> round N proposed (priority default, tag memo, the pitch as body) and its blocked as <repo> round N blocked (high, no_entry), each opening the engineer's thread on a tap when [console] resolves it; a round's push, like a lead's, needs the event in [notify] push. An engineer's ask pushes as a lead's does (<path> asks, with its own thread's link); its done and blocked reach the operator through the round.

Builds

bmesh build <repo> <NNN> [--model M] [--effort E] [--path P] [--family F] [--hold land|now] [--force]
bmesh build --accepted <repo> [--model M] [--effort E] [--family F] [--hold land|now] [--force]
bmesh scaffold <repo> [--check]

build dispatches one accepted spec. It takes the repo's registry row (a path without a row is refused, exit 2; a non-ship row exit 3), levels main as ideate does and reads the ledger there: the spec must read accepted (another status is refused, exit 3, naming it), and its judge names the model (fable, opus, sonnet as claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5; no judge is refused unless --model says), at --effort (default [models] lead_effort). The node is <repo>-<NNN>, or --path; a path whose node is not done or blocked is refused (exit 3, use --path <repo>-<NNN>-2), as is a host at [runner] max_leads, before anything is recorded. Then, in this order: building lands on main (built_by the thread id chosen for the lead, host, family, and branch when the runner names branches ahead, as claude and tmux do), committed as Dispatch NNN: building on <host> in a bmesh worktree, fast-forwarded and pushed as choose-ideas lands; the build brief is rendered (the spec file whole, the row's done rule and verify commands, the node path, the standard closing) to <user cache dir>/bmesh/briefs/<path>.md; the lead starts through the runner with that thread id, titled [build] <repo> <NNN>: <title>, exactly as lead start would. A runner that names the branch itself (t3) gets it recorded in a second commit, Dispatch NNN: branch <b>; a start that fails moves the spec back to accepted (Dispatch NNN: did not start) and exits 1. It prints {"path","thread","branch","worktree","model","spec","building","family"} (and hold, with --hold; see Holds), building the commit that recorded it. A model the runner rejects is the operator's to retry with --model; the binary has no fallback.

Every lead one build starts is in one build family, the set of leads a coordinator dispatched together, whose feedback it reads at the end (see Feedback). --family F joins family F (one subject token: letters, digits, - and _; anything else exit 2); without it each invocation mints <repo>-build-<N> (the repo's node token, see Names), N one past the highest family of that shape registered for the repo in bm-nodes or still holding feedback, as rounds are numbered. The lead's node records it as family, and so does its spec, landed with building. A coordinator dispatching across several commands or repos passes the first family to the rest; over ssh it goes on the remote command line.

Before anything is recorded, once the spec reads accepted, build (both forms) holds while the repo's last build run is still going. It reads the repo's leads on every host: a lead outside the build's family (--family, or the one just minted) that is not done or blocked holds it, a lead with no family as itself; so does a family of the repo's (one a lead of the repo is in, or named <repo>-build-<N>) with feedback pending. The build's own family never holds it. A single build is refused (exit 3) with held: <family> is still running (<path> <state>, ...); watch it with bmesh watch --nodes <paths> or held: <family> has <N> feedback pending; drain it with bmesh feedback list/drain <family>; --accepted prints held <family>: <why> (--json: [{"state":"held","family","reason"}]) once, exits 0 and starts nothing. Then it verifies main: the commit checked is main's code head, the newest commit on main that changes anything outside the spec directory, so the Dispatch, Review and done ledger commits never ask for a verify of their own. A verify result recorded for its tree answers (verify main <sha8>: ok t:<tree8> (answered from the record)); with none, the repo's verify kind, as that commit defines it, runs inline in a bmesh worktree at that commit (<[runner] worktrees>/<repo>/<repo>-verify-<hex8>, removed after) as land runs it, and records (verify main <sha8>: running at t:<tree8>, then ... (recorded)). A failing verify refuses the build (exit 3, nothing dispatched): main is red at <sha8> <subject>: verify fail t:<tree8> (<how>); build its fix with --force, then the last 30 lines of its output. A repo whose main defines no verify kind prints verify: <repo> defines none and goes on. --accepted holds and verifies once for the batch, and only when any spec is accepted. These lines go to stderr, and to stdout among --accepted's lines. --force gets past both, as ideate --force gets past an open round: to dispatch beside a running family, or the spec that fixes a red main. It says what it passed, --force: past held: ..., --force: past main is red at ..., or --force: past verify main <sha8>: no result for t:<tree8>, and runs no verify.

--accepted dispatches every accepted spec, lowest id first, through the same steps, and prints one line per spec: started NNN <title>: <path> on <model> (thread, branch, building, family F), waits NNN <title>: on 004 (building) for a spec whose body names a lower id (004, or a run 001 to 004) whose spec is not yet done, rejected or later (an id above its own is a pointer, not a dependency; a rejected or later spec never reaches done, so it is not waited on), waits NNN <title>: 3 leads working on <repo> once three leads of the repo (on any host) are not done or blocked, counting the ones just started, or not started NNN <title>: <the refusal>; one spec's refusal does not stop the next. When any started, a last line names their family: family <repo>-build-<N>. --json prints the lines as objects, each with family.

From a chat (a phone, a session with the plugin and no command typed), the operator runs this lifecycle through the plugin's operator skills, the chat entry points: buildermesh:ideate ("proposals for X", "run a round on X") runs ideate on the row's host (locally, else over ssh), with whatever the operator asked beyond the repo's name as --context, then watches the round with bmesh watch --nodes <repo>-round-<N>, relays an engineer's ask, and tells the operator in the chat when the round is proposed (its ranked pitch) or blocked, and, when the operator names one specific idea of their own instead (decided by what was asked, not the words), starts no two-thread round: it runs ideate --idea (a round of one engineer thread, on the row's host) when the operator asks for a thread, a model or an effort, or the idea needs the repo read and measured at length, and otherwise writes that idea's spec in the session (one half-pager, or two or three in build order for an idea too big for one lead, proposed_by: operator) and lands it on main as proposed; buildermesh:choose-ideas (the reply to a pitch in plain words) runs choose-ideas; buildermesh:build ("build X 3", "build what's accepted in X") runs build on the row's host (a held refusal it waits out with bmesh watch on the named nodes, draining its own family first, and runs again; one held by another coordinator's family, or a red main, it reports to the operator), then watches the leads with bmesh watch --nodes <paths> and relays each ask to the operator, answering it with bmesh --node operator send, holds and releases leads with hold and unhold instead of a free-text hold ("hold X", "hold land on X", "release X"; see Holds) and relays a lead reaching its gate as one line, and once a family's watch is done turns its feedback into accepted and proposed specs and drains it (see Feedback); buildermesh:scaffold ("scaffold X") runs scaffold --check, shows the diff, runs scaffold, the row's verify, commits Scaffold the shared definition of done and proposals and pushes; buildermesh:mesh-status ("what's building", "where is X") summarises mesh-status (and spec list for a named repo) as working, asking, silent and done since the last look. propose and build carry the Fable fallback the binary has not: a Fable model the runner rejects is run once more with --model claude-opus-5-5, telling the operator what the runner said. Each skill ends with one line: what is started or landed, and what waits on the operator, naming each thing by what it is in plain words (a spec or issue by what it does, its number after it in brackets if at all), so the line reads on its own; the done rule scaffold writes and every build brief's closing end the same way (brief.PlainWords).

scaffold gives a ship repo what a build expects of it, from its row and the binary, and changes nothing else, in the checkout the row names, given by name or path, or in a linked worktree of that checkout given by its path (the row is the one whose checkout shares the worktree's git common dir), which is written in place, so a lead scaffolds its own branch without touching the main checkout: the three sections of AGENTS.md between <!-- bmesh:done -->, <!-- bmesh:proposals --> and <!-- bmesh:mesh --> markers, in that order (the definition of done from the row's verify commands and remote; the proposals convention; the Buildermesh paragraph of docs/agents-snippet.md, embedded, its verify placeholder filled from the row), only the text between the markers rewritten and a missing block appended at the end (a missing AGENTS.md is created with the blocks); a block an earlier scaffold wrote between command-center: markers is read the same way and rewritten in place under bmesh: ones, so a repo never gets a second block; docs/proposals/README.md (this repo's, embedded) and docs/proposals/assets/.gitkeep when missing; and a kind file for each of the row's routines the repo lacks, seeded from examples/routines/ (verify inline and deterministic with its command the row's verify commands joined with &&; a row with no verify commands gets no verify kind and a line on stderr says so; a routine with no example is an error naming the file to write by hand). An existing kind is never touched, but for the verify kind's command when the row has verify commands: an existing verify kind whose command (as the kind loader reads it) is not those commands joined with && gets its one command = ... line rewritten to them, the rest of the file kept, whether or not the row's routines name it; a verify kind that runs a model is left alone, and one whose command spans lines is an error naming the file to set by hand. So the row's verify is the source of the verify kind and of both blocks that quote it, and a change to the verify commands is a change to the row (see The registry), which scaffold then writes. It prints wrote <file> per file, or <repo> is scaffolded. --check prints the unified diff of what would change and exits 1 when there is any, writing nothing. A row that is not ship, or a path without a row, exits 2. A second run changes nothing, so run it again after the row or the snippet changes; commit what it wrote.

Holds
bmesh hold <path>|--family F [--gate land|now] --text "<why>"
bmesh unhold <path>|--family F [--text "<words>"]
bmesh held

A coordinator (the build skill's session, or the operator) holds a lead at a gate until it says so: landings in order, a diff to read before it lands, a pause while quota is low, or a redirect before the lead builds the wrong thing. hold writes KV bm-holds <path> to {"gate","text","by","at"} (by the sender: --node, else this node, else the host name) and sends the lead a message of kind hold, hold from <by> (<gate>): <why>, which wakes an idle lead as any message does; it prints holding <path> before land: <why> (now: <why> at the gate now) per lead, --json [{"path","gate","text","by","at"}]. --gate land is the default, --text is required and a gate other than land or now is a usage error (exit 2); a hold replaces one already there. <path> must be a registered lead that is not done or blocked: an executor is refused (exit 3, refused: <path> is an executor; executors are never held: hold its lead <lead>), and so are a node of another role, a done or blocked lead and a path with no node. --family F holds every lead of build family F that is not done or blocked, refused (exit 3) when there is none. Rounds and routines are never held, a hold never releases itself (a release is an act, never a timer), and there are no gates but these two. Any sender may hold, as any sender may send.

  • land: bmesh land reads bm-holds before it takes the lease, and again once it holds it, and stops (exit 3, nothing pushed) with bmesh: held before land by <by> <age> ago: <why>; nothing is pushed: run bmesh held and end your turn, and run bmesh land again once the release wakes you. A now hold stops it the same way (held now by ...).
  • now: every bmesh inbox the lead runs while the hold stands prints it first, as its own line, held now by <by> <age> ago: <why>; stop here: run bmesh held, end your turn, and wait for the release (under --json, first, a message of kind held from <by>). Ages run to the minute, whole days as 1d3h.

held is the lead at its gate: it reads its own hold, publishes held with the text before land by <by>: <why> (now by ...), moves the node to the state held and prints held <path> <text>; end your turn: the release wakes you. With no hold it is refused (exit 3, refused: <path> is not held (released already?); run bmesh inbox and carry on), so a release that came first sends the lead on. held is reached from working, asking or silent and left by the node's own next event: the release wakes the lead, and its next started or progress makes it working again. The daemon never calls a held node silent (only working goes silent) and never pushes it (held can't be in [notify] push), and the map's needs-you band has no row for it: the coordinator put it there. mesh-status shows the state and, after the table, one line per hold with its age, <path> held before land by operator 3m ago: <why> (--json: the row's hold); watch prints the held event, <at> <path> held <text>, so the coordinator sees the lead reach its gate, and goes on watching. The hourly digest names a hold once per whole day of its age, <path>: held before land by operator 1d0h ago: <why>, in the pass whose window holds that mark, so a forgotten hold is named once a day.

unhold deletes the key and sends the lead a message of kind release, released by <by> (: <words> with --text), which wakes it, printing released <path>; a lead not held prints <path>: not held, sends nothing and exits 0. --family F releases every held lead of the family, <F>: nothing held when there is none. (bmesh release cuts a release tag, see Releases.) node forget deletes a node's hold with it. build --hold land|now (both forms) writes each lead's hold, by the sender and the text held at dispatch (build --hold <gate>), before the runner starts it, and sends the hold message once it has started (a message that fails is said on stderr, build: <path> is held before land, but the hold message failed: <err>, and the start stands): "build these, land none until I say". Its JSON carries hold, and --accepted's started line ends , held before land (, held now). A start that fails deletes the hold, and a build without --hold deletes a hold an earlier lead left on its path.

Briefs

The engineer, UI/UX engineer, build and half-pager templates are embedded in the binary (internal/brief/templates/); a repo overrides any of them with .bmesh/briefs/<name>.md (engineer, ux-engineer, build, half-pager). A template is filled in one pass: {{name}} placeholders take values and braces inside a value stay as written; a placeholder with no value is an error naming the template and the placeholder, never left in a brief. Every brief gets repo, repo_path, verify (the row's commands in backticks, or that none are listed), surfaces (the row's, or what to do without them), standard_closing (the closing paragraph with the verify commands inlined) and done_rule (the definition of done the row implies: verify passes, Opus in a session that did not write it reviewed it when it is sized M or larger as built (its spec's size, or the size its scope grew to; work with no spec sized as a spec would be), at medium effort for M and high for L or larger, while an XS or S change is done on verify alone, rebased, fast-forwarded to main, pushed to the row's remote); a round's brief also node, round, round_n, worktree, branch, thread_id, next_id, date and operator_context (the operator's focus section, or empty); a build brief spec, spec_path, spec_id, node, date and upgrading (empty, except in bmesh's own repo, where it is a bullet asking the lead to fill the spec's ## Upgrading section as built, with the template's rule). The proposal briefs carry the repo's facts and point at the ideate-engineer and ideate-ux skills for the method; the build brief carries the spec and points at the lead skill, and says that the review is sized by the spec's size (sized as built, so a grown scope counts at the size it grew to: an XS or S lands on verify alone; an M is reviewed on Opus at medium effort, an L or larger at high, queued with --effort), that its findings are fixed and then landed, the second pass only after a rebase changed the code, and a taste finding is noted in the final message, not re-reviewed. Briefs are kept under <user cache dir>/bmesh/briefs/<node>.md.

Feedback

bmesh feedback "<text>" [--repo R] [--where <file|command>] [--fixed SHA]
bmesh feedback list <family> [--json]
bmesh feedback drain <family> [--through SEQ]

A lead that hit friction outside its spec, which another build would hit too, leaves one feedback line per issue once it has decided it is done, just before bmesh done; its own spec's work is not feedback. feedback reads the family, repo and spec from the node (a node with no family, one not started by build, is refused: exit 3, no build family; an unregistered one exit 1) and publishes {"from","family","repo","spec","commit","where","fixed","text","at"} on bm.feedback.<family> in bm-mesh, commit the node's worktree HEAD when it is on this host. --repo (a registry name or path; default the node's repo) names where the friction is, buildermesh when it is in the mesh itself (bmesh, a skill, a brief); --where the file or command it was met in; --fixed a fix the lead already landed, so what is left is the general case. It prints feedback <seq> in <family> (--json: the record with seq). Executors never write feedback: they report to their lead, which decides what affects other builds.

There is no consumer: a message is pending for exactly as long as it is in the stream, which is until it is drained or the stream's 30 days have passed. list prints the family's pending feedback, oldest first, one line each: <seq> <from> (<repo> <spec>[, where W][, fixed F]): <text> (--json: an array of the records, each with seq); nothing pending prints nothing. drain deletes the family's messages from bm-mesh with a subject-filtered purge, up to and including --through (else up to the last one there when it starts), and prints drained N from <family>: nothing about them is kept on the mesh, only that family's subject is touched, and a message published after --through stays. mesh-status closes with feedback: N pending in <family> per family with any pending, so feedback whose coordinator has gone is seen and drained within those 30 days.

The coordinator, the build skill, reads the queue once watch prints watch-done for a family: it lists, merges duplicates, drops what a spec on main already covers (any status, so rejected ideas don't come back) or a --fixed commit settled, and sorts the rest. A quick fix (one cause, one obvious change, XS or S) becomes a spec it lands as accepted (Feedback NNN: accepted from <family>, proposed_by its thread, from_feedback the family and leads) and builds once the family is drained (pending feedback would hold the build), in a new family, with no operator review; anything that needs thought it writes up itself as a proposed spec (Feedback NNN: proposed from <family>, the same proposed_by and from_feedback) that waits on the operator's review, never as a round: the issues are already the ideas; an issue from a lead built from a from_feedback spec is always proposed, never built at once. Then it drains through the last sequence it listed, so the specs' from_feedback is the only record left, and reports one line per issue: fixed by NNN (started), proposed NNN, already NNN or dropped: <why>. A coordinator that dies between list and drain leaves the family pending, and mesh-status shows it.

Releases

bmesh release <repo> [--minor|--patch] [--dry-run]

A release names the point where a batch's work is all in and verified: release runs from the coordinator once a build family is done and its feedback drained, and tags the repo's code head (the newest commit on main that changes anything outside the spec directory, the commit build verifies) as vX.Y.Z, an annotated tag, and pushes the tag to origin. It takes the repo's registry row (a path without one is refused, exit 2), fetches origin with its tags first and reads origin/main (main in a repo with no origin, where the tag stays local). The version:

  • a repo with no vX.Y.Z tag in main's history gets v0.1.0;
  • after that, the highest such tag's minor version goes up (patch 0) when any spec landed since it is a feature, else its patch version; --minor or --patch says which instead (two at once is exit 2).

The specs landed since are those of the NNN: done at <sha8> subjects after the last tag (all of them for the first), less any whose merged commit is in the last release's history: a release tags the code head, so the done commit of a spec it carries comes after its tag. The tag's message is <repo> vX.Y.Z, a blank line and one NNN <title> per spec (no specs landed since <tag> when none did), in bmesh's own repo, when the release's upgrade notes exist, a blank line and the notes as text (Upgrading to vX.Y.Z (docs/upgrading/vX.Y.Z.toml):, the summary, what a start-over loses, one numbered line per step; see Upgrade notes); and release prints released <repo> vX.Y.Z at <sha8> (NNN, NNN) ((no specs); ; no origin, so it is local only without one; --json: {"repo","tag","commit","specs","pushed"}). A push that fails deletes the local tag again, so the next run starts over.

It refuses (exit 3), tagging nothing:

  • nothing to release since vX.Y.Z: only specs changed when no commit since the last tag changes anything outside the spec directory; when that tag is on the code head itself, as a tag release cut is (the done commits after it change only specs), it is reported, not cut again: already released <repo> vX.Y.Z at <sha8> (exit 0);
  • held: <family> is still running (...) or held: <family> has N feedback pending; ... while a lead of the repo is not done or blocked, or a family of the repo's has feedback pending: the hold build reads (see Builds), with no family of its own, so the coordinator's own family holds it too until its feedback is drained. A batch is released once, when it is done, never per lead;
  • main is red at <sha8> <subject>: verify fail t:<tree8> (...); release once main is green and the tail of the output, when verify at the code head isn't ok: a result recorded for its tree answers, else the repo's verify kind runs there and records, as for build (a repo with no verify kind says so and goes on);
  • in bmesh's own repo only (the repo whose go.mod declares the module this binary was built from, the test land's scaffold check makes; every other repo releases as described above, with no key to turn it on): refused: 055, 057 have Upgrading steps and docs/upgrading/vX.Y.Z.toml is missing: distil them into it (the build skill's step 7), commit it to main and release again when a spec landed since the last tag has steps in its ## Upgrading section (any text, unless the section is empty or its first word is nothing, in any case) and the new tag's notes are not on the ref read; refused: docs/upgrading/vX.Y.Z.toml: <what doesn't parse> when the notes exist but don't parse; refused: docs/upgrading/vX.Y.Z.toml names keys this binary doesn't know: [runner] foo when an ask or a when names a key this binary's config or registry doesn't have. Notes are checked whenever they exist, steps or not.

--dry-run runs the same checks and prints would release <repo> vX.Y.Z at <sha8> (...); --dry-run tags nothing, a blank line and the tag's message, and tags and pushes nothing. A pushed tag is public: a bad release is followed by the next one, never moved.

A repo starts releasing with its first tag, which the operator or a lead cuts by hand (or with release); from then on the build skill ends each batch with bmesh release <repo> for every repo where the family landed code and that has a v* tag. A box on [update] track = "release" (the default) updates to the newest release (see Updates), so a finished batch reaches each box once; track = "latest" and bmesh update --latest follow the code head instead.

Upgrade notes

In bmesh's own repo, each release's notes are docs/upgrading/vX.Y.Z.toml: the steps an install at the release before needs, which the coordinator distils from the ## Upgrading sections of the specs the release carries (the build skill, step 7) and commits to main before release. The file is TOML, and any key not listed here is an error:

  • summary: one line, what the release asks of an install;
  • start_over: true when the steps uninstall the box and set it up again;
  • loses: what a start-over loses;
  • [[step]], in order, each with a kind and an optional when:
    • kind = "ask": a key to set. file is config (the default; key is [section] key in config.toml) or registry (key is a row key in repos.toml, every row); changed is one line saying what changed; question (required); choices, a list; default (required, and one of choices when they are given). An ask takes no command, os, confirm or expect;
    • kind = "run": command (required), why (required), os (a list of linux, darwin, windows; none means every OS), confirm = true for anything destructive, which asks first;
    • kind = "check": command (required), expect (what its output must show), why, os;
    • run and check steps take no key, question, choices, default or changed.
  • when: comparisons that must all hold for the step to apply, [section] key == "value" or !=, joined by && (the value quoted, or a bare word), read from this box's config.toml just before the step (a key the file doesn't set reads as its default, so a step after init sees the new config).
summary = "Boxes follow releases; restart the hub."

[[step]]
kind = "ask"
key = "[update] track"
changed = "boxes now update to releases, not every landing."
question = "Follow releases or every landing?"
choices = ["release", "latest"]
default = "release"

[[step]]
kind = "run"
command = "systemctl --user restart bmesh-hub"
why = "the hub runs the old binary"
os = ["linux"]
when = '[hub] role != ""'

The ledger

A repo's specs live in docs/proposals/NNN-slug.md ([spec] glob), each a flat key: value frontmatter between --- fences (lines starting with # are ignored) over a half-page body. The frontmatter is the whole record, so a file that cannot be read is an error for every command, never skipped or counted around: a missing or non-numeric id, an id that does not match the file name's, a status outside proposed, accepted, adjusting, later, rejected, building, blocked, done, a judge outside fable, opus, sonnet, missing fences or a line that is not key: value; two files sharing an id. README.md is not a spec.

bmesh spec list [--repo R] [--status S] [--json]
bmesh spec next-id [--repo R]
bmesh spec set --id NNN --status S [--repo R] [--from A,B] [--field K=V]...
bmesh spec template [--repo R]
bmesh choose-ideas <repo> "<reply>"

A spec's branch is set by dispatch and corrected by the build thread when the runner renamed it: the spec set that writes done and merged adds --field branch=<the branch landed> when it differs, so the ledger names the branch that was merged. Its family is the build family it was dispatched in, set by dispatch with building. A spec a coordinator wrote from its family's feedback carries from_feedback: <family> (<the leads whose feedback it came from, comma-separated>) (see Feedback); a lead built from one may leave feedback, but its family's coordinator writes that up as proposed, never straight to a build.

--repo is a registry name or a path (default the current directory; a path that is a row's path counts as that row, as it does for every command taking a repo). list prints one row per spec, NNN status size kind lens title (? for a missing field), sorted by id; --json prints each spec's frontmatter plus path as an array. next-id is one past the highest id (001 for an empty ledger). set rewrites the spec's status and the given fields in place (an existing key keeps its line, a new one is added last, every other byte stays) and prints its row; --from refuses, changing nothing, a spec whose status is not one of the listed ones (exit 1, naming its status), as does a write the ledger would read back otherwise (a duplicated status: key) and a value with a line break. template prints the half-pager (the repo's .bmesh/briefs/half-pager.md when it has one, else the embedded one); in bmesh's own repo (its go.mod declares this binary's module) it adds an ## Upgrading section before ## Needs from the operator, unless the text has one: the steps an install from the previous release needs, written for any fleet, or nothing, a new config key written as an ask (what changed, the question, the choices and the default). release reads it (see Releases).

review is the operator's reply to a round, typed in plain words: "1 and 3 yes, 2 smaller, 4 no, 5 later, 3 on opus". Clauses are split at commas, semicolons and line breaks, and, once a clause's phrase has begun, at a number that a spoken verdict follows (3 yes and 4 no and 11 smaller and 12 no are two clauses each; 4 no because 5 covers it is one). A clause is its leading numbers (joined by and or the like), which name specs, and the phrase after them. A number of three or more digits (012, as ids are written) names that spec; one of one or two digits is a position in the pitch when there is one (1 and 3 yes answering a pitch of 012 to 016 means its first and third items; 45 yes to a five-item pitch is reported as no position, so the spec is 045; 6 to a pitch of 001 to 003 and 006 to 010, both a pitched id and the sixth item, is reported as ambiguous), and a spec id when there is none. The pitch is the one the operator saw last, the text of the repo's newest settled round (done or blocked) on the mesh, read whole, line by line (a line is the id it starts with; one holding two or more items of the shape NNN title (kind, size, judge): after sentence ends, the title free of sentence ends (a .;!? before a space; v1.2 is fine), is a list typed on one line and gives them all, so each child's pitch reads the same whether typed one per line or in one go, while an id cited inside a why is not an item), so a position means the same spec in every reply to it, whatever has moved since (a line whose spec has moved on is reported as left unchanged); an open round, even with a finished child, is not a pitch yet, and a settled round whose text names no spec (one blocked before a pitch) is passed over for an older one. With no mesh or no settled round with a pitch only ids count, and the output's first line says so. The phrase's first word decides: yes/accept/ok to accepted; no/reject to rejected, the rest of the phrase as reason; later to later; on fable|opus|sonnet alone sets judge and moves nothing; any other first word makes the whole phrase an instruction, so the spec becomes adjusting with it recorded as Adjust (<date>): <phrase> under its Proposal heading, for the thread that reworks the body. Reasons and instructions are kept as written, so a verdict word or a number inside one is just a word (2 make it work with no network adjusts 002). on <judge> right after yes or later sets the judge as well, and words after that (yes but smaller) make it an adjustment. A verdict may come first instead, yes, accept, build, go, approve, no, reject or later (optionally followed by to), or work on, then the numbers it is about and, for reject, the reason (accept 012, reject 2 too big, work on 1, 2 and 3, yes to all); it carries to the clauses of bare numbers after it, and a phrase after its numbers that is itself a verdict said first is a clause of its own (accept 012 and reject 013); after two or more numbers, a phrase that is not the verdict's own (after reject, one that starts with a spoken verdict; after yes or later, anything but on <judge>) is the last number's, and the verdict said first covers the others (reject 2 and 3 yes, accept 012 and 3 smaller; reject 2 and 3 too big rejects both, yes 1 and 3 on opus accepts both on opus). all among the numbers names every spec of the pitch (all yes, all later on opus; with no pitch it is reported). A clause of bare numbers otherwise takes the verdict of the clause right after it (1, 2 and 3 yes); a clause with no number in between leaves them unmatched. A spec named in several clauses gets the last move and every judge. Only proposed, later and adjusting specs move; another status is reported and left alone. A clause with no number, no phrase, or a number that matches nothing is printed as unmatched: ..., never guessed at. The command levels main (as ideate does), makes a worktree <[runner] worktrees>/<repo>/<repo>-review-<hex8> from it, applies the verdicts there, commits Review: 012 accepted, 013 later, fast-forwards main (merge --ff-only in the main checkout when it is on main, else fetch . <branch>:main), pushes to origin, removes the worktree and its branch, and prints one line per spec (NNN title: old -> new [on judge] [(note)]), the unmatched items and landed <sha8> on main: Review: ... (or nothing to land). A main that will not fast-forward is refused (exit 3) with the review kept on its branch.

Executors

A lead fans out by making executors: subagents in worktrees of their own, on branches under the lead's, each a node under the lead's path.

bmesh executor new --name N [--base REF] [--model M] [--json]
bmesh executor list [--json]
bmesh executor remove <name> [--force]

new runs as the lead (or as an executor fanning out again; the caller is the parent): it makes <[runner] worktrees>/<repo>/<parent path>.<name> with git worktree add -b <parent's branch>-<name> from the parent's HEAD (--base picks another commit; the branch is the worktree's current one, so a renamed branch is followed), registers node <parent path>.<name> (role: executor, parent, branch, worktree, repo, host, model) with an inbox and state idle, and prints {"path","branch","worktree"} followed, unless --json, by the executor header: the paragraph to paste at the top of the subagent's prompt, which names the path, worktree and branch, says to start every shell command with cd <worktree> && and to carry --node <path> on every bmesh command (the harness keeps neither working directory nor environment between tool calls), to run bmesh started first, to report the finished piece with bmesh done --commit <sha> --text "<one line>" alone (the mesh puts it in the lead's inbox with the branch) and to invoke the buildermesh:executor skill. A name matches ^[a-z0-9][a-z0-9-]*$; a second new with a name whose node is not done or blocked, or whose branch still exists, is refused (exit 3). When git worktree add fails (a hook, an unwritable root), nothing is registered and the worktree and branch are removed.

list prints the lead's tree (every node under the lead): path, state, branch, head (when the worktree is on this host) and unread count. remove refuses (exit 3) while the branch is not merged into the caller's HEAD unless --force; it then removes the worktree (--force also drops uncommitted changes) and the branch (-d, or -D with --force), forgets the worktree in the node, drains its inbox and marks it done. The node and its branch name stay in bm-nodes until bmesh node forget (see Finished nodes); the name can be used again.

An executor's ask, blocked and done reach its parent's inbox as well as being published as its own events, since only leads reach the operator, and the lead acts on its executors from its inbox; started and progress are events only. ask is a message ask from <path>: <text> (id ask-<path>-<hash of path and text>); blocked --text T is a message of kind report, blocked from <path>: <T> (id blocked-<path>-<hash of path and text>); done --commit SHA [--text T] is a report, done from <path>: branch <b> at <sha8>: <T> (id done-<path>-<sha>); with no text, each ends before its : <T>. The branch is the executor worktree's current one (rev-parse --abbrev-ref HEAD, so a renamed branch is followed), the recorded one when the worktree is detached, gone or on another host; the sha is --commit resolved to a full commit in the worktree, else as given. The id is the event itself, so a retry within the stream's two-minute duplicate window is one message. A report carries {"event","from","branch","commit"} (branch and commit for done) in its report field, which inbox --json prints. The message is sent after the move is checked against the node's state and before the event, so a refused event sends nothing. The digest lists an executor's ask, silence, block or completion nested under its lead's line and counts only leads under "asking". An executor's done needs --commit and never reads the spec.

The skills

The repo is a Claude Code plugin, buildermesh, with eleven skills. Seven are the operator's chat entry points: to the lifecycle, ideate, choose-ideas, build, scaffold and mesh-status (see Builds); and across boxes, sync and prune (below). Four are followed by the nodes the lifecycle starts: lead (own one spec, decide the shape, executors, checkpoints, merge, verify with one review pass, land, feedback, ask), executor (your worktree and branch only, checkpoints, routines, report, ask goes to the lead, never merge or push, except pushing its own branch to queue a kind of another OS), ideate-engineer and ideate-ux (a round's method: what to read, the lenses and measurements, screenshots of the row's surfaces for the UI/UX engineer, three to five ranked half-pagers, landing on main, done with the ranked list, and the operator's reply through bmesh choose-ideas). buildermesh:sync ("sync my repos", "sync X") shows first what bmesh sync would do, which has no dry run of its own: this box's checkout against a fresh fetch of origin (up to date, behind, ahead, or what will refuse it) and, from bmesh hosts --json, the boxes that would get a level, their state and which are secure; then it runs bmesh sync --all or --repo R and reports per box what moved, what is held for approval, what was refused and what is still to answer. buildermesh:prune ("clean up worktrees") runs bmesh prune dry first, shows the keep and remove rows per box, runs it with --apply and reports per box; on a bmesh without prune it says prune is not there yet. Both stop, saying so, on a box with [hosts] off. The node skills each name the optional pstack and method skills at fixed moments, each step conditioned on that skill being in the session's skill list, with a written default for a session without it (see Quickstart): the lead opens in poteto-mode, plans an L spec with method:writing-plans, decides the shape with architect and attack-the-premise, checks a browser surface with control-ui, runs figure-it-out before an ask and blast-radius before landing; the executor builds with tdd and runs figure-it-out before an ask; the round's engineers run exhaust-the-design-space and attack-the-premise before ranking. Install it from the repo:

claude plugin marketplace add rickdiculousli/buildermesh && claude plugin install buildermesh@buildermesh

or from a checkout: claude plugin marketplace add <path to the checkout> && claude plugin install buildermesh@buildermesh. The plugin also carries one hook, hooks/hooks.json: at every session start it runs bmesh hosts --here and then bmesh update --notice (when bmesh is on PATH; the second with its stderr dropped, so a binary older than the plugin says nothing), so on a box with [hosts] on a session opens with the line saying which box it is on (see Several boxes), and on a box whose binary is behind main with the notice (see Updates); it prints nothing else. A lead's brief says "follow the buildermesh:lead skill" and adds the operator's own conventions; the executor header names the executor skill; a round's briefs name the ideate skills. The skills name no repo convention; docs/agents-snippet.md is the paragraph a repo's AGENTS.md gets, embedded in the binary for bmesh scaffold and kept in the repo for reading.

Finished nodes

bmesh mesh-status [--all] [--json]
bmesh node forget <path> [--worktree [--wait 30s]]

mesh-status prints the nodes sorted by path: path, role, state, since, runner and unread count. A node done for longer than [status] keep_done (default 24h; 0 shows them all) is left out, and a closing line counts what was: … N finished nodes older than 24h (--all shows them). Then one line per hold, <path> held before land by <by> <age> ago: <why> (see Holds), then one line per build family with feedback not yet drained, by family: feedback: N pending in <family> (see Feedback), and, with the t3 runner and this box's T3 sign-in under 7 days from expiry, t3 sign-in on <host>: expires in N days (bmesh t3 login) (see T3 sign-in), the behind-main notice (see Updates), and last the hub line when this box's bmesh hub runs an older binary than its bmesh serve (see Several boxes). A blocked node is never left out: it waits on the operator. A round and its children are one unit, left out only when every one of them is. --all lists every node; --json carries every node with no flag, as the map's /api/nodes does, as an array of {"node","state","runner","unread"}, a held node's with hold.

A routine instance's node leaves the mesh by itself. A fresh run's node, state and runner sample are deleted once its run ends and its checkout is removed; its started, progress and done events stay in the stream for the map, and its reply's by still names it. A pooled instance that stops (its pool restarted or shrunk, its kind gone from its repo's main, its repo gone from the registry) is marked done; the daemon's kind rescan, once a minute, deletes the node, state and runner sample of every routine instance on its host that has been done for [status] keep_done (0: never) and is not running, a fresh run's left behind included.

Every other node stays until it is forgotten. node forget deletes a done or blocked node: its bm-nodes entry, state, runner sample and inbox consumer, printing forgot <path>; its events stay in the stream, and the nodes under it are not forgotten with it. It also deletes an idle node that never reported and whose thread isn't running, as a lead whose session died before its first event leaves behind: one is refused (exit 3) that published an event of its own (one in bm-mesh whose from is its path: refused: <path> is idle but published <event>; only an idle node that never reported is forgotten), whose host is not this box or whose runner.kind is not this box's runner (refused: <path> is idle and names no host, refused: <path> is idle and its thread is on <host>; run it on <host>, refused: <path> is idle and its runner is "<kind>", not this box's <runner>; run it on <host>), or whose runner, asked once for runner.thread's status, doesn't report it known and not running: running (refused: <path> is idle and its thread <thread> is running; stop it first), can't tell (tmux) or names no thread (refused: <path> is idle and its thread may be running; stop it first), or fails (the same, with the error in parentheses before ; stop it first). A lead between its runner start and its first started is idle with no events too; the status check is what keeps it. Any other state is refused (exit 3, refused: <path> is <state>; only a done or blocked node is forgotten), as is a node with a node under it (a lead's executors, a round's children) that is not done or blocked (refused: <child> under <path> is <state>). Without --worktree nothing on disk is touched, and a node with a worktree prints worktree <path> kept. --worktree first removes the node's worktree (git worktree remove in its repo; one already gone from disk is pruned) and deletes its branch (the one checked out there, so a branch the runner renamed is followed; else the node's), printing removed worktree <path> and deleted branch <b>; for another box's node, with [hosts] on and this box joined, it sends a forget-worktree job to that box and waits --wait (default 30s), forgetting the node only when that box answers it removed the worktree (see Hosts: Another box's worktree), and with [hosts] off it is refused (run it there); it is refused (exit 3, naming it, nothing touched) when the worktree is dirty (git status --porcelain prints anything) or the branch is not merged into main (an executor's: or into its parent's branch, while that exists). Merged means the branch's tip is an ancestor, or every commit of it has an equivalent there (git cherry), as after a rebase before landing replayed them. The merged branch is deleted with -D, since -d would also want it merged by ancestry into its upstream.

Routines

A routine runs one of a repo's checks against a commit: a shell command or a headless claude -p session. A kind says how; a job asks for one run; a result records it by tree.

Kinds

A kind is .bmesh/routines/<kind>.toml in the repo (the directory is [routines] dir in the repo's .bmesh/config.toml). Its name is the file name and matches ^[a-z0-9][a-z0-9-]*$. Every key, with its default:

Key Default Meaning
mode "pooled" inline, fresh or pooled (see Modes)
os "" the OS a box needs to run it, the word bmesh hosts prints (Go's GOOS): darwin (macOS), linux or windows; "" runs on any box. Only that OS's daemons take its jobs (see Jobs)
deterministic false the same tree always gives the same result: a recorded result answers a repeat job
slots [] slot names (from the repo's [slots]) a run holds a lease on
timeout "20m" a run is killed (its whole process group) after this
command "" run through the platform shell in the checkout at the commit; a job's args are appended as positional arguments ("$@" under sh)
model "" a model id: the kind runs claude -p with it
models [] several model ids; a job's --model picks one, default the first
prompt "" model kinds: the setup prompt, sent once per session ahead of the first job
allow [] model kinds: extra --allowedTools entries, e.g. "Bash(npm test:*)"; Read, Grep, Glob and LS are always allowed
pool 1 pooled: instances per model; fresh: runs at once
batch_idle "30s" pooled: start on held jobs after this long with no new one and no go
recycle_uses 20 pooled model: new session after this many jobs
recycle_context 120000 pooled model: new session when the last reply's context tokens (input + cache read) exceed this
recycle_idle "55m" pooled model: new session after this long idle (the prompt-cache TTL)

Exactly one of command, model and models is set; an unknown key is an error. An os other than the three is refused, kind <k>: os "<v>": want darwin, linux or windows (macOS is darwin), and so is os on an inline kind, which runs in the requester's own worktree and so can't move. os states a fact about the work ("this build needs macOS"), true on any fleet; there is no key naming a box. A command has trailing spaces, newlines and ; trimmed, and its last line may not end in a comment (the appended "$@" would be commented out). A model id may not contain spaces or ; (it must fit a verified entry); result record, routine queue and routine run refuse such a --model with exit 2. A recycle key of 0 is off.

A command runs with BMESH_JOB, BMESH_KIND, BMESH_COMMIT, BMESH_TREE, BMESH_FROM (the path the job runs as: the instance, or the requester for an inline run) and BMESH_REPLY (the requester's path) in its environment. A model kind gets the job on stdin as a header line (job: <id> commit: <sha> tree: <tree>), the job's --text and its args, and runs claude -p --model M --output-format json --session-id|--resume <uuid> [--effort E] --allowedTools <list> --max-turns 40, --effort only when the job's --effort set one (each job of a pooled session runs at its own; queue and run refuse an effort outside low, medium, high, xhigh, max, and any effort for a command kind, with exit 2). Its run is ok unless the reply's last VERDICT: line says otherwise (VERDICT: ok or VERDICT: fail), or claude errs. Markdown around the line is ignored: leading *, _, #, `, > and spaces, and the same around the value, so **VERDICT: fail**, **VERDICT:** fail and VERDICT: **ok** all read. When the kind's prompt contains VERDICT: (it asks for one), a reply with no verdict line fails; otherwise it passes by claude's exit code. A run's output is stdout and stderr (a command) or the reply text (a model).

examples/routines/:

  • verify.toml: mode = "inline", deterministic = true, command = "npm test".
  • review.toml: mode = "pooled", models = ["claude-opus-5-5"] (every change is reviewed on Opus, in a session that never saw it), allow = ["Bash(git diff:*)", "Bash(git log:*)"] and a prompt that reviews git diff <base>..HEAD (the job text carries base: <sha>, e.g. --text "base: $(git merge-base origin/main HEAD)") and asks for findings as ### <severity>: <what breaks> sections ending in VERDICT: ok or VERDICT: fail; it says verify already ran the tests, vet and formatting at that commit, so the reviewer reads the code rather than trying to run them.
  • surface.toml: mode = "fresh", deterministic = true, slots = ["browser"], command = "npx playwright screenshot"; the job args <url> <file> are appended as "$@"; a commented os = "darwin" line shows how a check that needs a Mac (Safari, say) says so.
Modes
Mode Runs Where
inline bmesh routine run in the requester's own process; no daemon the requester's worktree, at HEAD
fresh the daemon starts a new instance per job, routine.<kind>.f<id8>, and removes its checkout and node afterwards a checkout of its own
pooled the daemon keeps pool instances per model, routine.<kind>.<host>-<model-short>-<n> (<host>-cmd-<n> for a command), that drain the kind's inbox in batches one checkout per instance, kept

id8 is the job id's first 8 characters, lowercased. model-short is the model id lowercased with a leading claude- stripped, every character outside [a-z0-9] made -, runs of - collapsed to one and leading and trailing - trimmed (claude-opus-5-5 is opus-5-5, claude-opus-5-5[1m] is opus-5-5-1m). host is the box's host name ([host] name, else the short host name, lowercased) as a path token, its . and _ written as -, so each box's instances are nodes of their own (routine.review.k16-opus-5-5-1, routine.review.ricks-macbook-opus-5-5-1). Pooled instances are numbered from 1 per model across this box's repos that define the kind. An instance's checkout keeps the host-less name, <checkouts>/<repo>/routine.<kind>.<model-short>-<n>, since checkouts are local to the box.

A pooled instance holds the jobs it takes until batch_idle passes with nothing new, or a go arrives (bmesh routine go <kind>, or a job queued with --go), then runs them in order and runs any job queued meanwhile at once. A model instance keeps one session across jobs (--session-id on its first job, --resume after) and starts a new one when recycle_uses, recycle_context or recycle_idle is reached, or when the kind's definition at a job's commit differs from the one its session started with. A run whose output does not parse as claude's JSON result (including a non-zero exit with no JSON), first or resumed, drops its session whatever the recycle settings: the next job starts a new one with the prompt.

Instances are nodes (role: routine, with repo, host and model). The daemon writes their state (working during a job, idle between, done when it stops: a fresh run after its job, a pooled one when its pool is stopped or restarted; see Finished nodes for when their nodes leave) and publishes started, progress (job <id> started before a run and job <id> ok|fail <ms> after it, job <id> ok|fail <ms> ctx <tokens> for a model instance (its context tokens), both with to = the requester's path; or recycled) and done for them on bm.<instance path>.<event>. A node event with a to counts as activity of that path too, so a lead waiting on a routine is not marked silent.

The map draws one box per kind (every kind a configured repo defines, and any other kind that has instances), not one per instance: its second line counts N queued · N busy · N idle · N spent (queued the kind's depth, busy its working instances, idle its idle ones, spent its done ones of either mode), zero counts left out, or its mode (inline, fresh, pooled) when every count is zero; a busy kind pulses as working, and the box links to the routines page. The routines page has one card per kind with its instances under a heading per repo, each row the instance's name without routine.<kind>. (the full path in its tooltip), working ones first, then idle, and the done ones folded into one spent (N) row per repo that opens on a tap.

The daemon reads each registered repo's (repos.toml) kinds every minute from its main (origin/main after a fetch when the repo has an origin, else main) and starts, restarts or stops pools to match. A registered repo whose path does not exist on this box (a row for another box) is skipped, with no kinds here, and logged once per daemon start: routines: repo has no checkout on this box; skipped repo=<name> path=<path>. A kind whose os on main is set and isn't the box's own OS is left out: that daemon starts no feed for it, writes no consumer settings and starts no instances, so it never fetches the kind's jobs and only a box of that OS runs them. A repo whose kinds can't be read (no main, say) keeps what it had and is logged once per repo and main commit, routines: read kinds repo=<name> err=<error>, not every minute. A repo a box can't judge a kind for (no checkout there, a main that failed to read and left no definition of the kind from an earlier read, or the kind left out for another OS) has that kind's jobs deferred to a box that can (Jobs). Routines are off when [runner] checkouts is empty.

Jobs
bmesh routine list [--json]
bmesh routine status [<kind>] [--json]
bmesh routine queue <kind> [--commit C] [--model M] [--effort E] [--go] [--rerun] [--again] [--text T] [-- args...]
bmesh routine go <kind>
bmesh routine wait <job id> [--timeout D]
bmesh routine run <kind> [--commit HEAD] [--model M] [--effort E] [--here] [--rerun] [--text T] [-- args...]

list prints the cwd repo's kinds with their instances and queue depth (waiting plus in-flight jobs); status prints a kind's (or every kind's) instances with their host (the box the instance runs on, so it shows which box did the work), state and model, then, when there are any, the kinds' unanswered jobs in queue order under JOB KIND STATE HOLDER FROM: waiting (in the inbox, never taken, handed back, or held by a daemon that died until its AckWait passes; holder -), held (taken by a daemon and not yet running: holder daemon.<host> until a pooled instance takes it into its batch, then that instance, or a fresh run's path) or running (holder the instance). A job counts as answered once the kind's consumer acked it, or its requester's inbox holds a reply to it; a daemon's entry not rewritten within the kind's AckWait is a dead daemon's, and its jobs read waiting. --json carries the instances in each kind's object as members, [{"path","host","state","model"}], and the jobs as jobs, [{"id","from","state","holder","seq"}].

A job is an inbox message (kind: "job") to routine.<kind>, with the job id as its Nats-Msg-Id, so a job queued twice is stored once (queue prints duplicate <id>). It names a commit (--commit, default HEAD, resolved in the cwd repo to a full SHA with its tree) and a repo: the requester node's repo when it is a registered node naming one; else the registry name whose path shares the cwd's git common dir (so a linked worktree, where leads live, names its main repo); else queue refuses it (exit 3), bmesh: <repo> is not in the registry, naming the main repo directory's base name, as it refuses a node's repo the registry lacks: no daemon would run its job. queue refuses an inline kind too (exit 3, bmesh: <kind> is inline: run it with bmesh routine run). The daemon resolves the repo through its own registry. The kind's inbox consumer is inbox-routine--<kind>, one consumer that every box running the kind shares. Its MaxAckPending is -1 (no limit): each daemon fetches only while fewer of the kind's jobs than its own capacity are in flight on it, its instance count for the kind (pool per model per repo for pooled, pool for fresh, at least 1, so a box whose repos can only refuse still answers). Its AckWait is the kind's longest timeout + 1m on the box that wrote it last: a daemon writes it when its feed for the kind starts and when its own value changes, and on every other rescan reads the consumer and writes MaxAckPending -1 back, keeping the AckWait it read, when it reads anything else (an old daemon's count). The daemon keeps a job it holds or runs in progress every 20 s, a third of the 1m every box's AckWait adds to a timeout, so no box's AckWait outruns it and the job is not redelivered while that daemon lives. A daemon that stops (SIGTERM or SIGINT, and before an update's re-exec) kills every running job's process group and hands every job it holds or ran back with a NAK, unanswered, so the next daemon takes it at once (a model job killed mid-run is paid for twice); only a daemon that dies leaves its jobs unacked until AckWait passes. Each daemon keeps its unsettled jobs of a kind in KV bm-jobs under <kind>.<host>, rewritten on every change and with every in-progress heartbeat (every 20 s), and emptied when it starts, which status reads for held and running.

The daemon runs a job with the kind as defined at the job's commit (git show <commit>:.bmesh/routines/<kind>.toml), falling back to main's definition only when the file is absent at that commit. A deterministic kind's job is answered from its recorded result, ok or failed, without a run unless --rerun; queue checks the record first and prints answered from result <key> (job <id>). A job whose commit is not in the daemon's clone (queued from another box) gets one git fetch origin before it is answered not a commit, so push the branch before queueing to a daemon elsewhere. A job whose slot is busy goes back to the inbox for 30 s. A review is one pass per branch: queue review for a worktree whose branch already has a review result (one recorded for a commit that is an ancestor of HEAD but not of main, origin/main when the repo has an origin, so a landed spec's review in the history does not count) is refused (exit 3) naming that result, its verdict, commit and time, and queues only with --again, which is for code a rebase changed. A kind that a configured repo defines but not the job's repo's main (kind <k> is not on <repo>'s main (origin/main at <sha8>), the commit that box last read main at, main at for a repo with no origin), an inline kind, an unknown model, a commit whose kind runs on another OS (kind <k> at <commit8> runs on darwin; this box is linux, below) and a lost lease are answered ok=false with that reason; routine wait prints it and exits 1, as for any failed job. So a lead that queues a kind only its branch has is told at once by any box with the repo. A daemon defers a job it can't judge instead: one for a repo its registry doesn't name (it reads repos.toml only when serve starts, so restart serve after changing the registry), or for a repo with no checkout (or no usable path) on that box, whose main failed to read and left no definition of the kind from an earlier read, or whose main gives the kind another OS. It hands the job back with a NAK delayed 2 s × 2^(n-1), at most 1m, less a random part of up to a quarter so the redelivery doesn't fall in step with its own next fetch (n the job's delivery count), sends no reply, and logs routines: deferred job <id>: <repo> <why> here once per job; a box that can run the job takes it. A deferred job holds none of the box's capacity, and a job that only boxes without its repo see waits, as does a job of a kind that no configured repo defines on its main (it has no feed), until routine wait times out. A go is one message, which one box fetches: every box holding jobs of the kind looks in the stream for a go past its oldest held job (and the last go it acted on) after each empty fetch and, while its capacity is full, every second, so a go any box fetched starts every box's held batch.

A kind with an os runs only on a box of that OS; its jobs wait on the hub while that box sleeps, and run when it wakes. A job runs on the os its commit gives the kind, else on main's (the definition a daemon falls back to, and the one that decides which boxes take the kind's jobs), whatever the working copy says. queue of a job that runs on another OS than this box's, when no recorded result answers it, refuses (exit 3) a commit on no origin branch (git branch -r --contains <commit> --list 'origin/*' prints nothing), bmesh: push first: <kind> runs on darwin, which fetches the commit from origin, and otherwise prints queued <id> (runs on darwin). Which boxes take a kind's jobs is read from main, but a job runs the kind as defined at its commit, so a branch could give a kind both boxes run an os. queue reads the kind at the job's commit (git show, not the working copy), and when that sets an os compares it with the kind on origin/main (main when the repo has no origin), refusing (exit 3) when main's differs, bmesh: <kind> runs on darwin at <commit8> but anywhere on main, so a job could reach a linux box: land the os change first, or give it a kind name of its own; when main lacks the kind (it has no feed anywhere), bmesh: <kind> is not on main: land it first; and when main can't be read, bmesh: cannot read kind <kind> on <ref>: <why>. A commit with no os in the kind, or no kind file, isn't checked: it runs where main sends it. Both checks come after the record lookup, since a recorded answer queues nothing. A daemon whose job's commit gives the kind another OS (queued by an old CLI, or main moved between queue and fetch) answers it ok=false right after loading that definition, before the record lookup and the slot lease, kind <k> at <commit8> runs on darwin; this box is linux, and acks it; it is never run or recorded there. A kind name is one inbox across every repo and box, and a box defers the jobs of a repo whose main gives the kind another OS, so kinds of different OSes may share a name.

A reply is an inbox message (kind: "reply") to the requester carrying {"job","kind","tree","ok","output","result_key","by"}; the output is cut at 64 KiB with a trailing [truncated] line. wait reads the reply from this node's inbox subject in the stream, not its consumer: the subject keeps every message for the stream's 30 days, acked or not, so wait scans that history for the reply to the job, then follows the subject until it arrives, prints its output and exits 0 when ok, 1 when failed, 5 on --timeout with bmesh: timed out waiting for <id>; the job may still be queued or running, and its reply stays in the stream for 30 days: run bmesh routine wait <id> again rather than queueing it twice. A reply inbox already read is found the same way, so wait works whenever it runs. A reply still unread is acked once found (one no-wait batch from the consumer that acks it and returns every other message unread), so no wake follows for it and inbox does not print it again; a reply further back than 100 unread messages stays unread and wakes the node once. --timeout defaults to 20m of queueing plus the kind's timeout plus the daemon's 1m beyond it, when the cwd repo defines the kind the job was queued to (looked up by the job id in the kinds' inboxes; 41m for a 20m kind), else 20m. bmesh inbox returns what the inbox holds at once, without waiting for more, acks everything it reads (--peek leaves it unread), and prints a reply as one line, <at> <from>: reply to job <id>: <kind> ok|fail, <n> lines; bmesh routine wait <id> prints it; --json prints every message whole.

queue, go and wait need a node identity (--node, BMESH_NODE or the registered worktree): the reply is addressed to it. run, lock-lease and result record use the node when one resolves and otherwise act as the host name; list, status and result find need none.

Checkouts

Fresh and pooled runs never touch a requester's worktree. Each instance has a detached worktree of the registered repo's path at <[runner] checkouts>/<repo directory name>/<instance path>, moved before each job with git checkout --detach <commit> && git clean -fd; the daemon reads the repo's main in a sibling checkout named main.

routine run is the inline run: it runs HEAD of the cwd worktree (--commit must name HEAD) and refuses a fresh or pooled kind unless --here. It refuses a kind whose os isn't this box's, --here included (exit 3, bmesh: mac-build runs on darwin; this box is linux: queue it): run here, it would record this OS's result as the kind's for the tree, and every later job for that tree would be answered from it. Every inline run records a result for HEAD's tree, so it refuses a dirty tree: exit 3 bmesh: worktree is dirty; commit first. It prints the run's output, then ends with its verdict line, <kind> ok|fail t:<tree8>[ m:<model>] (recorded), or (answered from the record) when a deterministic kind's recorded result answered it (its recorded output printed above); the line is a verified entry, and is not part of the recorded output.

Leases
bmesh lock-lease acquire <slot> [--ttl D]
bmesh lock-lease renew <key> [--ttl D]
bmesh lock-lease release <key>
bmesh lock-lease run <slot> [--ttl D] -- <cmd...>

A slot is a resource only so many runs may use at once. Its capacity is in the repo's .bmesh/config.toml (default 1):

[slots]
browser = 1
[routines]
dir = ".bmesh/routines"

Leases live in KV bm-leases, key <slot>.<n> (n below the capacity), value {"holder","expires_at"}; an expired entry is free. --ttl defaults to 10m and is at least 3 s. acquire prints the key (exit 3 when the slot is busy); renew and release refuse a lease held by someone else (exit 3). A holder renews every ttl/3; when a renew fails the lease is lost: the run is killed and the job answered ok=false with lease lost (nothing is recorded; lock-lease run exits 1). lock-lease run runs the command in its own process group, kills the group and releases on SIGINT or SIGTERM, and exits with the command's code. Kinds take leases on their slots for timeout + 1m (inline runs and the daemon alike).

Results
bmesh result find <kind> [--commit C] [--model M] [--json] [-- args...]
bmesh result record <kind> --ok|--fail [--commit C] [--model M [--base B]] [--output FILE|-] [-- args...]

Results live in KV bm-results, key <kind>.<tree>[.<args8>][.<model-short>]: tree is the commit's full tree hash, args8 the first 8 hex of the sha256 of the args joined by NUL (only when there are args), model-short as in Modes (only for a model). The record:

{"kind":"verify","tree":"<tree>","commit":"<sha>","args":[],"ok":true,"output":"...","at":"2026-01-02T03:04:05Z","by":"app-012","model":"claude-opus-5-5","patch_id":"<sha1>"}

model is omitted for a command kind; patch_id is the git patch-id --verbatim (--stable, whitespace kept, so a re-indented hunk is another diff) of the diff a model was asked to read, from the commit the job text's base: <sha> names to the job's commit (read with git diff-tree -r -p --binary --full-index --no-renames, so no user config changes it), and is omitted for a command kind, for a job text naming no base and for an empty diff (a base that is not a commit there records none: the daemon logs it, routine run says so on stderr); output is cut at 64 KiB. Every run that names a commit records: inline runs and daemon runs, deterministic or not (a kind's newest run at a tree replaces the last). Answered-from-record jobs, refused jobs, lost leases and runs cut off by a stopping daemon record nothing. result record stores a result by hand for any kind name, with or without a kind file; --commit defaults to HEAD, and a result for HEAD is refused while the worktree is dirty (exit 3 bmesh: worktree is dirty; commit first). --base B records the patch-id of B..<commit> as a review's (it needs --model, exit 2 without; an empty diff is refused, exit 3). result find prints <ok|fail> <tree> <at> <by>[ m:<model>] (exit 3 when none); with --model it prints that model's result, without it every result of the kind at the tree with those args, one line per model (--json: one object per line). A result subcommand's usage error prints its own usage, flags included; -h on any subcommand names its flags.

A result older than [routines] keep_results (default 30d, the stream's age; 0 keeps them forever) is gone: bmesh serve provisions bm-results with that age. A deterministic kind then runs again, and bmesh done for a spec landed longer ago than that needs its routines run again on the merged tree (it names the missing result); the spec's verified line is the record that stays.

verified

A spec's frontmatter carries one line of proof, ;-separated, one entry per check:

verified: verify ok t:3f2a9c1e; review ok t:3f2a9c1e m:claude-opus-5-5

Each entry is <kind> <ok|fail> t:<tree first 8>[ m:<model>][ c:<tree first 8>]. c: names the tree a model's result was recorded at when it carries to t: across a rebase that left the diff unchanged:

verified: review ok t:3f2a9c1e m:claude-opus-5-5 c:81d0b7a4; verify ok t:3f2a9c1e

bmesh spec verified [--spec NNN] [--commit C] [--write] composes the line from the results recorded for the tree of HEAD in the cwd worktree, or of commit C (--commit; read below as HEAD; exit 2 when it is not a commit): one entry per (kind, model), the newest result winning, whatever its args, sorted by kind then model. A (kind, model) with no result there takes the newest model result whose patch_id equals HEAD's (the diff from HEAD's merge-base with origin/main, main without an origin, to HEAD), written t:<HEAD's tree8> and c:<its own tree8>: a review of the same diff on another base. A changed diff carries nothing, however small; a command kind's result (verify) never carries, since it is about its tree, and nothing carries when HEAD is on main. It prints verified: ..., or exits 3 with bmesh: no results for HEAD's tree; run the routines first (no results for <C>'s tree t:<tree8> under --commit). --write sets the line in the spec's file in this worktree, whichever commit it was composed for, so a line for a commit already merged is rebuilt from its recorded results rather than typed (found by [spec] glob; the spec is --spec, else this node's), replacing a verified: line or adding one before the closing ---; a line that would not read back as the same entries is not written.

bmesh done refuses unless the spec reads done on main with a merged commit that main contains (git merge-base --is-ancestor <merged> <ref>, ref = origin/main when the repo has an origin, else main): exit 3 bmesh: not done: merged <sha> is not on <ref>. For a lead it further requires a non-empty verified line with no fail entry, whose every entry is for the tree of merged and is matched by a record of that kind at that tree with the same ok (and the same model when the entry names one), whatever its args; a carried entry is matched by a model record at the tree its c: names (first 8 hex), with the same ok and model, whose patch_id is the diff merged landed: that of <a>..<merged> for one of the nearest 100 first-parent ancestors <a> of merged. It refuses with exit 3:

  • bmesh: not done: verified is empty
  • bmesh: not done: verified has a failing entry <kind>
  • bmesh: not done: verified entry <kind> is for tree <t>, merged tree is <m>
  • bmesh: not done: verified names <kind> but no result for tree <t> (<t> the c: tree for a carried entry)

An executor's done skips the spec check.

On success done prints done <path> <sha8> (<sha8> the --commit given), and started, progress, ask and blocked print <event> <path>. inbox prints one line per message, <at> <from>: <text> (a reply as <at> <from>: reply to job <id>: <kind> ok|fail, N lines; bmesh routine wait <id> prints it), or inbox: empty when nothing waits (nothing under --json). A done for a node already done is compared with its newest done event's commit: the same commit (equal, or one an abbreviation of at least 7 characters of the other) exits 0 with already done at <sha8>, checking and publishing nothing; another commit is refused, exit 3 bmesh: refused: <path> is already done at <sha8>, not <sha8>. A lead's --commit is the commit main points at after its push, the follow-up commit that records done; the spec check reads merged and the results, not this sha.

Landing

bmesh land [--spec NNN] [--ttl D] [--wait D]

land lands a lead's branch on main from its worktree, in one command, under the lease land.<repo> (bm-leases, key land.<repo>.0, one place; <repo> the node's repo, else the registry row or base name of the main checkout), so two leads landing at once queue instead of redoing each other's work. The spec is --spec, else the node's; the holder is the node, else the host name. Run from a node held in bm-holds, at either gate, it stops first (exit 3, nothing pushed, bmesh: held before land by <by> <age> ago: <why>; ...; see Holds), and it reads the hold again once it holds the lease. It refuses (exit 3), before taking the lease, a dirty worktree and one not on a branch (or on main). Run from a node, it then reads the node's inbox as the daemon's wake does (from the stream after the consumer's ack floor, holding and acking nothing): while it holds a message sent by operator with bmesh send, it refuses (exit 3) bmesh: refused: <n> unread message(s) from the operator; run bmesh inbox, act on them, then run bmesh land again, since the operator's messages are the ones likely to change what landing needs; reading the inbox is the way past it, and there is no --force. Anything else unread (a report, a reply, another node's message) doesn't stop it and is named on one line, each item as the wake names it, unread: done from <path> (branch <b> at <sha8>); reply to job <id> (<kind> ok|fail); message from <path>. It reads the inbox again once it holds the lease, so a message from the operator that arrived while it waited refuses it before the fetch. Run outside a node, it has no inbox and skips the check. A message that arrives after that read, during the rebase and checks, is not seen. Then it prints the kinds it will require of the landed tree and where each came from, required: verify (the repo's verify kind), review (t:<tree8> at HEAD), or review (t:<tree8> before the rebase at <sha8>) for a result from before a rebase by hand (required: nothing when there are none). While another holder has the lease it prints waiting for land.<repo> (held by <holder>) once per holder and tries again every 2 s, giving up after --wait (default 30m; exit 5 bmesh: timed out waiting for land.<repo>, held by <holder>); the lease lasts --ttl (default 10m, at least 3 s) unrenewed and is renewed every ttl/3, so a lander that dies blocks the next for at most that long. Then, printing a line per step:

  1. fetch origin/main <sha8>: git fetch origin main; refused (exit 3) while the main checkout's main has commits origin/main doesn't (someone's unpushed work, never pushed by land). Without an origin the base is the local main.
  2. rebase onto <sha8>: N commits (or rebase: on <sha8> already). A rebase that fails is aborted and refused, exit 3, bmesh: refused: rebase onto origin/main conflicts in <files>; it is aborted and nothing is pushed. Rebase yourself, resolve it, re-validate, then run bmesh land again; resolving it is the lead's. A branch with nothing beyond the base is refused (nothing to land), as is a spec that does not read building in the worktree.
  3. scaffold: follows the row: with a ship row for the repo whose AGENTS.md (in the rebased tree) has a <!-- bmesh: marker, the scaffolded text the branch changed is compared with what scaffold writes from the row and the binary: each of AGENTS.md's marked blocks when they differ from the base's (origin/main's), and the verify kind's command when it differs from the base's; a file the branch didn't change there is not compared, so a row published mid-build, a newer snippet or another box's older binary never refuse a landing, and a file scaffold would create is not drift. Drift prints the first 20 lines of its unified diff (... after them) and is refused, exit 3, bmesh: refused: the branch's scaffolded files differ from what bmesh scaffold writes from <repo>'s row; the row's verify is the source: bmesh repos propose with the new verify, or restore the scaffolded files; once the row is published with the new verify, bmesh scaffold <worktree> writes the files on the branch. A repo never scaffolded prints scaffold: not scaffolded, not checked; one with no ship row prints nothing. A tree whose go.mod declares this binary's own module renders its own blocks, which this binary may predate, so there only the verify kind is compared: scaffold: the verify kind follows the row (AGENTS.md is rendered by this tree's own bmesh, not checked).
  4. The results for the rebased tree. The kinds required are those with a result at the branch's tree before the rebase, and verify when the repo defines it. A branch rebased by hand is read as it was before: when HEAD is the commit a rebase made (the branch's reflog, git reflog show refs/heads/<branch>, has a rebase (finish) entry at HEAD; rebase -i and pull --rebase are the same), the head the rebase rewrote, the reflog entry just before it, counts as land's own head before the rebase, and so on back while that head was itself made by a rebase; a kind with no result at HEAD's tree is required when that head's tree has one, and is then judged against that head and its fork point from main, as if land had done the rebase itself. So a review lost to a conflict that the lead resolved by hand (land stopped, the lead rebased and ran it again) is carried when the resolution left the diff unchanged and refused otherwise, as below. A commit made after the rebase ends the walk: HEAD is then read as it stands, as is a branch whose reflog has no rebase or expired (gc.reflogExpire, 90 days), or that was rebased on another box. A review's findings fixed in a commit and then rebased land on verify alone, the fix's tree having no review. An inline kind runs as routine run does (a deterministic one is answered from its record) and prints its verdict line; a failing one prints its output and exits 1. Another kind (a review) prints the entry its result for the rebased tree makes, <entry> (recorded), or the entry a result recorded with the rebased diff's patch-id makes (see verified), <kind> ok t:<new8> m:<model> c:<old8> (carried from t:<old8>: the diff is unchanged), on the line; with neither, a result from before the rebase that is ok, recorded no patch-id, and whose diff the rebase left unchanged (the patch-id of the fork point to the old head equals that of the new base to the new head) carries: each model's result is stored again with that patch-id, the diff it read, and lands on the line as a carried entry, printed <kind> ok t:<new8> m:<model> c:<old8> (carried from t:<old8>: the rebase left the diff unchanged), which done matches as any carried entry (a result with no model has nothing done can match, so it carries off the line, printed <kind> ok t:<old8> carried: the rebase left the diff unchanged (not on the verified line)); a failing one, or a changed diff, is refused (exit 3) naming the kind and how to re-run it. Any failing entry for the tree is refused too.
  5. verified: ... and spec NNN done: merged <sha8>, branch <b>: the verified line as spec verified --write composes it, and status: done (from building) with merged the rebased HEAD and branch the worktree's current branch.
  6. commit <sha8> NNN: done at <merged8>: the spec's change, committed on the branch.
  7. push origin main <sha8>: git push origin HEAD:refs/heads/main, a fast-forward or nothing. A push refused because origin/main moved takes the record commit back off the branch, prints main moved; again from the fetch and starts again at step 1, once; a second move is refused (exit 3) with nothing pushed. Any other failure takes the commit back and exits 1.
  8. main <sha8>: the main checkout's main fast-forwarded as choose-ideas does (merge --ff-only when it is on main, else fetch . <branch>:main); when it can't, land says so on stderr and still lands, origin/main holding the commit. Without an origin this step is the landing, and a main that moved is retried as a refused push is.

It ends released land.<repo> and landed <sha8>, the record commit: the one bmesh done --commit names. When it stops, nothing is pushed and the branch holds no record commit, so land runs again as it stands.

Hosts

Boxes that share the hub can act on each other through it, with no SSH between them: the admin's signed roster says which boxes belong, every message between boxes is signed, and a box runs only what its row in the roster lets it. Nothing here runs unless [hosts] enabled = true, which only bmesh init --hosts sets; a single box never asks, and its daemon never pushes or pulls anything. The ops are ping (it shows the mesh works), level (Levelling: boxes keep each other's checkouts level with origin), propose (The registry: the admin's signed repos.toml), prune and forget-worktree (Pruning, Another box's worktree) and nix (Removing a repo everywhere).

Joining. bmesh init --hosts [--admin] asks: join (with an invite) or be the admin (--admin says admin); whether this box is secure (what others ask of it waits for the operator's approval); and whether its daemon runs routines (default no on a secure box, yes otherwise). Run again, it asks with the current answers (from config.toml, host.key and <state dir>/roster.json) as the defaults. It needs a config.toml (bmesh init first) and a [host] name (or host name) that is one subject token, ^[a-z0-9][a-z0-9-]*$ with no -- and at most 63 characters. Last, after the keys and the join, it writes the [hosts] table (enabled, admin, secure) and, with routines off, [runner] checkouts = "" (with routines back on, a checkouts = "" line goes, back to the default); every other line of config.toml stays as written, the old file is kept as config.toml.prev, repos.toml is never touched, and --force keeps its meaning (start over). The daemon reads the config at start, so restart it afterwards.

  • The admin (--admin): makes the admin key (below) when it is missing and publishes roster v1 with its own row (its approve key is the admin key; it accepts ping), or, run again, keeps the roster and republishes only when its own row changed. When the hub already has a roster signed by another key it prints a roster signed by <fingerprint> exists; becoming admin replaces it on every box you invite again. It prints admin: <host> <fingerprint>, roster v<N>.
  • On the admin box, bmesh hosts invite <name> [--secure] [--accept a,b] [--replace] (default --accept ping) prints invite for <name>: <id>-<secret> and waits: the id is 8 hex characters and not secret, the secret 128 random bits in base32. The code is good for 10 minutes and once. A name already in the roster is refused unless --replace, and the admin's own name always (its row carries the admin key).
  • On the new box, bmesh init --hosts asks for the code at its prompt (never in argv, so it isn't in ps or the history) and sends {name, key, approve_key, ts, salt} on bmhosts.join.<id> (core NATS request/reply: nothing stored), MACed with HMAC-SHA256 under HKDF-SHA256(secret, salt=id, info="bmesh join request v1"). The admin burns the invite on the first request whose MAC verifies; a request that fails the MAC is dropped without burning, and the third such failure burns it. The name must be the invite's. The admin publishes the next roster with the box's row (its approve key only when the invite says --secure) and replies {roster, admin_key} under the same MAC (info="bmesh join reply v1", bound to the request's salt); the new box takes the first reply whose MAC verifies, ignoring junk, checks the roster carries its key, pins the admin key and stores the roster: joined: roster v2, admin <host> <fingerprint>. A fresh invite re-pins a box that pinned another admin; a box already joined may leave the code empty to keep its roster.

Keys. Every joined box has an Ed25519 host key in <config dir>/host.key (the seed in hex, mode 0600); its agents can read it, so it proves only "this box". The admin box, and every secure box, also has one protected key agents can't use: the admin key (signs rosters, registries and nix jobs) or the approve key (signs approvals). Its public half is kept in <config dir>/admin.pub (or approve.pub), so only signing reads the key itself. [hosts] key_store says where it lives:

  • keychain (the default on macOS): the login Keychain, service bmesh, account admin or approve, written through security -i with the key on stdin (never in ps) and an empty trusted-app list (-T ""), so every read raises the macOS dialog asking for the login password. Before each read bmesh prints Keychain dialog follows for: <action>: deny any dialog that wasn't announced, and choose "Allow", never "Always Allow". Before reading it checks the item's access list (security dump-keychain -a) and refuses, the admin key's Keychain item trusts <app>; remove it in Keychain Access, when any app has been added (what one "Always Allow" does) or every app is trusted. Over SSH there is no dialog and the read fails: admin and approve commands run at the Mac's own screen.
  • file (the default elsewhere): <config dir>/admin.key (or approve.key), mode 0600, the seed sealed with XChaCha20-Poly1305 under scrypt(passphrase, salt, N=32768, r=8, p=1); the passphrase is asked on the terminal without echo (twice when the key is made).

The admin commands also refuse when stdin isn't a terminal; that is a convenience, and the dialog and the passphrase are the control.

The roster is KV bm-roster, key roster (outside bm.>): {type, version, admin_key, hosts: [{name, key, secure, approve_key, accept}], issued_at, sig}, signed by the admin key. A box keeps the admin key it pinned and its newest valid roster in <state dir>/roster.json, and accepts a roster only when the pinned key signed it and its version is higher. The admin takes the next version from its own roster.json (after taking any newer roster its key signed), never from the hub, saves it there and writes the hub with compare-and-set on the key's revision. Every daemon takes a newer roster.json first (a roster the admin saved but couldn't write to the hub, or a fresh invite's pin), never writing an older roster over it, then reads the roster key each minute and before each job, and puts its own back when the hub's copy is missing, unsigned or lower, so a roster deleted or rolled back by a token holder comes back within a minute from any awake box. On the admin box:

bmesh hosts set <name> [--secure=true|false] [--accept a,b]   # change a row (secure needs the box's approve key: a --secure invite)
bmesh hosts revoke <name>                                       # the next roster without it; no other box can revoke, and the admin can't revoke itself

Each prints roster v<N>: …. A fingerprint is the first 16 hex characters of a key's SHA-256.

Presence. Each daemon writes KV bm-hosts, key <host>, each minute: {type, host, rev, os, routines, repos: [{name, path, origin}], notes, ts, sig}, signed with its host key (origin is the checkout's origin URL, else the row's remote; notes is <config dir>/notes.md, read each minute, invalid UTF-8 dropped, cut to 16 KB on a character boundary (logged once) and trimmed; "" without the file). Presence is never the source of a path or a decision. bmesh hosts [--json] prints roster v<N>, admin <host> <fingerprint>, then one row per roster box: its state, awake, asleep (the entry's server timestamp is over 3 minutes old), unverified (its signature fails: shown without notes) or absent; the admin and secure marks; OS, revision, routines, repo count and notes when it has some; what it accepts; and when it was seen. --json prints {"roster_version","admin","fingerprint","hosts":[{"host":{row},"state","at","presence":{…}}]}.

bmesh hosts --notes [--json] prints every box's notes, ## <host> (<state>), a blank line and the notes, boxes in roster order with a blank line between them (no notes when none has any; --json: {"<host>": "<notes>"}); an unverified box's are withheld, with bmesh: <host>: unverified, notes withheld on stderr. Notes are where a box's session guidance goes: what is local on it, what it can't reach, how it differs from the others.

The session line. Each minute, after presence, the daemon writes <state dir>/this-box.txt (through a temporary file and a rename): this box is <host>[ (admin)]; secure: yes|no; on the mesh: <host> (<state>[ <age>][, secure][, admin]), …, every other roster box with its presence state, an asleep one with how long ago it was last seen (asleep 2h: minutes, hours, or days past two days), no other box when there is none, and ; notes: `bmesh hosts --notes` when any verified box has notes. bmesh hosts --here prints that file and does nothing else, adding ; as of <age> ago, not updated since when the file is over 3 minutes old (its daemon stopped, or can't reach the hub, so the states and ages are the last it saw): it never dials the hub (a headless routine session doesn't wait on it), never runs or asks for init, and prints nothing (exit 0) when [hosts] is off or the file is missing. After it, [hosts] on or off, comes the hub line when this box's bmesh hub runs an older binary than its bmesh serve (hub runs <hub>, serve <serve>: restart bmesh-hub, from hub.json and serve.json, see Several boxes). The plugin's session hook runs it (see The skills).

Jobs and replies travel in bm-mesh. A job for a box goes to bm.inbox.host.<host>; the box's durable consumer inbox-host--<host> (AckWait 10m, InProgress every minute while a job runs, MaxAckPending 1) resumes where it stopped, so a box that was asleep gets what it missed, in order. A reply goes to bm.hostreply.<caller>.<job id>. Neither ever shows in the map, watch, status or the activity reader.

Signed messages. Every job, reply, approval, roster, registry and presence entry is signed: sig is the hex Ed25519 signature over the SHA-256 of the message's bytes, deterministic (RFC 8032), so one message gives one signature on every box. The bytes are every field in a fixed order, starting with the type tag (bmesh/job/v1, bmesh/reply/v1, bmesh/approve/v1, bmesh/roster/v1, bmesh/registry/v1, bmesh/presence/v1): a scalar is a 4-byte big-endian length then its bytes (a string as UTF-8, an integer in decimal ASCII, a boolean 0 or 1); a list is a 4-byte big-endian count then each item; a struct is its fields in order. Lists of names (accept) are sorted and deduplicated, and the roster's hosts and presence's repos are sorted by name; ts, expires and issued_at are RFC 3339 UTC to the second; a salt is 16 random bytes in hex; keys and signatures are lowercase hex. The fields:

  • job: type, id, by, to, op, repo, args, roster_version, ts, expires, salt;
  • reply: type, job_id, job_salt, by, to, ok, text, ts, salt;
  • approve: type, job_id, job_salt, job_sha256, by, decision, ts, salt;
  • roster: type, version, admin_key, hosts (name, key, secure, approve_key, accept), issued_at;
  • registry: type, version, admin_key, rows, issued_at;
  • presence: type, host, rev, os, routines, repos (name, path, origin), notes, ts.

A receiving box checks a job in this order and stops at the first failure: (1) by is in the roster (an op that needs the admin is checked against the admin key); (2) the signature verifies; (3) to is this box; (4) roster_version isn't ahead of this box's roster (else it naks the job for a minute, until it has caught up; a hub it can't read naks too); (5) ts is at most 5 minutes ahead of its clock (else a nak for a minute: a Mac just woken can be behind); (6) expires hasn't passed and expires - ts is at most 24h; (7) the op is in this box's accept; (8) the salt's record in <state dir>/salts (one file per salt, kept until the job expires, surviving a restart): none writes started and runs it; done acks and sends the stored reply again, never re-running, and is logged only (a lost ack redelivers a job too); started (a redelivery after a crash or sleep) resumes an idempotent op (ping, level, prune) and answers any other interrupted; a salt another job used is refused as replay. A failure is answered refused: <check> (roster, signature, to, expired, accept, replay) and never run. An expired job is acked, answered and logged only, so a weekend asleep doesn't flood the phone; roster, signature, to and replay failures are also published as bm.daemon.<host>.refused (text refused <check> from <sender>: <op> job <id>, body <sender> <check>), which any daemon with [notify] and [hosts] pushes once (title <host> refused <sender>: <check>, priority high) through the push-<seq> claim, at most once per sender and check every 10 minutes. The caller checks a reply the same way: signed by the job's to, naming the job's id and salt.

ping answers <rev8>, roster v<N>. bmesh hosts ping [<host>] [--wait 10s] sends it to every roster box (itself included), or the one named, living 24h, and prints each answer as it arrives (k16: 1a2b3c4d, roster v2); a box that hasn't answered within --wait prints <host>: asleep: queued until <expires> (no answer in <wait> instead of asleep when its presence is awake). bmesh hosts replies [--json] prints every reply this box's jobs got (<ts> <host> <op> <job id>: <text>), checked against the job in the stream and the roster; one that doesn't check is shown as unverified, withheld.

Waking. A daemon tells it has woken from a gap of 3 minutes or more between its minute ticks, read on the wall clock (monotonic time stops while a box sleeps), logs it, re-reads the roster and self-levels (below); it reads the roster again before each job.

Levelling. A repo checked out on several boxes, some of them plugins every session loads from the checkout, stays level with origin from whichever box drives it, and no box puts code on another that the operator hasn't seen where that matters.

  • One lock per repo. A level, the daemon's routine fetch (its rescan, and a job's commit fetched once) and a landing's fast-forward of main in the main checkout (bmesh land, build, choose-ideas) all take <state dir>/locks/<repo>.lock, a flock that waits for its holder, so they never collide on git's index.lock or a ref lock. With [hosts] off nothing takes it.
  • level, a built-in op (repo the registry name, args [<sha>, <branch>, <until>]: origin's head after the sender levelled, the sender's branch, "" for a pinned row, and when the sender stops waiting), is judged by the receiving box alone, against its own registry row's checkout, which must exist (else refused: no checkout of <repo> here):
    • It runs a plain git fetch origin (and, for a row with a pin, fetches that tag, never forced). The target is origin/<branch> (the job's branch, else the checkout's current one), or the pin tag's commit for a pinned row; a missing one is refused: no origin/<branch> or refused: no tag <pin>, and a checkout with no origin refused: no origin.
    • The job's sha only says "at least this": it must be in the target's history, else refused: <sha8> not on origin/<branch> (or not on <pin>), so a side branch's or a fork's commit can't be named.
    • The checkout check moves nothing and answers refused: in progress (a MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, a rebase-merge or rebase-apply directory, or an index.lock), refused: dirty (uncommitted or untracked changes), refused: other branch (on another branch than the target's, or detached, for a row that isn't pinned) or refused: diverged (both ahead of and behind the target).
    • Behind only, it fast-forwards (merge --ff-only): fast-forwarded <N>. A pinned row's checkout moves to the tag, detached (moved to <pin>), unless it carries commits no remote branch or tag has (ahead <N>, not pushed: pinned to <pin>); a pinned row is never pushed. Level with the target: up to date.
    • Ahead only, it pushes when the caller may push (a received job or bmesh sync, never a self-level), the row's mode is in [hosts] push_modes (default ["ship", "fork"]) and the newest local commit is at least 10 minutes old: git push origin <branch> <tags…>, the tags (lightweight ones included) that point at the pushed commits, answered pushed <N>. Otherwise it answers ahead <N>, not pushed: <why> (a self-level never pushes, mode <m> is not in push_modes, the newest commit is <age> old (10m0s before a push)). When another box pushed first, the push is refused as not a fast-forward and answered refused: diverged.
    • Plugin refresh. After the checkout moves, the box refreshes what it loads from it: when it carries .claude-plugin/marketplace.json, claude plugin marketplace update <name> and then claude plugin update <id> --scope user for each user-scope install from that marketplace (read from a folder in the checkout) whose version lags the folder's; and, when codex is on PATH, codex plugin add <id> for each installed Codex plugin whose marketplace source is the checkout (Codex keeps a copy). Each refresh is one more part of the answer (fast-forwarded 3; claude pstack@pstack-claude 0.15.24 -> 0.15.25); a failure is appended as plugins: <error>. A new session loads the change. bmesh runs no other command a job or the registry names.
  • The hold. On a secure box (its [hosts] secure, or its roster row's), a level that would move a repo sessions load waits for the operator, whether it arrived as a job, came from the daemon's own self-level or from bmesh sync at the box. Such a repo is a checkout that is a plugin marketplace (.claude-plugin/marketplace.json, .agents/plugins/marketplace.json, or the marketplace source of a Codex install), or the [update] repo. A push moves nothing here, so it isn't held.
    • The hold is <state dir>/held/<job id>.json: the job (a self-level's is the box's own, signed with its host key), the repo, its head and target, and git log --stat HEAD..<target>. A repo has one hold: a newer job's replaces it, and the older job's sender hears hold superseded by job <id>; a self-level leaves an existing hold as it is. The job is answered held for approval (+<N> commits).
    • The box publishes bm.daemon.<host>.held (text <host> holds: <repo> +<N> commits (<sender or self-level>), body the hold's id), which a daemon with [notify] pushes once through the push-<seq> claim (title the text, priority default).
    • bmesh hosts held [--json] lists the holds: <id> <repo> +<N> commits to <target8>, from <sender>, expires <day hh:mm>.
    • bmesh hosts approve <id>, at the box's own screen, shows the log again, reads the approve key (the admin key on the admin box) with the announced Keychain dialog (or the file store's passphrase) and signs approve/v1 over the job's hash, with decision approve <target>, the target the operator saw. It sends that to the box's daemon (core NATS request/reply on bmhosts.approve.<host>, outside bm.>). The daemon checks the approval is signed by the box's roster approve key, names the hold's job (id, salt and hash) and target and is within 5 minutes of its clock; that the job's signature still verifies against its sender, who is still in the roster (else it drops the hold); then levels. When origin moved since, nothing moves: the hold takes the new target and log, and the command shows them and asks again (Approve <repo> at <target8>? [y/N], signing again with the key it read). Approved, it prints <repo>: fast-forwarded <N> (approved), and the job's sender gets that as a second reply. With no daemon running, it is refused.
    • bmesh hosts deny <id> drops the hold; the job's sender hears denied by the operator.
    • A hold expires with its job (24h; a self-level's job lives 24h too) and is dropped, logged only. On a box that isn't secure nothing is held.
  • bmesh sync [--repo R | --all] [--wait 30s] levels this box's checkout of the repo (default: the registered repo of the current directory; --all: every registered repo this box has), which pushes when it is ahead and allowed to, then sends level with origin's head to every roster box whose accept has level and whose verified presence lists the repo, living 24h, and prints each answer that arrives within --wait: k16: fast-forwarded 3, ricks-macbook: held for approval (+1 commit), or for one that hasn't answered ricks-macbook: asleep, queued until <day hh:mm> (no answer in <wait> when its presence is awake). With several repos each line starts with <repo>: . It works the same from any box, since each box judges its own checkout against origin. It needs [hosts] on and the box joined, and is refused for a repo the registry doesn't name or this box has no checkout of.
  • Self-level. Each daemon with [hosts] enabled levels every registered repo it has a checkout of, on start and on each wake, as a fast-forward only, never a push; refusals are logged, not pushed, and holds apply as above. A box that was asleep catches up as soon as it opens.
  • Pushes. A refusal or failure of a received level whose sender stopped waiting (its until has passed: a box that was asleep answering later) is published as bm.daemon.<host>.level (text <host>: <repo> <answer> (job from <sender>)) and pushed once through the push-<seq> claim (title the text, priority high). Two boxes ahead at once leave the second push refused as not a fast-forward, and the phone hears diverged.

The registry. With [hosts] on, repos.toml is the admin's signed document rather than each box's own file: a row's verify is shell every lead runs and bmesh scaffold writes into .bmesh/routines/verify.toml (and bmesh land refuses a branch that changes it there or in AGENTS.md by hand), and its path decides what nix deletes, so no box but the admin can change it on another.

  • It is KV bm-roster, key registry: {type, version, admin_key, rows, issued_at, sig}, signed by the admin key, where rows is the admin's repos.toml as written, comments and all.
  • A box takes a registry only when the admin key it pinned signed it, its version is higher than the box's own, and the box's own loader reads the rows (the same refusals as repos.toml, an unknown key among them). It then writes the rows as its repos.toml (keeping the file it replaces as repos.toml.prev, through a rename) and the document as <state dir>/registry.json, and its hosts layer (presence, levels, prune and nix) reads the new rows at once; the routine loop reads repos.toml at start. A registry it can't read leaves both files as they were: the box publishes bm.daemon.<host>.registry (text <host>: refused: registry v<N> needs a newer bmesh (<error>)), pushed once per version through the push-<seq> claim (priority high), and leaves the newer registry on the hub for the boxes that can.
  • Every daemon reads the registry key each minute (so at start and on wake) and puts its own registry.json back when the hub's copy is missing, unsigned or lower, as it does the roster. A box re-pinned to a new admin starts again from no registry.
  • bmesh repos publish, on the admin box, prints the row diff from the current registry to this box's repos.toml (registry v<N> -> v<N+1>:, then + <name>: <field> <value>, …, - <name> and ~ <name>: <field>: <old> -> <new> lines, or (comments and layout only)), reads the admin key (the announced Keychain dialog: publish registry v<N+1>) and publishes the next version: registry v<N+1> published. The version comes from the box's own registry.json, never the hub's; an unchanged file prints registry v<N>: no change.
  • On any other box, bmesh repos propose [--wait 30s] prints the same diff against the registry it has (proposed against registry v<N>:) and sends the box's repos.toml to the admin box as a propose job (args [<base version>, <rows>], living 24h; the admin box's row must accept propose). The admin's daemon refuses rows it can't read and a proposal against an older version than its registry (refused: proposed against registry v<N>; it is v<M> now: propose again), and otherwise holds it: <state dir>/held/<job id>.json with the row diff, published as held (<admin> holds: registry proposal from <box>, <N> row changes), answered held for approval as <id> (<N> row changes against registry v<base>). A newer proposal from the same box supersedes it. Nothing changes until it is approved: bmesh hosts held lists it (<id> registry proposal, <N> row changes against v<base>, from <box>, expires <day hh:mm>), and bmesh hosts approve <id> at the admin's screen shows the diff, reads the admin key and publishes the proposal as the next version, registry v<N> published: proposal from <box> approved; the proposer hears approved: registry v<N> (<N> row changes). A proposal the registry has moved past since, or one past its job's expiry, is dropped (its rows would undo what came since) and its proposer told so; while the admin's own repos.toml has changes the registry doesn't, which approving would overwrite, the approval is refused and the proposal stays held. bmesh hosts deny <id> drops it as it drops a level's hold.
  • publish, propose and remove first take a newer registry from the hub when there is one; having just replaced this box's repos.toml, they stop there (took registry v<N> from the hub as <path> (your file is kept as repos.toml.prev): bring your edits over and run this again).
  • With [hosts] off, repos.toml is a plain local file, as before.

Pruning. prune [<repo>] is a built-in op (repo a registry name, or "" for every registered repo with a checkout on the box; args ["apply"] to remove, else a dry run). Each box judges its own checkouts by its own nodes:

  • Each linked worktree of the checkout (the main checkout and routine checkouts under [runner] checkouts aside) is a row. One under [runner] worktrees is bmesh's: it is removed when its node (matched by worktree path among this box's nodes) is done, it is clean, and its HEAD is in main (by ancestry, or every commit having an equivalent there, git cherry, as after a rebase) or in its lead's branch; otherwise it is kept, with the reason: no bmesh node, node <state> (idle with no state), uncommitted changes, unmerged. Removing is git worktree remove (never forced), then git branch -d of its branch (-D when the merge was proven by git cherry or the lead's branch). Any other worktree is kept, not bmesh's (T3 cleans its own): on T3 0.1.0 its storage cleanup removes a thread's worktree once it has no commits beyond the default branch, keeping one with uncommitted changes.
  • Then every local bmesh/* branch no worktree holds (T3 leaves a lead's behind) is removed when git branch -d accepts it (merged into its upstream, else into the checkout's HEAD), never with -D; any other is kept, unmerged.
  • With apply it holds the repo's lock (Levelling) while it removes.
  • The answer is a line per row, <repo> <worktree path> <keep|remove|removed|failed> <reason> or <repo> branch <name> <keep|remove|removed> <reason> (a failed removal adds (failed: <error>)), and a summary, <N> worktrees, <B> branches, <M> to remove, <K> kept (applied: , <R> removed, and , <F> failed). A named repo the box has no checkout of answers no checkout of <repo> here.
  • bmesh prune [--repo R | --all] [--apply] [--wait 30s] (default: the registered repo of the current directory) prunes this box first, then sends prune (living 24h) to every other roster box whose accept has it, and prints each box's lines as <box>: <line>, or <box>: asleep, queued until <day hh:mm> for one that hasn't answered. With [hosts] off it prunes this box only. It exits 1 when a removal on this box failed.

Another box's worktree. forget-worktree <node path> runs node forget --worktree's removal on the node's own host, with the same rules (the node done or blocked, the worktree clean, the branch merged by ancestry or git cherry), and on success forgets the node there too: removed worktree <path>; deleted branch <b>; forgot <path>. It refuses refused: no node <path>, refused: <path> is on <host>, not <box> and refused: <path> is <state>; only a done or blocked node is forgotten, besides the removal's own refusals. bmesh node forget <path> --worktree [--wait 30s] of another box's node sends it (living 24h) to that box, which must accept it, waits, and forgets the node only on success, printing <box>: <answer> and forgot <path>; a refusal is printed and exits 3, nothing forgotten. A box that doesn't answer in time prints queued; the node stays until <box> answers: when it wakes it takes the job and forgets the node itself.

Removing a repo everywhere. nix <repo> is a built-in op that needs the admin's signature: its job is signed by the admin key, from the admin box. It carries the repo name and the origin of the repo's row in the admin's registry (args [<origin>], plus ignored-ok), never a path.

  • The path comes from the receiving box's own registry row; a repo with no row there answers absent: <repo> is not in this box's registry, and a row whose checkout doesn't exist absent: no checkout at <path> (its routine checkouts, <[runner] checkouts>/<checkout name>, still go).
  • The box refuses, touching nothing, unless the directory (symlinks resolved) has the row's path's name, is a checkout's top level, has an origin (refused: <path> has no origin) that is the job's (refused: <path>'s origin is <url>, not the registry's <origin>; a GitHub URL matches its owner/name, and a trailing / or .git doesn't count), and the box's own row names no other remote. It also refuses a checkout with uncommitted or untracked changes; a linked worktree (other than a routine checkout) with uncommitted or untracked changes, or whose HEAD is not in main or origin/main (refused: worktree <path> is on <branch>, not merged into main); a commit on no remote (git log --branches --tags --not --remotes, so an unpushed tag too: refused: <path> has commits on no remote (<commit>)); a stash; and ignored files, in the checkout or a linked worktree, unless the job says ignored-ok (refused: <path> has ignored files (<paths>): nix with ignored-ok to remove them too).
  • On success it removes the linked worktrees (git worktree remove), <[runner] checkouts>/<checkout name> and the checkout, under the repo's lock, and answers removed <path> and <checkouts dir>[, with <N> linked worktrees].
  • bmesh repos remove <name> --checkouts [--ignored-ok] [--apply] [--wait 1m], on the admin box, sends nix (living 24h) to every roster box whose verified presence lists the repo (the admin box too; each must accept nix) and prints each answer as it arrives. Only once every one of them has answered removed or absent does it publish the next registry without the row: registry v<N>: <name> removed. Until then it prints pending: <box>, …; the registry keeps <name> until each answers removed or absent (run this again to re-ask them), and keeps the boxes and their jobs in <state dir>/nix/<name>.json; run again, it counts the answers that arrived since and re-asks only the boxes still pending. One read of the admin key signs the jobs and the registry. Without --apply it prints the boxes it would ask and the version it would publish, and asks nobody. It is refused for a row with no remote, and while this box's repos.toml has changes the registry doesn't (publish them first). Without --checkouts it only publishes the registry without the row; with [hosts] off, bmesh repos remove <name> [--apply] takes the row out of this box's repos.toml (keeping repos.toml.prev; --checkouts is refused).

Threat model. Whoever holds the hub token can read every message, delete keys and streams and flood subjects; signatures don't stop that. They stop such a party, or any box not in the roster, from getting a box to act. Revocation is best-effort against a token holder, who can delay a box's view of the newest roster but can't forge one. A box's host.key is readable by its own agents, so a compromised box can act as itself until it is revoked. The admin and approve keys are the secrets no agent can use.

Names

Paths match ^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$; the first token is never inbox, daemon, feedback, host or hostreply, the first tokens of subjects that are not a node's events (routine is for the routine pool: routine.<kind> and routine.<kind>.<instance>), and no token contains -- (the inbox consumer name maps . to --, so a--b and a.b would collide).

A registry name may hold capitals, _ and . (repos.toml), which a path token can't, so every path, family and branch bmesh makes from a repo's name (<repo>-round-<N>, its children's branches, <repo>-<NNN>, <repo>-build-<N>, and the branches build and choose-ideas record on main through) starts with the name's node token: lower case, . and _ written as -, runs of - collapsed to one and a trailing - trimmed (MapleMarketScanner is maplemarketscanner, my_repo.js is my-repo-js). A node's repo and the worktree directories keep the registry name. Two names with one token (Foo and foo, my.app and my_app) would share those paths, so bmesh init, repos publish and repos propose refuse such a registry (exit 3, bmesh: repos Foo and foo are both foo in node paths; rename one).

Kind Name Subjects / keys
stream bm-mesh bm.> (30 days, file store, 2m duplicate window)
event subject bm.<path>.<event>
inbox subject bm.inbox.<path>
daemon subject bm.daemon.<host>.<event> (started, update, wake, silent, digest, push, push-failed, refused, held, level, registry)
consumer per inbox inbox-<path, "." as "--"> filter bm.inbox.<path>
kind inbox subject bm.inbox.routine.<kind> (jobs and gos)
consumer per kind inbox-routine--<kind> filter bm.inbox.routine.<kind>, shared by every box that runs the kind; MaxAckPending -1 (each box fetches up to its own instance count), AckWait = timeout + 1m (the last writer's)
routine event subject bm.routine.<kind>.<instance>.<event> (started, progress, done)
feedback subject bm.feedback.<family> (a build family's pending feedback; no consumer, drained by purge)
host job subject bm.inbox.host.<host> (signed jobs for a box; see Hosts)
consumer per host inbox-host--<host> filter bm.inbox.host.<host>; MaxAckPending 1, AckWait 10m
host reply subject bm.hostreply.<caller>.<job id>
join subject bmhosts.join.<id> (core NATS, outside the stream: an invite's request and reply)
KV bm-nodes <path> to Node (a build's lead carries its family; a node whose runner named its own thread, as T3 does, carries the id bmesh chose as runner.ref: the briefs' proposed_by and the spec's built_by)
KV bm-state <path> to State
KV bm-runner <path> to RunnerSample
KV bm-results <kind>.<tree>[.<args8>][.<model-short>] to Result ([routines] keep_results, default 30 days)
KV bm-leases <slot>.<n> to {"holder","expires_at"}
KV bm-roster roster to the admin's signed roster, registry to its signed registry (Hosts)
KV bm-hosts <host> to that box's signed presence

The map, watch, status, the push loop and the activity reader skip the same subjects (mesh.IsMeshInternal: first token inbox, daemon, feedback, host or hostreply, each keeping what it reads of inbox and daemon events); host traffic (bm.inbox.host.*, bm.hostreply.*) never reaches the map or watch. | KV | bm-claims | quota.<provider>.<reset unix> to the claiming daemon (daemon.<host>); age limit 8 days | | KV | bm-jobs | <kind>.<host> to {"kind","host","at","jobs":[{"seq","job","from","holder","state","since"}]} (that daemon's held and running jobs of the kind) | | KV | bm-holds | <path> to {"gate","text","by","at"} (a coordinator's hold on a lead, until unhold; see Holds) |

JetStream is addressed through the domain from [mesh] domain (default hub).

Config

Three files in the config dir (<os.UserConfigDir()>/bmesh: ~/.config/bmesh on Linux, and anywhere that directory already exists; BMESH_CONFIG overrides the config file's path, and the other two live beside it): config.toml, this box's settings; repos.toml, the registry of repos, the same on every box; and the setup flag .setup-complete, written by bmesh init (see Quickstart).

bmesh config check loads this box's config.toml and repos.toml with this binary, as every command does, and prints config ok: <path>, or refuses (exit 3) with config check failed: <why>, naming the key. bmesh serve (the supervisor included) and bmesh hub run the same check at start: on a refusal they print bmesh: config check failed: <why> and exit 78 (EX_CONFIG), which the units init --service writes don't restart on (Quickstart).

repos.toml

One [repos.<name>] table per repo (a name matches ^[A-Za-z0-9][A-Za-z0-9_.-]*$: a directory name, so a checkout keeps its name; quote one holding a dot, [repos."my.app"]); every key with its default:

[repos.party-buzzer]
path = "~/repos/party-buzzer"           # required; the checkout on this box (~ expands; a relative path is from the config dir)
mode = "ship"                           # ship | companion | publish | fork | dormant
remote = ""                             # owner/repo, or a URL; for people and the table
host = ""                               # the box proposal threads run on ("" = this one)
ux_host = ""                            # the box UI/UX threads run on
verify = []                             # the shell commands that must pass before a change is done
surfaces = []                           # the UI screens a UI/UX thread screenshots, with their viewports
pin = ""                                # a release tag every checkout stays on, instead of following a branch
routines = ["verify", "review"]         # the routine kinds a build runs

An unknown key, a row without path or a mode outside the five is an error for every command, since one loader reads it for all of them. [repos] in config.toml, where the registry used to be, is refused with a message naming repos.toml. bmesh lead start --repo, routine queue and the daemon resolve repo names through it.

bmesh repos [--json]               # the table: name, mode, remote, verify, host, ux_host, surfaces, pin (sorted by name)
bmesh repos show <name> [--json]   # one row, key: value lines
bmesh repos publish                # (admin, [hosts] on) publish this file as the next registry version
bmesh repos propose [--wait 30s]   # ([hosts] on) send this file's changes to the admin box, held for approval
bmesh repos remove <name> [--checkouts [--ignored-ok]] [--apply] [--wait 1m]   # take a row out (see Hosts)

With [hosts] on, this file is the admin's: each daemon writes it from the newest registry the admin signed (keeping the old file as repos.toml.prev), so a change made here reaches the other boxes through bmesh repos publish on the admin box, or bmesh repos propose anywhere else, and is overwritten by the next registry otherwise (see Hosts: The registry).

--json prints the rows as an array (show: one object) of {"name","path","mode","remote","host","ux_host","verify":[],"surfaces":[],"pin","routines":[]}, defaults filled in, for other tools to read. show of an unknown name exits 3. bmesh init adds the current directory's checkout as a row (path and its GitHub origin as owner/repo) unless it is there already, and rewrites the file whole, so a comment in it is lost.

config.toml

Keys and defaults. A key bmesh doesn't know is ignored; [runner] t3_dispatch and t3_project, which the t3 runner read before T3 0.1.0, load with one line each on stderr, bmesh: [runner] t3_dispatch is ignored (T3 0.1.0 needs no script); delete it from <path>.

[mesh]
nats = ""                                   # "" = the server bmesh serve embeds on listen, which every other command dials; else e.g. "nats://localhost:4222"
domain = "hub"
token_file = ""                             # "" = no token
store = "<cache dir>/bmesh/jetstream"       # embedded server's store (os.UserCacheDir: ~/.cache on Linux, ~/Library/Caches on macOS); an existing ~/.local/state/bmesh/jetstream is kept
listen = "127.0.0.1:4222"                   # embedded server's bind
[host]
name = ""                                   # "" = `hostname -s`; also the name of the server bmesh hub runs
[hub]                                       # the server bmesh hub runs on this box (see Several boxes)
role = ""                                   # "" = no hub here (one box: serve embeds it; or a client of another box's hub) | "hub" | "leaf"
listen = ""                                 # hub: "<tailnet ip>:4222"; leaf: "127.0.0.1:4222"; never every interface
leaf_listen = ""                            # hub only: "<tailnet ip>:7422", the port leaves dial; "" = none
remote = ""                                 # leaf only: "nats://<hub tailnet ip>:7422", redialed forever
store = ""                                  # hub only; "" = <state dir>/hub/jetstream
max_file_store = "8GB"                      # hub's JetStream limits, as nats-server reads sizes (GB/MB powers of 1024, G/M of 1000)
max_memory_store = "256MB"
[serve]
listen = "127.0.0.1:3774"
[runner]
kind = "t3"                                 # claude | t3 | tmux (init writes claude when claude is on PATH)
t3_server = "http://127.0.0.1:3773"         # this box's T3 0.1.0; "" is a config error
t3_runtime_mode = "auto"                    # auto | full-access: the mode t3 threads start in
tmux_session = "bmesh"
worktrees = "~/.bmesh/worktrees"            # executors' worktrees, and the claude and tmux runners'
checkouts = "~/.bmesh/checkouts"            # routine instances' checkouts; "" = routines off
claude = "claude"                           # claude command for model routines and the claude runner, split on spaces
max_leads = 0                               # leads at once on this host (lead start refuses past it); 0 = no cap
[notify]
ntfy = ""                                   # "" = no digest; else "http://k16:2586/bmesh"
command = ""                                # test hook: receives the POST body on stdin instead of HTTP
digest_every = "1h"                         # command runs through the platform shell, body on stdin, title in $BMESH_TITLE (and $NTFY_TITLE)
push = ["ask", "blocked", "done"]           # a lead's (and a round's) events pushed at once (only these three; another entry is a config error); [] = no pushes
map_url = ""                                # the map's URL for a push's click-through (<map_url>#/lead/<path>); "" = none
catch_up = "1h"                             # on start, push missed events no older than this
[liveness]
grace = "20m"
poll_every = "60s"
wake_every = "10m"                          # an idle lead (or engineer) with unread mail is woken at most this often
[spec]
glob = "docs/proposals/{id}-*.md"
[models]
lead = "claude-opus-5-5"                    # lead start's model when --model names none (build passes the spec's judge)
lead_effort = "high"                        # a lead's effort: low, medium, high, xhigh or max; "" = the runner's default
proposer = "claude-opus-5-5"                # a proposal round's two threads
proposer_effort = "xhigh"                   # their effort
[status]
keep_done = "24h"                           # mesh-status leaves out nodes done longer than this, and the daemon deletes stopped routine instances' nodes after it; "0" = never. Durations take whole days too: "30d"
[routines]
keep_results = "30d"                        # bm-results' age limit, applied by serve; an older result is gone; "0" = none
[quota]
command = ""                                # the map's quota strip; "" = no strip. Run through the platform shell at most every 5m (30s timeout); prints a JSON array of {"reading":{"provider","five_hour","week","five_reset","week_reset"}}
remind_topic = ""                           # the ntfy topic a used 5-hour window's reset reminder goes to; "" = none (see Quota reminders); needs command and [notify] ntfy
[console]
thread_url = ""                             # the map's thread link for leads on this host, {thread} = the lead's runner thread, path-escaped (mcp:x is mcp%3Ax); "" = this host's [console.hosts] entry, else no link
[console.hosts]                             # host = template, for leads on other hosts (e.g. k16 = "http://k16:3773/…/{thread}")
[update]
repo = ""                                   # registry name of the repo whose main the daemon rebuilds itself from, at what track follows; "" = off (see Updates)
check_every = "6h"                          # without repo, how often the daemon checks what track follows on the module's main and writes the notice when behind; "0" = never
upstream = false                            # without repo, also install the target when there is a newer one (go install), drain and re-exec; on Windows only under serve --restart
track = "release"                           # what every update follows on main: release = the newest vX.Y.Z tag (the code head until there is one), latest = the code head (newest commit outside the spec directory); bmesh update --latest goes there once
[hosts]                                     # written by bmesh init --hosts (see Hosts)
enabled = false                             # nothing of the hosts layer runs unless true
admin = false                               # this box is the roster's admin
secure = false                              # what others ask of this box waits for the operator
key_store = ""                              # keychain | file: where the admin or approve key lives; "" = keychain on macOS, file elsewhere
push_modes = ["ship", "fork"]               # the registry modes whose rows a level pushes when this box is ahead (see Hosts)

The state dir, where the daemon keeps what must survive a restart (the T3 sign-in's push record, roster.json, the salt records, the holds in held/ and the per-repo locks in locks/), is $XDG_STATE_HOME/bmesh when XDG_STATE_HOME is set, else ~/.local/state/bmesh on Linux and ~/Library/Application Support/bmesh/state on macOS: never the cache dir, which macOS purges.

A digest is also published as a digest event on bm.daemon.<host>.digest, with the title as text and the body as body.

A wake is the text the daemon sends to the thread of a lead (or engineer) on this host whose runner is idle and has unread mail (a done or blocked one only when that mail holds a message sent with bmesh send, so the operator can ask a finished lead something; a late report or reply doesn't wake it), and to a silent one whatever its mail, at most once per wake_every; each is published as a wake event on bm.daemon.<host>.wake. It names what waits: bmesh: 3 unread: done from app-012.alpha (branch t3code/x-alpha at 1a2b3c4d); reply to job <id> (review fail); message from operator. Run bmesh inbox now, then continue. A report is done from <path> (branch <b> at <sha8>) or blocked from <path>, a reply reply to job <id> (<kind> ok|fail), anything else <kind> from <from>; up to five items, oldest first, then and N more, and the count alone (bmesh: 3 unread. Run ...) when there is nothing to list or the read fails. The daemon reads them only once the wake is due, from the inbox subject in the stream after the consumer's ack floor, never fetching, so none of the lead's messages is held or acked by it. The count is the consumer's unread count; a reply routine wait already acked (see Jobs) can sit above the floor, so the list can be longer than the count.

Pushes

A daemon with [notify] ntfy or command set also pushes a lead's ask, blocked and done (the push list) to the same topic at once: title <path> asks, <path> is blocked or <path> is done; priority high for an ask or a block and default for done; tags question, no_entry, white_check_mark; the body is the event's text cut to 3 KB, plus commit <sha8> for done; a tap (Click) opens the lead's thread when [console] resolves it, with an Open map action for its map page when map_url is set; with no thread the tap opens the map page. The phone must reach both links, so on a tailnet map_url and the [console] templates use the full MagicDNS name (http://<host>.<tailnet>.ts.net:3773/…), not the short host name. A round's done and blocked, and an engineer's ask, push as Proposal rounds says. Executors' and routines' events never push. The T3 sign-in's expiry pushes are described under Runners, and with [hosts] enabled a box's refusals push as Hosts says (even with push = []). With command set, the body goes on stdin and the headers in $NTFY_TITLE, $NTFY_PRIORITY, $NTFY_TAGS, $NTFY_CLICK and $NTFY_ACTIONS.

Each event is pushed once across restarts and daemons: before posting, a daemon claims it by publishing bm.daemon.<host>.push (to the lead, text the title, seq the event's stream sequence) with Nats-Msg-Id push-<seq>, and a duplicate reply means another daemon has it; on start a daemon pushes the listed events no older than catch_up (by the event's at, else its stored time) that have no push claim yet, in order, then follows live. A live event stored over a minute ago (the backlog a reconnect to the hub delivers) gets the same rules: it is dropped when older than catch_up or when any daemon's push claim names its seq. A push that still fails after retries at 5 s and 30 s is logged and recorded as push-failed; the claim stands, so it is never repeated. At shutdown the push being posted gets 10 s to finish; any claimed push still queued or waiting to retry is logged and recorded as push-failed with body shutdown.

Quota reminders

With [quota] remind_topic set, the daemon reads quota once an hour (on start, then hourly) through the same gate as the map's strip, so [quota] command still runs at most every 5 minutes whoever asks. For each provider whose 5-hour window has usage (five_hour above 0) and whose five_reset, rounded to the minute, is still ahead, it posts one message to [notify] ntfy's URL with the last path segment replaced by the topic (http://127.0.0.1:2586/bmesh and quota-reset give http://127.0.0.1:2586/quota-reset): title Claude 5-hour quota reset (the provider's name, capitalised), body Claude's 5-hour window is fresh. Weekly was 41% used., tag hourglass, and an At header of the rounded reset, so ntfy delivers it when the window resets (no At when the reset is under 30 s away, since ntfy refuses a shorter delay: it is posted at once). The reset is rounded because a provider's reset time jitters by fractions of a second between readings. Each window is posted once across daemons and restarts: before posting, a daemon creates quota.<provider>.<rounded reset unix> in bm-claims, and a key already there means another daemon (or this one, an hour ago) has it. A post that fails deletes its claim and is logged, so the next reading tries again.

Map

The map (/) draws one block per lead, a tree of the lead and its executors (a round and its engineers draw the same way), then the orphans block, then the routines column (last on the phone, where blocks stack). Blocks run in need order: blocked, asking, silent, then working, then held, then idle (no state yet), then done; ties keep started_at order, so a running lead keeps its place. When at least one lead is live (any state but done) and at least one is done, the done leads leave the tree and fold into a shelf under the live blocks: a row of chips (buildermesh-001 ✓, newest first) headed done (N), each a link to its lead page. With no live lead, done leads draw as trees. A lead's state is its own: a live lead whose executors are all done still draws as a tree, and a done event moves the lead to the shelf on the redraw the map does for done.

Under the header the strip leads with a summary row computed from the loaded tree on every render, over leads, rounds and their children (not routines): 2 working · 1 asking · 1 silent · 2 held · 3 done, in that order (working, asking, blocked, silent, held, done), zero counts dropped, each word coloured as its badge, all idle when none of the six has a node. A live event that restyles a node updates it at once. The strip shows whenever the tree has nodes, not only when there is a quota or a digest. Below the row come the quota lines, then the newest digest as a fold (▸ digest 50m ago: <title>) that opens to the digest body: the daemon's hourly wording, not the present. Under ?check=1 the page's title strip= counts the strip's rows, the summary row among them, held= counts the held boxes on the map and bandHeld= the held rows in the needs-you band (always 0).

The recent-events drawer draws each row's node as a chip in its lead tree's hue (a hash of the path's top segment over eight hues; an executor is a lighter step of its lead, routines and daemon.* grey), labelled without the prefix of an ancestor that has rows of its own (eng, its full path in the tooltip); an inbox row's chip is the sender's, then → and a small chip for the recipient. Each node with rows takes a lane, in order of first appearance from the newest row, 14 px apart with a faint line in its hue down the list, its chip at the lane's offset; past six lanes (three under 700 px) the rest share the last, its line grey. Hovering a chip or its line dims every row but that node's and those addressed to it and brightens its line; clicking a chip (a tap on touch) pins that focus across new events until it is clicked again or Escape is pressed, and clicking another chip moves the pin.

Above the map, a "needs you" band shows whenever a lead, a round or an engineer is asking, blocked or silent: one row per node with its state badge and path (a link to its lead page) and the state's words, the ask or the block text, or for a silent node the daemon's verdict (quiet for 20m) and how long ago it was given. Rows run in need order (blocked, asking, silent); a row leaves on the node's next started, progress or done, as a live event or on the next read of the tree. On the map an asking or blocked box's second line is the state followed by as much of the words as the box fits, instead of its unread count; a silent box draws at full opacity with a dashed stroke in the blocked colour. A held box (a coordinator holds the lead at a gate) draws a badge in its own calm slate colour with a solid stroke and, like asking, its words as the second line; it is counted in the strip and never has a band row, since the coordinator put it there. node scripts/check.mjs exits 1 when the title's bandHeld= is non-zero. The band's words, the recent-events drawer's rows and the lead page's inbox and timeline rows clamp to three lines and open on a tap; the lead page's timeline names a child by its last segment (eng, not buildermesh-round-1.eng), the full path in the row's tooltip.

The map's lead page (#/lead/<path>, served for any node by /api/lead/<path>) reads the lead's spec from its registered repo's local origin/main (main without an origin) and never fetches in the request; the daemon fetches origin main in the background at most once a minute per repo while lead pages are being viewed, so a spec shows up to a minute or two after it lands. /api/lead/<path> carries children, [{node, state, unread}]: the node's direct children in the tree /api/tree draws (a lead's executors, a round's engineers); and, for a round, specs, [{id, title, status, text}]: the specs on that same origin/main whose proposed_by is one of its children's runner threads (or a child's runner ref), by id ([] before any land; null for any other node), the ledger cached for a minute per repo like the spec and a file that doesn't parse left out. The page's main column opens with a tree card, one row per child (state badge, last name of its path, branch, N unread), each a link to the child's own page, absent when there are no children; then a lead's spec card or a round's specs card, one NNN title · status line per spec that opens its body in place (no specs landed yet before any land). A card with nothing to show (no spec, no messages, no events) is one muted line, and in the page's one-column layout (narrower than 1000px) it sorts after the cards with content. Under ?check=1 the page's title also names its firstCard, the card drawn highest.

Exit codes

Code Meaning
0 ok
1 error; a failed run (routine run, routine wait, an inline kind land runs); a lost lease; an interrupted lock-lease run; a malformed spec, or a spec set its --from refuses
2 usage; a scaffold of a repo without a row or whose row is not ship
3 refused (a command other than init and help on a box not set up, run outside a terminal; a repos show of an unknown name; a done the spec or its verified line doesn't support, a reserved or taken path, a send to a running thread, an executor name or branch in use, an executor remove of an unmerged branch, a dirty worktree for a deterministic inline run, an unknown kind, a fresh or pooled kind run inline without --here, a routine run of a kind whose os is another OS's, a routine queue of such a kind from a commit on no origin branch, a routine queue whose commit gives the kind an os that main doesn't (or main lacks the kind or can't be read), an inline run of a commit other than HEAD, a busy slot, a lease held by someone else, an update whose main lacks this binary's revision or whose build reports another, no result for result find or spec verified; an ideate on a non-ship row, with five proposed specs, with a round still open and no --force, an unreachable host, a live child or an existing branch; a main that ideate, build or choose-ideas cannot level with origin or fast-forward; a build of a non-ship row, of a spec that is not accepted, with no judge and no --model, of a taken path or on a host at its lead cap; a release held by a working lead or pending feedback, at a red code head, or with only specs changed since the last release; a second review job for a reviewed branch without --again; a land from a dirty worktree or off a branch, with main ahead of origin, a rebase that conflicts, nothing to land, a spec not building, a required result missing or failing, or main moving twice; a lead start, build or ideate with the t3 runner without a sign-in that serves it, a t3 login whose ceiling is below t3_runtime_mode or a t3 login --status with no live sign-in; a hub whose [hub] role, listen, leaf_listen or remote, or [mesh] token_file, won't do (the message names the key), a serve with [hub] role set and [mesh] nats empty; a config check of a config this binary refuses; a hosts or sync command on a box with [hosts] off or not joined, a sync of a repo the registry doesn't name or this box has no checkout of, a hosts approve or deny of a hold this box doesn't have, an approval outside a terminal, without an approve key in the roster, with no daemon answering or that the daemon didn't level, an admin command (`hosts invite
5 timeout (watch, routine wait, t3 login, a land waiting for its lease)
78 config: serve or hub refused this box's config at start, naming the key (Config); their units, and serve --restart, don't start it again

lock-lease run exits with its command's code.

License

MIT.

Documentation

Overview

Package buildermesh embeds the files of this repo that the bmesh binary hands to other repos: the paragraph a repo's AGENTS.md gets, the README of a proposals directory and the example routine kinds. They stay in the repo for reading and are embedded from there, so the two never differ.

Index

Constants

This section is empty.

Variables

View Source
var AgentsSnippet string

AgentsSnippet is docs/agents-snippet.md: the buildermesh paragraph for a repo's AGENTS.md, with its bracketed verify placeholder.

View Source
var Examples embed.FS

Examples holds examples/routines/<kind>.toml, the kinds a repo is seeded with.

View Source
var ProposalsReadme string

ProposalsReadme is docs/proposals/README.md: what a proposals directory holds and the lifecycle of a spec.

Functions

This section is empty.

Types

This section is empty.

Directories

Path Synopsis
cmd
bmesh command
Command bmesh is the buildermesh CLI: nodes report events, message each other and watch the mesh through it.
Command bmesh is the buildermesh CLI: nodes report events, message each other and watch the mesh through it.
internal
brief
Package brief renders the briefs the mesh hands its threads: the engineer and UI/UX engineer briefs of a proposal round, the build brief of a lead and the half-pager a spec is written as.
Package brief renders the briefs the mesh hands its threads: the engineer and UI/UX engineer briefs of a proposal round, the build brief of a lead and the half-pager a spec is written as.
checkout
Package checkout makes and moves the clean detached worktrees that routine instances run in.
Package checkout makes and moves the clean detached worktrees that routine instances run in.
config
Package config reads bmesh's config.toml: schema, discovery and defaults.
Package config reads bmesh's config.toml: schema, discovery and defaults.
daemon
Package daemon holds the bmesh daemon's loops: it keeps each lead's runner sample current, wakes idle leads with unread mail, marks quiet nodes silent, and runs the routine kinds of the repos in [repos].
Package daemon holds the bmesh daemon's loops: it keeps each lead's runner sample current, wakes idle leads with unread mail, marks quiet nodes silent, and runs the routine kinds of the repos in [repos].
hosts
Package hosts is the mesh's membership and proof: the admin's signed roster, signed jobs and replies between boxes, presence, the one-time invite and the keys behind them.
Package hosts is the mesh's membership and proof: the admin's signed roster, signed jobs and replies between boxes, presence, the one-time invite and the keys behind them.
instance
Package instance runs routine jobs: a shell command, or a claude -p session that persists across jobs.
Package instance runs routine jobs: a shell command, or a claude -p session that persists across jobs.
kind
Package kind reads routine kind files: the TOML a repo keeps under .bmesh/routines/ to say how a routine runs.
Package kind reads routine kind files: the TOML a repo keeps under .bmesh/routines/ to say how a routine runs.
level
Package level brings one checkout level with its origin (020): it fetches, judges the checkout against origin/<branch> (or a pinned tag), fast-forwards a box that is behind, pushes one that is ahead when the push gate allows, and refreshes the plugins sessions load from it.
Package level brings one checkout level with its origin (020): it fetches, judges the checkout against origin/<branch> (or a pinned tag), fast-forwards a box that is behind, pushes one that is ahead when the push gate allows, and refreshes the plugins sessions load from it.
mesh
Package mesh holds the NATS names, paths and clients of the bmesh mesh.
Package mesh holds the NATS names, paths and clients of the bmesh mesh.
meshtest
Package meshtest starts an embedded NATS server for tests.
Package meshtest starts an embedded NATS server for tests.
nix
Package nix removes a repo's checkout from a box (023): the admin's signed nix job names a repo and the origin its registry row has, the box takes the path from its own registry row and refuses, touching nothing, anything whose loss couldn't be undone from a remote.
Package nix removes a repo's checkout from a box (023): the admin's signed nix job names a repo and the origin its registry row has, the box takes the path from its own registry row and refuses, touching nothing, anything whose loss couldn't be undone from a remote.
proc
Package proc puts a command in its own process group and kills the group, so no descendant outlives a run: Setpgid and kill(-pid) on Unix, a new process group and taskkill /T on Windows.
Package proc puts a command in its own process group and kills the group, so no descendant outlives a run: Setpgid and kill(-pid) on Unix, a new process group and taskkill /T on Windows.
prune
Package prune removes the worktrees and branches a finished node leaves behind on this box: one node's (`node forget --worktree`, the forget-worktree op) or a whole checkout's (the prune op).
Package prune removes the worktrees and branches a finished node leaves behind on this box: one node's (`node forget --worktree`, the forget-worktree op) or a whole checkout's (the prune op).
runner
Package runner starts and observes the agent sessions behind nodes: a T3 Code thread (T3), claude in a tmux window (Tmux) or headless claude -p turns (Claude).
Package runner starts and observes the agent sessions behind nodes: a T3 Code thread (T3), claude in a tmux window (Tmux) or headless claude -p turns (Claude).
runner/t3mcp
Package t3mcp speaks to T3 Code's MCP endpoint (/mcp: streamable HTTP, JSON-RPC, the mcp-session-id header) and holds the sign-in that endpoint needs: the OAuth login that mints a token and the file it is kept in.
Package t3mcp speaks to T3 Code's MCP endpoint (/mcp: streamable HTTP, JSON-RPC, the mcp-session-id header) and holds the sign-in that endpoint needs: the OAuth login that mints a token and the file it is kept in.
runner/t3mcp/t3fake
Package t3fake is an httptest T3 Code server for tests: the environment document, the MCP OAuth endpoints and the MCP tools the t3 runner calls, answering with the shapes recorded from a T3 0.1.0 server (kept in testdata/t3v2).
Package t3fake is an httptest T3 Code server for tests: the environment document, the MCP OAuth endpoints and the MCP tools the t3 runner calls, answering with the shapes recorded from a T3 0.1.0 server (kept in testdata/t3v2).
scaffold
Package scaffold gives a ship repo what a buildermesh build expects of it: the "Definition of done", "Proposals" and "Buildermesh" sections of its AGENTS.md between markers, docs/proposals/README.md and assets/.gitkeep, and the routine kinds its registry row names, seeded from the embedded examples.
Package scaffold gives a ship repo what a buildermesh build expects of it: the "Definition of done", "Proposals" and "Buildermesh" sections of its AGENTS.md between markers, docs/proposals/README.md and assets/.gitkeep, and the routine kinds its registry row names, seeded from the embedded examples.
selfupdate
Package selfupdate builds bmesh from a checkout of its repo's main and swaps the result in for a binary, keeping the one it replaces beside it.
Package selfupdate builds bmesh from a checkout of its repo's main and swaps the result in for a binary, keeping the one it replaces beside it.
shell
Package shell runs a command line through the platform's shell: sh on Unix, cmd on Windows.
Package shell runs a command line through the platform's shell: sh on Unix, cmd on Windows.
spec
Package spec finds a spec on the project's main branch and reads its frontmatter, with the same rules as tools/proposals.py.
Package spec finds a spec on the project's main branch and reads its frontmatter, with the same rules as tools/proposals.py.
upgrade
Package upgrade reads a release's upgrade notes, docs/upgrading/<tag>.toml in bmesh's own repo: the steps an install from the release before needs, distilled by the coordinator from the Upgrading sections of the specs the release carries.
Package upgrade reads a release's upgrade notes, docs/upgrading/<tag>.toml in bmesh's own repo: the steps an install from the release before needs, distilled by the coordinator from the Upgrading sections of the specs the release carries.
web
Package web serves the map: a page, the JSON it reads (the node tree, a lead's page, the routines and the strip) and the mesh's events as server-sent events.
Package web serves the map: a page, the JSON it reads (the node tree, a lead's page, the routines and the strip) and the mesh's events as server-sent events.
scripts
seed command
Command seed fills a scratch mesh (scripts/seed.sh runs it against a throwaway bmesh serve) with every state the map draws: two live leads and their executors (working, asking, done and one idle), a held lead, a done lead for the shelf, a silent lead, an orphan, a round and its two engineers (one blocked), a pooled routine kind with instances in two repos and three spent fresh runs, a queued job, a few events and messages, two leases, a result and a daemon digest, and writes the quota fixture the seeded config's quota command reads (-quota).
Command seed fills a scratch mesh (scripts/seed.sh runs it against a throwaway bmesh serve) with every state the map draws: two live leads and their executors (working, asking, done and one idle), a held lead, a done lead for the shelf, a silent lead, an orphan, a round and its two engineers (one blocked), a pooled routine kind with instances in two repos and three spent fresh runs, a queued job, a few events and messages, two leases, a result and a daemon digest, and writes the quota fixture the seeded config's quota command reads (-quota).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL