balls — the Branching Agent Labor and Logistics System — is a git-native task tracker for the point where you stop running one agent and start running a fleet.
That is where ordinary trackers come apart. Two agents claim the same task and overwrite each other's work. A third stops one commit short of done and reports success. main's history turns into a slurry of bookkeeping commits braided through real code. And the tracker that was supposed to coordinate all of this wants a daemon, a second database, or a network round-trip on every read — so the whole fleet stalls the moment one of them is unreachable.
balls is built so none of those failures have anywhere to take root. Tasks are markdown files (TOML frontmatter) committed to dedicated git branches, and nothing touches main — that property is structural, not a convention: base balls never opens your project repo, so it cannot leave a commit there. Every claim hands the agent its own git worktree, so parallel work is isolated by construction, not by etiquette. Status is derived, never stored — claimed, blocked, and ready are computed from a single occupancy field, so two agents can't hold conflicting views of who owns what. And every operation is atomic — a claim, a close, a sync either lands whole or leaves nothing to repair. An interrupted op's entire recovery protocol is to run it again: it detects whatever already landed and converges onto it, so a crashed agent never wedges the store or strands a half-merge for anyone to untangle. Because the whole system rides one VCS — fetched by git fetch, synced by git push, its history read by git log — there is no database, no daemon, no external service to run, nothing that can be down when the work needs it. balls works offline, and it degrades gracefully: strip every plugin and it is a pure local task list any collaborator can read, diff, and hand-edit with stock git.
The CLI is bl. It runs the full spectrum — from one operator keeping a standalone backlog (no fleet, no codebase; the whole thing is task files you could keep by hand with vi and git) through a single developer driving a dozen agents, up to an entire team running enterprise workflows and external integrations across many machines — on the same two branches the whole way up.
Companion documents go deeper.
SKILL.md(bl --skill) is the operating guide for an agent drivingbl— its command map points to each command's ownbl <cmd> --skillfor full usage.docs/architecture.mdis the frozen design reference (§0–§16) — the authority for every claim in this README.docs/release-notes-greenfield.mdnarrates the greenfield (0.x) model and what changed from legacy;docs/demonstration.mdis a captured end-to-end proof run against the shipped binary. This file is the introduction.
One agent takes a task all the way through: bl claim → work → bl close → done. There is no review step and no separate reviewer: claiming gives you a code worktree, and bl close delivers it (squashes your work to main) and tears the worktree down in one move. Balls does not assume a separate reviewer; if you want a split submit/approve flow, add a review gate as an ordinary close-blocker subtask (or a forge plugin that mints one at claim) — submission itself is git-native work, never a close phase. Otherwise the agent that claims also closes — which keeps agents from stopping short of finishing, the single most expensive failure mode in an agent-driven workflow.
Balls ships as a small Rust binary bl plus three sibling plugin binaries (bl-tracker, bl-delivery, and the opt-in bl-chore). The only runtime dependency is git.
git clone https://github.com/mudbungie/balls.git
cd balls
make install
make hooks # one-time per clone: install the repo-local pre-commit hookmake install builds release binaries and installs four executables to ~/.local/bin/: bl (core, plus a balls alias symlink), bl-tracker, bl-delivery, and bl-chore. Wiring is by name: the hook schedule (config/plugins.toml) lists plugin names, and bl prime/bl install bind each name to the binary of that name installed beside bl — a local, gitignored config/plugins/bin/<name> symlink that dispatch then resolves (§6). The seed wires bl-tracker and bl-delivery; bl-chore installs beside them but stays dormant until you schedule it (opt-in — see Plugins). A core-only install leaves bl prime founding a stealth, plugin-less task list: remotes and code worktrees silently never engage. Install the scheduled plugins beside bl and they wire themselves. Make sure ~/.local/bin is on your PATH.
make hooks wires the repo-local pre-commit hook (clippy, 300-line cap, tests, 100% coverage, warning-clean rustdoc). Run it once per clone; it is not part of make install because a user installing the binary should not have hooks attached to whatever repo they happen to be in. The coverage check requires cargo install cargo-tarpaulin.
make doc is the blessed documentation build: cargo doc --no-deps --document-private-items with RUSTDOCFLAGS=-D warnings. It is the only invocation the docs are guaranteed clean under, and it is what the pre-commit hook and CI run, so a broken intra-doc link is a build failure rather than a line in the scroll. The docs deliberately link to private items — a module note points at the private helper carrying the reasoning — which is why the blessed build documents them; a single crate-level #![allow(rustdoc::private_intra_doc_links)] in src/lib.rs states that, and nothing else is exempted.
To remove everything make install placed:
make uninstallcargo install ballscargo install places bl in ~/.cargo/bin/. To get the plugins beside it, build and copy bl-tracker, bl-delivery, and bl-chore next to bl (or use the source install above).
cargo install cargo-zigbuild
cargo zigbuild --release --target x86_64-unknown-linux-gnu
cargo zigbuild --release --target aarch64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-apple-darwin
cargo zigbuild --release --target aarch64-apple-darwinbl --version
cd your-repo
bl prime --as you # founds the substrate on first run
bl create "My first task"
bl listBalls is MIT licensed. See LICENSE.
State lives on two branches of your repo, each with one job and its own transport discipline:
balls/config— the landing. Holdsconfig/(this checkout's config: which plugins run, where the store syncs from). Path-derived, single-owner, never pushed by balls.balls/tasks— the store (default name;config.tasks_branchnames it). Holdstasks/<id>.md, one file per task. Shared, sync-merged.
config/ and tasks/ are top-level folders on both branches always, so the code reads config from the landing ref and tasks from the store ref with no special-casing of what else a branch carries. tasks_branch can never name the landing, though: the two checkouts are worktrees of one repo, and git refuses a branch checked out twice, so the coincident name is refused outright. "Nothing on main" is structural: base balls never opens the project repo, so it cannot leave a commit there. The split exists because config and tasks have opposite transport disciplines — config is install-replaced (destructive, no merge), the store is sync-merged (union, fast-forward-only) — and one ref cannot carry both safely.
balls does not keep its checkouts in your project tree. Per invocation path, the landing and store live under $XDG_STATE_HOME/balls/clones/<percent-encoded-path>/ as config/ and tasks/. Code worktrees live in the delivery plugin's territory at $XDG_STATE_HOME/balls/plugins/<delivery>/<project-path>/<id>/ — the project path is mirrored there, not percent-encoded, so the build dir carries no % (which would break cargo/rust-lld linking; the clones/tracker dirs hold only git data, so they keep percent-encoding). You rarely touch these directly — the verbs read and write them — but that is where git log/git show of task history lives.
"Per invocation path" means the literal directory you ran in — balls never walks up looking for a project root, so a subdirectory addresses a different (usually empty) store. Every command takes a global -C PATH that names the directory outright: bl -C ~/dev/proj list reads the project's store from anywhere, including from inside a work/<id> worktree. It resolves nothing on its own — no walking, no git detection; a PATH that is not an existing directory is refused. bl prime still founds a fresh store on a subdirectory miss (a deliberate nested/sibling store is a supported use case), but if an ancestor directory already carries a founded store it warns on stderr naming it — cd there, or use -C — before founding here anyway; a re-prime of an already-founded checkout stays silent.
A task has no status field. The three live states are computed on read:
- claimed — the
claimantfield is set (someone holds it). - blocked — unclaimed, but an unresolved
claim-blocker remains. - ready — unclaimed with every
claim-blocker resolved; claimable now.
A closed task has no file — absence is the resolution. Its history (including the delivery commit on main tagged [bl-xxxx]) is the record. Closed work stays reachable: bl show <id> resolves any dead id, and every bl list filter reaches the dead set with --all (live + dead) or -s closed (dead only), reconstructed from balls/tasks history. bl list <needle> --all is a substring search over the title and body of every task the project has ever had.
Deleting on close is also what keeps a long backlog cheap to read: finished tasks are out of the working set, with nothing to summarize or age out to get them there.
The human-facing output of list/show paints derived columns — the status ladder, the tree, ISO-8601 dates — none of them stored. --json is the orthogonal bedrock projection: raw stored frontmatter only, literal integer timestamps, no derived field. It is the round-trippable "what's actually there" and the supported machine contract. Parse --json, never the human render.
| Command | What it does |
|---|---|
bl prime [--as ID] [--remote URL] [--center URL] [--install URL] |
Ready this checkout: founds the substrate on first run (no separate init), then syncs. Re-prints the worktree path of every task you still hold. Run at session start. --remote URL shapes one op; --center URL enrolls this checkout into a shared center (durable bind + adopt config/ + prime, one shot; subsumes --install); --install URL adopts a center's config/ only. Prints the landing's config/PRIME.md verbatim if it has one. |
bl sync [BRANCH] [--as ID] |
Pull the store from the remote (fetch + fast-forward). No arg syncs the configured store branch. |
bl list [NEEDLE] [-s|--status ready|blocked|claimed|closed] [--all] [--everywhere] [--tag T] [--claimant NAME] [--since D] [--until D] [--json] |
List tasks, one row per ball. Default = live (non-closed), scoped to this checkout's project (what claim admits: this git root + rootless balls); --everywhere lifts the scope to every project on the store, labelling foreign rows (human render only). -s closed (or --all for live+dead) reconstructs archived tasks from history. Filters compose (AND): NEEDLE = case-insensitive substring over title+body; --claimant = exact holder. A claimed row shows its claim-age (human render only; --json is stored frontmatter). |
bl show <id> [--json] |
Task detail, journal included (the ball's store history with its -m notes, oldest-first — human render only). A closed id still resolves (reconstructed from history). |
bl create "TITLE" [--body B] [-p N] [-t TAG] [--parent ID] [--needs ID[:OP]] [--blocks OP|ID:OP] [-m MSG] [--as ID] |
File a task (--body sets the markdown body, -m the commit note). Prints the new id. |
bl import [--as ID] |
Bulk-create tasks from --json bedrock records on stdin — the inverse of show --json. Ids and timestamps are ingested verbatim (no minting, stamping, or gating); an existing id is refused (use update to modify). For migration, restore, and federation joins (§16). |
bl claim <id> [--as ID] |
Start work: materialize the work/<id> worktree, take occupancy. Prints the worktree path to stdout. |
bl unclaim <id> [--as ID] |
Release a claim, remove the worktree. |
bl update <id> [--title T] [--body B] [--parent ID|--no-parent] [-p N|--no-priority] [-t TAG] [--no-tag TAG] [--needs ID[:OP]] [--no-needs ID] [key=value] [-m MSG] |
Overwrite any field: --title/--body; set or clear the --parent/-p scalar; add (-t) or drop (--no-tag) a tag; set (key=value) or remove (key=) a preserved extra; add (--needs) or unlink (--no-needs) one of this task's own blockers. Only reciprocal --blocks (an edge on ANOTHER task) stays create-only. -m is the commit note. |
bl comment <id> "TEXT" [--as ID] |
Append TEXT to the task's markdown body under a horizontal rule — sugar over update --body, and the one note that renders in both bl show and bl show --json (the body is stored; the -m journal is derived). No -m, no field flags; empty TEXT is refused. |
bl close <id> [-m MSG] [--as ID] |
Deliver (squash work/<id> → main) + archive the task + tear down the worktree and the work/<id> branch. Refuses if main moved and is not yet in work/<id> — merge it in, resolve and test there, then close again. If the task file changed since your own last touch, close refuses once with the unseen diff; a bare re-run seals exactly that content. |
bl install [PATH] [--from REF] [--to REF] [--bin NAME=PATH] [--as ID] |
Copy a committed path between branches (adopt/publish plugin config). PATH defaults to config/, --from to the configured upstream, --to to the landing; --bin NAME=PATH names a referenced plugin's local binary explicitly (else beside bl, then $PATH). A folder source mirrors (deletions propagate), a file/glob source unions. |
bl conf [KEY] |
Read or write this checkout's local config (never synced), with provenance. No arg dumps every resolved value with its layer and source file; one KEY reads that value. Write with bl conf <set|append|prepend|remove> KEY VALUE…; KEY ∈ task-remote, task-branch, log-level, clock-provider, <op>.<pre|post>, show, list. |
bl --skill |
Print the operating guide (SKILL.md) — architecture, the footgun invariants, and the command map. bl skill is the deprecated spelling (kept, with a migration note). |
bl <cmd> --skill |
Print one command's full usage — flags, examples, semantics. bl <cmd> --help/-h and bl help <cmd> are aliases (per-command help folds into --skill). |
bl help |
Print the terse command directory (also --help/-h). |
There is no init (folded into prime), no review (folded into close), no ready (it is bl list --status ready), and no remaster/resolve/reopen. Subtraction is the design discipline: a new verb is a smell.
Run bl prime at the start of every session:
bl prime --as YOUR_IDENTITYprime is idempotent. On first run it founds the local substrate — seeding config/ from the install defaults and creating the store — then syncs with the remote. Re-running converges to a no-op. It also converges an upgraded checkout's version skew: retired first-party plugin names still committed in the schedule are rewritten to their current spelling (and rebound to the sibling binary beside bl), and crash debris — a leftover changes/ worktree, a retired stealth.lock, a leftover .git/index.lock blocking every commit in the landing, an unsettled work/<id> branch whose worktree is gone — is reported, never deleted. A report only claims what prime can prove: a changes/ worktree or an index lock may equally belong to an op running right now, so those lines hedge ("crash debris unless an op is running here right now") instead of instructing you to delete. Upgraded and things are weird? Run bl prime, then read bl conf for what it found. To enroll a fresh checkout into a shared project (a "center"), the one-shot is bl prime --center <git-url>: it writes the durable per-clone binding, adopts that center's config/, and primes — one command, no half-enrolled window. A local bare repo is a legitimate center (git init --bare ~/hub.git, then bl prime --center ~/hub.git). The rule is --remote shapes one op; --center enrolls a checkout (prime-only). --center subsumes --install, which adopts a center's config/ without the durable bind — a single hop, not a walk: a center's config names its own store branch, never another config to chase.
If the landing carries a config/PRIME.md, prime prints it verbatim as its last act — the project's own briefing to whoever just primed. Because install is a pure path-copy, the brief travels with the config it lives in: bl prime --center <hub> adopts a center's config/, brief included, and shows it on the same op that enrolls you, so one authority briefs the whole fleet.
Write pointers, not copies: "read docs/architecture.md §9 before touching close", never a restatement of §9. A copied fact drifts from its original, because the change that invalidates it never touches the copy — and a stale brief is worse than no brief, since agents trust what they are handed. Nothing seeds a default (absence is silence), and there is no flag to suppress it — a brief too long to read every session is a file to shorten.
Every claim/close/prime is stamped with a worker identity, resolved from --as ID, else $USER, else the literal "unknown". Don't let an LLM invent its own name — language models are not RNGs and collapse to the same handful of names across sessions (you end up with three Junipers stepping on each other's claims). Source the randomness outside the model: have the agent harness pick a name at session start and pass it via --as. A portable recipe is shuf -n1 /usr/share/dict/words. In Claude Code, a SessionStart hook in ~/.claude/settings.json that exports the name for the agent to pass as --as works well.
Three pieces wire a repo so a fresh agent finds balls at all, and each sits where the thing it describes already lives:
-
AGENTS.mdin the project tree — discovery. Your harness loads it before the agent does anything; one line is enough:We use balls (`bl`) for task tracking. Run `bl --skill` for the operating guide.From there the agent reads
SKILL.mdand follows it to eachbl <cmd> --skill. balls does not write this file and cannot: base balls never opens the project repo (§11) — the same property that makes "nothing onmain" structural. That hop is yours. -
config/PRIME.mdon the landing — this project's rules, printed verbatim by everybl prime(above). UnlikeAGENTS.mdit travels:installcopies the config it lives in, so one center briefs the whole fleet. -
A harness hook passing
--as— identity (above). balls cannot see session boundaries, so the name has to come from outside the model.
Keep the two files on separate jobs. AGENTS.md says balls exists; PRIME.md says what this project expects of whoever just primed. Restating either inside the other is how PRIME.md becomes a second AGENTS.md, and then both drift — so write pointers, not copies, in both.
There is deliberately no bl setup <editor>: a generator for someone else's config schema is code you must edit when their schema moves, which fails the severability test (§0). The setup is three files you own.
tasks/<id>.md is TOML frontmatter (fenced by +++) plus a free-form markdown body:
+++
title = "Refactor the foo system"
created = 1748357520 # unix seconds; storage is always unix-time, display renders ISO-8601
updated = 1748443920
claimant = "you@example.com" # occupancy: present ⇒ claimed, absent ⇒ unclaimed. NO status field
parent = "bl-1000" # containment only — builds the tree, gates nothing
priority = 2 # optional; lower = higher priority; absent sorts last
tags = ["refactor", "infra"]
[[blockers]] # the one relational primitive
id = "bl-1100"
on = "claim" # can't be CLAIMED until bl-1100 resolves (a dependency)
[[blockers]]
id = "bl-1200"
on = "close" # can't be CLOSED until bl-1200 resolves (a gate)
+++
Free-form markdown body.- The id is the path.
<id>is the filename basename — the sole source of truth, noid:field, no index.git log -- tasks/<id>.mdworks by id directly. - TOML everywhere. Frontmatter and config are both TOML (one pure-Rust serializer, no C dependency). It exports losslessly to JSON for tooling.
- Unknown keys are preserved on writeback — the opt-in seam for a team's own field (e.g. a
state:pipeline column read by their own display plugin), never a core field.
The one relational primitive is a blocker edge {id, on} on the blocked task: "this task can't do op on until task id resolves." on is any op, but two have create-time sugar:
--needs B[:OP]— add a blocker on this task (defaultOP = claim, a dependency: can't be claimed until B closes).--blocks OP/--blocks ID:OP— the reciprocal: gate another task's op on this one.--parent X --blocks closemakes X a parent that can't close until this child does (a gate).
--parent is containment only — it builds the display tree and gates nothing. An "epic" is just a task with children; to make a parent wait on its children, add explicit --needs/--blocks edges. Core enforces blockers: a claim of a blocked task or a close with an open gate is refused, naming the blocker. There is no special gate type — "review before close," sign-offs, and build gates are all emergent (a gate child plus a tag), never core rules.
Behavior beyond the base (commit config to the landing, task files to the store) is plugins — single binaries, dispatched as subprocesses with no in-process or privileged path. The schedule is config: config/plugins.toml on the landing has a [hooks] table mapping <op>.<phase> to an ordered list of plugin names (list position = run order). Each name resolves through a local, gitignored config/plugins/bin/<name> symlink that bl prime/bl install bind to the binary installed beside bl; a scheduled name whose binary is missing at founding is pruned from the seed, so a remote-less or plugin-less box still works. Binaries never travel with config — acquisition stays your package manager's job. The one exception to the symlink discipline is the machine layer (bl-053a): a name contributed by the per-machine ~/.config/balls/plugins.toml [hooks] overlay resolves at dispatch beside bl, then on $PATH — the clock_provider rule — so a box-wide plugin is one XDG entry plus one binary on PATH, no per-landing bind; landing-committed names never get that fallback. A plugins.toml may carry a [source] table (adversary = "git clone https://github.com/mudbungie/balls-adversary && make install"): free-text hints displayed verbatim wherever a missing binary already refuses (dispatch, bl install's dangling report, the seed prune), never parsed or executed — a human reads the hint and runs it; the bin/<name> adjacency stays the trust gate.
The shipped seed (default-config/plugins.toml):
[hooks]
"sync.pre" = ["bl-tracker"] # import remote state first
"prime.pre" = ["bl-tracker"]
"install.pre" = ["bl-tracker"] # fetch the center's config to adopt (§13 prime --install)
"prime.post" = ["bl-delivery", "bl-tracker"] # re-materialize still-claimed worktrees + print their paths, then settle store content (fetch-ff + push)
"claim.post" = ["bl-delivery", "bl-tracker"] # worktree (prints its path), then the push (tracker last)
"unclaim.post" = ["bl-delivery", "bl-tracker"]
"show" = ["bl-delivery"] # read-op (single phase): fold the worktree path into the human render
"close.pre" = ["bl-delivery"] # deliver (squash) before the seal
"close.post" = ["bl-delivery", "bl-tracker"] # teardown (worktree + the work/<id> branch), then push
"create.post" = ["bl-tracker"]
"update.post" = ["bl-tracker"]
"import.post" = ["bl-tracker"] # imported records sync like any mutate (§16)Two plugins ship by default and are wired by the seed config:
- bl-tracker — the only component that talks to a remote: fetch + fast-forward on sync, push after each op, found/adopt on prime. Strip it (or configure no remote) and the store stays local-only — "stealth" is not a mode, just a
tasks_branchwith no remote behind it. - bl-delivery — owns the
work/<id>code worktree and branch: materialize on claim, squash-deliver on close then delete both (the squash and the seal have already landed, so the delete is provably lossless);unclaimreleases the worktree but keeps the branch, since a handoff delivered nothing. It is kind-blind (never branches on task type) and stateless across ops (the worktree path is a pure function of the binding and id). Base balls never opens the project repo, so "nothing in the project tree" is structural — only this plugin touches your code.
A third, bl-chore, ships but is opt-in (not in the seed schedule): wire it with bl conf prepend claim.pre bl-chore and it mints one tagged close-gate child per configured chore at claim, so the claiming agent must discharge them before bl close — a forcing-function checklist, not enforcement. One hook is the whole wiring: the chores are written into the claim's own change worktree, so they land in the claim's commit and a claim that aborts mints nothing.
A plugin contributes by editing the change worktree (the task file), never by a parsed return value — core parses nothing back (there is no return channel, §7). Its stdout is the user-facing channel, forwarded verbatim to the invoker's stdout: "bl claim prints the worktree path" is bl-delivery printing there, and bl prime re-prints the path of every task you still hold the same way. Its stderr is enveloped line-by-line into the per-clone op log. A non-zero exit aborts the op and rolls prior plugins back in reverse. That is the whole protocol. See docs/architecture.md §6–§8 for the full contract.
There is one delivery path: close.pre squashes work/<id> into the integration branch as one commit whose subject carries the [bl-id] delivery tag — and skips the squash when that incarnation already delivered (a [bl-id] commit on integration since the branch forked), whoever performed the merge.
Delivery is a validation and atomic-advance boundary, never a merge queue. It pins the target tip once and requires that tip to already be an ancestor of work/<id>; a source that has not incorporated its target is refused — by name, with the remedy — before anything merges, gates, squashes or moves a ref. So the tree the hook gates is the tree the closer built and ran, and the target advances by compare-and-swap from the pinned tip to the tagged squash. The rule is fractal: child -> work/<parent> and root -> integration are the same operation at every depth. Reconciling is the source owner's job, done in their own worktree, tested there.
- Forge (opt-in, ships separately per-forge) is not a delivery variant — it never hooks
close.pre. The forge plugin mints an approval gate child atclaim(an ordinary close-blocker, not a special mechanism) and closes it atsyncwhen the PR merges, unblocking the next close. Submission is git-native work: pushwork/<id>and open the PR yourself, with the[bl-id]tag in the PR title — that tag is what lets the post-merge close recognize the squash-merge as the delivery and skip the local squash. "Forge review" is not a mode — it is the ordinary close plus a gate child, enforced by core's close-blocker guard.
Don't confuse a forge plugin with an issue-tracker plugin (Jira, Linear, GitHub Issues): same plugin protocol, unrelated job. Issue-tracker plugins mirror backlog state; forge plugins drive the merge gate. Both ship separately, not bundled here.
A common deployment is a bare project repo (no working tree at the root) — the worktree/merge model makes a direct commit to the working branch a git-level impossibility rather than a discouraged convention. Then:
git statusat the bare root is fatal by design (must be run in a work tree), not a broken repo. For task state usebl list; for code state rungit status/git diffinside yourwork/<id>worktree.- All
blverbs run from the bare root.
Because state lives in XDG and the two branches are path-derived per checkout, multiple clones of one project share a store by naming the same tasks_branch remote — federation is many landings pointing at one store branch. The landing is never shared (it has no merge); only the store is.
- A task you decided against (dupe, stale):
bl unclaim <id>if you hold it, thenbl close <id>— an empty deliverable archives without delivering code. updateoverwrites every ball field — no create-only split. It sets (--title/--body/--parent/-p/-t/key=value/--needs) and clears (--no-parent/--no-priorityblank a scalar,--no-tag/--no-needsdrop a member, a barekey=removes an extra) — so a mis-wired or cyclic blocker, a stale parent, or a wrong title is fixed in-band, never with store-file surgery. The lone create-only flag is reciprocal--blocks(an edge naming this task on ANOTHER task), since that is not this task's own field.
Releases to crates.io are automated via release-plz and GitHub Actions. The normal flow:
- Merge feature work to
mainwith the project's commit style — a short title with a[bl-xxxx]trailer, optionally followed by a body. Every non-balls:commit is picked up by release-plz's changelog. - On every push to
main,.github/workflows/release-plz.ymlopens (or updates) a Release PR that bumpsCargo.toml, regeneratesCHANGELOG.md, and lists the commits going into the release. - Review the Release PR. CI (
.github/workflows/ci.yml) runscargo test,cargo clippy, line-length + 100% coverage checks, andcargo publish --dry-run. - Merge the Release PR. release-plz tags
vX.Y.Z, creates a GitHub release, and publishes to crates.io.
Source commits here aren't Conventional Commits, so release-plz can't infer minor/major from feat:/fix: prefixes. Instead, release-plz.toml configures bracketed markers that compose with the [bl-xxxx] style:
| Marker | Bump | When to use |
|---|---|---|
[major] |
major | Breaking change — task file format, CLI flag removed, etc. |
[minor] |
minor | New user-visible capability — new command, config option, behavior. |
| (none) | patch | Default. Bugfix, refactor, doc, internal cleanup. |
Put the marker anywhere in a commit message that lands in the release window — release-plz matches the regex against the full commit text. The marker must be a standalone bracketed token (so prose mentions like [minor]/[major] describing the convention, wrapped in backticks, don't self-trigger a bump). If multiple commits in the window are marked, the highest bump wins.
# on main, with a clean tree
cargo test && cargo publish --dry-run
# bump version in Cargo.toml, update CHANGELOG.md
git commit -am "Release vX.Y.Z"
git tag vX.Y.Z
git push origin main --tags
cargo publishCHANGELOG.md is release-plz-owned — don't hand-curate [Unreleased]; write rich commit bodies instead.
balls is published as the balls crate and its modules are public, but the supported, stable surface is the bl CLI — in particular --json on the read verbs, the bedrock projection. The crate API tracks the internal greenfield architecture (task, lifecycle, checkout, plugin, …, documented in src/lib.rs) and may change between releases. For programmatic integration, prefer shelling out to bl ... --json: agents already have shell access, so bl list --status ready --json is a tool call with no adapter to maintain.
The one exception is the typed read surface for linking consumers: reads::Catalog::load / Catalog::entries / Catalog::get / Catalog::is_resolved and reads::task_json are the in-process mirror of bl list --json / bl show --json — same bedrock records, no derived status (derive it via task::Task::status/ready/closeable with a caller-supplied resolver, e.g. Catalog::is_resolved). It exists for hosts that embed balls wholesale (exact-pinned) rather than shell out; pin an exact version — the surface is not semver-stable. Mutations have no library path: they go through the bl verb surface only, which is what carries the change-worktree/seal protocol and the plugin chain.
A second exception serves hosts that embed balls and multiplex the plugin binaries themselves (a host whose bl is its own executable has no sibling bl-tracker/bl-delivery beside it): tracker::run and delivery_bin::run are the two shipped plugins' whole argv/wire boundaries as library entrypoints — the shipped binaries are thin edges over exactly these, so a multiplexing host answers the same §6/§7 contract from the same code, with env resolution left at the host's own process boundary.
A third exception is the attempt capability, attempt::Attempt (architecture §11.1): a delivery whose source is not a ball. A host that runs N ≥ 1 isolated tries at one obligation — alternatives to compare, an agent working a target it has no ball for — opens an attempt against an opaque attempt::Target (asked for, never constructed: the integration branch, a ball's work/<id>, an explicit validated branch, or another attempt), gets a private attempt/<handle> source ref, index and worktree forked from an exact commit, and delivers it by exactly the law bl close delivers a ball by — the target must already be incorporated, then the repo's own pre-commit gate, a tagged squash, and a compare-and-swap. deliver returns the base / source / target / delivered-commit identities; release frees the worktree while the source stays addressable, discard removes both. balls owns the refs, worktrees, delivery and cleanup and nothing else: how many attempts exist, how they compare, and when a rejected one expires are the host's, and no candidate/winner/outcome is stored anywhere. There is no bl verb for this on purpose — bl close is the N = 1 case of the same mechanism.
Beads and balls start from the same premise: agents need structured, queryable, persistent task state — not markdown files strewn across a repo — and that state belongs out of main's commit history so feature work and bookkeeping don't interleave. What differs is what holds it.
Beads uses Dolt — a version-controlled SQL database. That buys cell-level merging and fast queries on large task sets. The cost is running two version-control systems side by side: git for code, Dolt for tasks — two histories to keep consistent, two merge models, two remotes, and a database binary every collaborator installs.
Beads also pitches itself as a memory upgrade rather than a tracker: project insights stored and injected at session start, plus compaction to decay old closed tasks out of the context window. Balls does the first with a file — a config/PRIME.md on the landing, printed verbatim by every bl prime. It needs nothing for the second: closing deletes the task file, so finished work is already out of the working set, and bl list <needle> --all still searches every task the project has ever had.
Balls asks whether one VCS can do both jobs. The two-branch design keeps task data fully out of main's commit graph — same separation — but stores it in the same git repository, fetched by the same git fetch, pushed by the same git push. A collaborator who clones the repo gets the backlog; one without bl installed can still read, diff, and hand-edit task files with stock git. There is no second system to operate. The tradeoff is real: Dolt's cell-level merge is strictly more granular than git's file-level merge. Balls mitigates with one file per task (conflicts are per-task) and a text-mergeable TOML schema, but doesn't match per-cell precision. The bet is that one VCS beats two whenever one is sufficient — and for a backlog of tasks, git is sufficient.
Cline Kanban provides a visual board for agent orchestration with worktree-per-task isolation. It solves the human attention problem well. But it's local-only with no multi-machine story, closed-source infrastructure, and tightly coupled to the Cline ecosystem despite claiming agent-agnosticism. There is no durable shared state — each developer's board is independent.
Traditional trackers weren't designed for agent workflows. They require network round-trips for every read, can't be queried offline, don't support the claim-and-worktree lifecycle, and have no concept of local-first operation. They remain the right tools for human project management; balls integrates with them via issue-tracker plugins rather than replacing them.
Balls takes the core insight — structured task files, dependency tracking, agent-native CLI — and implements it on the only infrastructure every developer already has: git. Tasks are files. Sync is push/pull. History is git log. Collaboration is merge. There is nothing to install except a small CLI and its sibling plugins, nothing to configure that isn't a committed TOML file, and nothing to operate except git.