balls 0.5.10

Git-native task tracker for parallel agent workflows
Documentation
# balls

**balls** — the **B**ranching **A**gent **L**abor and **L**ogistics **S**ystem — 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 driving `bl` — its command map points to each command's own `bl <cmd> --skill` for full usage. `docs/architecture.md` is the frozen design reference (§0–§16) — the authority for every claim in this README. `docs/release-notes-greenfield.md` narrates the greenfield (0.x) model and what changed from legacy; `docs/demonstration.md` is 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)

```bash
git clone https://github.com/mudbungie/balls.git
cd balls
make install
make hooks     # one-time per clone: install the repo-local pre-commit hook
```

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

```bash
make uninstall
```

### From crates.io

```bash
cargo install balls
```

`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

```bash
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-darwin
```

### Verify

```bash
bl --version
cd your-repo
bl prime --as you          # founds the substrate on first run
bl create "My first task"
bl list
```

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**. Holds `config/` (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_branch` names it). Holds `tasks/<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 `claimant` field 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.

### `--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. 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.

### Session start

Run `bl prime` at the start of every session:

```bash
bl prime --as YOUR_IDENTITY
```

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

#### The landing's brief

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.

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

### Bootstrapping a repo for agents

Three pieces wire a repo so a fresh agent finds balls at all, and each sits where the thing it describes already lives:

- **`AGENTS.md` in 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.md` and follows it to each `bl <cmd> --skill`. balls does not write this file and *cannot*: base balls never opens the project repo (§11) — the same property that makes "nothing on `main`" structural. That hop is yours.
- **`config/PRIME.md` on the landing — this project's rules**, printed verbatim by every `bl prime` (above). Unlike `AGENTS.md` it travels: `install` copies 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.

---

## Task schema

`tasks/<id>.md` is TOML frontmatter (fenced by `+++`) plus a free-form markdown body:

```markdown
+++
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, no `id:` field, no index. `git log -- tasks/<id>.md` works 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 (default `OP = 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 close` makes 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`):

```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_branch` with 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); `unclaim` releases 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.

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

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** at `claim` (an ordinary close-blocker, not a special mechanism) and closes it at `sync` when the PR merges, unblocking the next close. Submission is git-native work: push `work/<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 status` at the bare root is fatal *by design* (`must be run in a work tree`), not a broken repo. For task state use `bl list`; for code state run `git status`/`git diff` inside your `work/<id>` worktree.
- All `bl` verbs 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, then `bl close <id>` — an empty deliverable archives without delivering code.
- `update` overwrites **every** ball field — no create-only split. It sets (`--title`/`--body`/`--parent`/`-p`/`-t`/`key=value`/`--needs`) and clears (`--no-parent`/`--no-priority` blank a scalar, `--no-tag`/`--no-needs` drop a member, a bare `key=` 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](https://crates.io/crates/balls) are automated via [release-plz](https://release-plz.dev/) and GitHub Actions. The normal flow:

1. Merge feature work to `main` with 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.
2. On every push to `main`, `.github/workflows/release-plz.yml` opens (or updates) a **Release PR** that bumps `Cargo.toml`, regenerates `CHANGELOG.md`, and lists the commits going into the release.
3. Review the Release PR. CI (`.github/workflows/ci.yml`) runs `cargo test`, `cargo clippy`, line-length + 100% coverage checks, and `cargo publish --dry-run`.
4. 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)

```bash
# 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 publish
```

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

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.

---

## Why not existing alternatives

### Beads

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

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.