balls
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.
Default workflow
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.
Installation
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.
From source (recommended)
make 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:
From crates.io
cargo 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).
Cross-compilation
Verify
Balls is MIT licensed. See LICENSE.
The model in brief
Two branches, never main
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.
State lives outside the repo (XDG)
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.
Status is derived, never stored
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. bl show <id> and bl list -s closed/--all reconstruct dead tasks from balls/tasks history, walking newest→oldest.
--json is bedrock
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.
Commands
| 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. |
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 close <id> [-m MSG] [--as ID] |
Deliver (squash work/<id> → main) + archive the task + tear down the worktree. 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.
Session start
Run bl prime at the start of every session:
prime 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.
Identity
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.
Task schema
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.
Blockers and the dependency model
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.
Plugins
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. 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):
[]
= ["bl-tracker"] # import remote state first
= ["bl-tracker"]
= ["bl-tracker"] # fetch the center's config to adopt (§13 prime --install)
= ["bl-delivery", "bl-tracker"] # re-materialize still-claimed worktrees + print their paths, then settle store content (fetch-ff + push)
= ["bl-delivery", "bl-tracker"] # worktree (prints its path), then the push (tracker last)
= ["bl-delivery", "bl-tracker"]
= ["bl-delivery"] # read-op (single phase): fold the worktree path into the human render
= ["bl-delivery"] # deliver (squash) before the seal
= ["bl-delivery", "bl-tracker"] # teardown, then push
= ["bl-tracker"]
= ["bl-tracker"]
= ["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: materialize on claim, squash-deliver + tear down on close. 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.post 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.
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.
Delivery: one path; forge changes who merges
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.
- 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.
Bare-repo deployment
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.
Removing or abandoning tasks
- 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.
Releasing
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.
Bump signaling
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.
Manual release (fallback)
# on main, with a clean tree
&&
# bump version in Cargo.toml, update CHANGELOG.md
CHANGELOG.md is release-plz-owned — don't hand-curate [Unreleased]; write rich commit bodies instead.
Library usage
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.
Why not existing alternatives
Beads
Beads was right about the core insight: agents need structured, queryable, persistent task state — not markdown files strewn across a repo. Balls is built on the same realization. Both keep task state out of main's commit history so feature work and bookkeeping don't interleave; balls does this with dedicated git branches, beads with a separate database. The question we answer differently is what holds that state.
Beads uses Dolt — a version-controlled SQL database. That buys cell-level merging and sub-millisecond queries, both genuinely nice 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.
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
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.
GitHub Issues / Jira / Linear
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.
The balls approach
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.