tracker #
tk is an issue tracker whose issues are plain files on the main branch
of a dedicated Git repository.
Each write is a single commit, pushed with a lease on the main it started
from. Two simultaneous claims on one issue cannot both succeed, and the
loser is told who holds it. Kinds, fields and write rules are declared
beside the issues in schema.toml. tk hook consumes events from the merge
queue mq and closes
issues whose checks a landing proves. tk prints issues as rows.
Contents #
Security #
Anyone allowed to push to the authority can rewrite any issue. tk adds no
access control and keeps no user list. Authorship is whatever identity the
Git configuration of the code checkout carries. --as NAME records a
different holder or comment author and appends a Co-authored-by: NAME
trailer to the commit. Nothing authenticates NAME.
tk new --private marks an issue untriaged. Such an issue lives in the
authority with all the others and is exactly as confidential as that
repository. --public hides it from listings and from tk serve.
Background #
git-bug writes each bug as a chain of
commits under refs/bugs/<id> of the code repository and orders concurrent
edits with Lamport clocks. Radicle
applies the same design to issues under refs/cobs and signs every change.
Fossil replays
timestamped ticket-change artifacts into a TICKET table.
git-issue keeps a directory of
plain files per issue in a separate repository under .issues.
Beads, built for coding agents, uses a
Dolt database under .beads and synchronises it through Dolt remotes.
tk differs on exclusive transitions. A claim, release or close is never
merged. It is pushed with a lease on the main it was computed from. After a
lost race tk fetches and recomputes, up to 32 times, and the second claimer
then finds the first holder. git-bug, Radicle and Fossil accept both
concurrent changes and order them afterwards, possibly after two people have
started the same work. Beads obtains exclusion inside one database. tk
obtains it across machines with nothing but Git. Its state is a plain tree on
one branch, reviewable with git show as in git-issue. Unlike git-issue,
issues carry typed acceptance checks and typed edges.
Install #
From the opam overlay that publishes tracker:
$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam install tracker
This installs the tk and tk-serve executables and the tracker library.
To build from a checkout of this repository, with the overlay configured:
$ opam install . --deps-only --with-test
$ dune build
$ dune runtest
tracker requires OCaml 5.2 or later. Creating a store requires Git.
Usage #
A first session #
tk involves three repositories. The authority is shared, addressed by an
absolute path or an SSH URL, and a write exists only once it accepts the
push. The store is a local clone of the authority; issues live on its
main. The checkout is your code. Its root holds a .tracker symbolic
link to the store, and its Git configuration identifies the writer.
tk init only rebuilds the query projection. The first commit of a store is
therefore written by hand in draft, cloned bare twice (authority, then
store) and linked from app:
$ git init -q -b main draft
$ cat > draft/schema.toml <<'EOF'
> kinds = ["bug", "task"]
> statuses = ["open", "claimed", "landed", "closed"]
> check_kinds = ["case", "reading"]
> label_families = [{ name = "area", values = ["api", "docs"] }]
> EOF
$ cat > draft/tracker.toml <<EOF
> organisation = "demo"
> prefix = "demo"
> remote = "$PWD/issues.git"
> EOF
$ git -C draft add schema.toml tracker.toml
$ git -C draft commit -q -m 'issues: start'
$ git clone -q --bare draft issues.git
$ git clone -q --bare issues.git issues
$ git init -q -b main app
$ ln -s ../issues app/.tracker
$ cd app
Filing reads the body from standard input. --check case:ARM:NAME names the
check whose success will close the issue. Without a check the issue cannot
be claimed. Listing needs no network:
$ echo 'A 413 response makes the client resend the same body.' |
> tk new --kind bug --title 'Upload retries forever after a 413' --label area:api --check case:test:upload_413
created demo-4b0e2f6a9c1d7835
$ tk list
TK / TRACKER
demo-4b0e2f6a9c1d7835 P2 open Upload retries forever after a 413
Before filing, tk new searches live issues for the same words and stops on
a match. --distinct REASON files anyway:
$ echo 'Retries never stop.' | tk new --kind bug --title 'Upload retries forever'
tk: 1 live row may already hold this finding: claim it, or file with --distinct REASON
demo-4b0e2f6a9c1d7835 open - Upload retries forever after a 413
[1]
$ echo 'Send bodies over 8 MiB in parts.' |
> tk new --kind task --title 'Chunked uploads' --priority 1 --check case:test:chunked_upload
created demo-e81c5a3f02b96d47
Search matches whole words. Linking the bug to the task leaves only the task
ready, unblocking one issue (+1):
$ tk search retries
demo-4b0e2f6a9c1d7835 open - Upload retries forever after a 413
$ tk link demo-4b0e2f6a9c1d7835 depends-on demo-e81c5a3f02b96d47
demo-4b0e2f6a9c1d7835 depends-on demo-e81c5a3f02b96d47
$ tk ready
TK / TRACKER
demo-e81c5a3f02b96d47 P1 +1 Chunked uploads
Alice claims the task. Bob is refused, with her name. Once she closes it the bug becomes ready:
$ tk claim demo-e81c5a3f02b96d47 --as alice
claimed demo-e81c5a3f02b96d47
$ tk claim demo-e81c5a3f02b96d47 --as bob
tk: row demo-e81c5a3f02b96d47 is held by alice
[1]
$ tk comment demo-e81c5a3f02b96d47 --as alice -b 'Parts of 4 MiB, retried one at a time.'
commented demo-e81c5a3f02b96d47
$ tk show demo-e81c5a3f02b96d47
TK / TRACKER
demo-e81c5a3f02b96d47 claimed Chunked uploads
Send bodies over 8 MiB in parts.
comment c-9f6c8bb2f11b5874db9dbd3d (alice): Parts of 4 MiB, retried one at a time.
$ tk close demo-e81c5a3f02b96d47 --as alice -r 'Chunked uploads shipped in 1.4.'
closed demo-e81c5a3f02b96d47
$ tk ready
TK / TRACKER
demo-4b0e2f6a9c1d7835 P2 +0 Upload retries forever after a 413
Claims never expire. A refused claimer takes another row from tk ready. An
abandoned claim is freed by tk release ID --holder alice -r REASON, which
fails if the holder has changed meanwhile.
The authority now holds seven commits:
$ git -C ../issues.git log --format=%s
tk close demo-e81c5a3f02b96d47
tk comment demo-e81c5a3f02b96d47
tk claim demo-e81c5a3f02b96d47
tk link demo-4b0e2f6a9c1d7835 depends-on demo-e81c5a3f02b96d47
tk new demo-e81c5a3f02b96d47
tk new demo-4b0e2f6a9c1d7835
issues: start
Two lines are hidden. Each command prints tracker: /path/to/issues on
standard error, and identifiers are random. [1] marks an exit status.
test/cram/readme.t replays this session under dune runtest.
Joining a store #
On a second machine:
$ git clone -q --bare "$AUTHORITY" ~/issues
$ ln -s ~/issues .tracker
$ tk ready
tk reads .tracker only from --root, by default the current directory,
and never from a parent directory. Commit the link with a relative target and
every clone and worktree has it. Queries read the local store as it stands.
tk sync fetches the authority and publishes local commits it lacks. Writes
always fetch first.
Coming from Beads #
| Beads | tk |
|---|---|
bd ready |
tk ready |
bd list |
tk list |
bd search |
tk search |
bd show |
tk show |
bd create |
tk new --kind KIND --title TITLE, with the body on standard input |
bd update --claim |
tk claim ID --as NAME |
bd dep add |
tk link ID depends-on OTHER |
bd comment |
tk comment ID -b TEXT |
bd close |
tk close ID -r REASON |
Kinds, labels and fields are whatever the store's schema.toml admits. |
The data model #
Each row lives under rows/A/B/C/ID/. ID is the store prefix followed by
16 hexadecimal digits, and ABC begins the SHA-256 of ID.
row.jsoncarries the structured attributes: priority from 0 to 4, triage state, labels, packages, edges, checks, claim and eventually the closure. Members are written in a fixed order with a final newline. Unknown members are refused.body.mdis the optional body.comments/GGG/NNNNN-ID.mdis one comment: a JSON header with author and time, a blank line, then text.NNNNNis its zero-based index in the row, the order it was written in, andGGGis that index divided by 128, so no directory holds more than 128 entries however many comments a row has. Identifiers derive from author and text, and retrying a comment writes nothing.
schema.toml enumerates admitted kinds, statuses, check kinds and label
families. Without kinds, the five Beads defaults apply: bug, feature,
task, epic and chore. A label reads FAMILY:VALUE, and the value set
["*"] admits anything. [[fields]] entries declare typed per-kind fields,
supplied through tk new --fields-file. An optional [rules] table states
write invariants such as a maximum title length or a label requiring another.
Every write is validated against the schema of the tree it produces and
refused before main moves when it introduces a violation. tk check
reports existing violations. tracker.toml holds organisation, prefix
and remote, the authority.
Checks of kind case:ARM:NAME or count:COMMAND:NUMBER are satisfied by a
landing whose check NAME or COMMAND passed. reading:READER:SUBJECT
requires a human reader and a manual close. An edge is depends-on,
tracks, duplicates, supersedes or discovered-from.
Self edges and dangling targets are refused, as are cycles of depends-on or
tracks edges.
Status moves through open, claimed, landed and closed. Claims record
holder and time and never expire. Rows lacking checks, and epic rows, are
not claimable. tk release ID --holder NAME -r REASON succeeds only while
NAME still holds the claim. Closing a claimed row requires its holder's
name and records a reason. landed means the change landed with some checks
unmet.
Queries go through a SQLite projection, tk/index.sqlite inside the store's
Git directory and never committed. Any change to the tree of main triggers
a rebuild on the next read. Deleting the file loses nothing.
Commands #
Every command accepts --root DIR, the checkout containing .tracker,
defaulting to the current directory. Queries other than tk search emit
JSON under --json.
Row writes exit 1 when the store refuses them (held row, rule violation) and
2 when the store or authority is unreachable. Repeating a refused write
unchanged gets the same refusal. tk schema change, tk hook and the
queries exit 2 on every failure. Command-line errors exit 124.
| Command | What it does |
|---|---|
tk new |
File a row in one commit, after searching the live rows for the same finding. |
tk list [QUERY] |
List rows, or those whose id or title contains QUERY. |
tk search PHRASE |
Find live rows by id, or by whole words of the title or body. |
tk show [REV:]ID… |
Print rows with their body and comments, from main or from revision REV. |
tk ready |
List the open, unclaimed rows whose prerequisites are all closed, by priority. |
tk status |
Count rows by status. |
tk claim ID |
Claim a row in one compare-and-swap on the authority. |
tk release ID --holder NAME -r REASON |
Release a claim only if NAME still holds it, recording why. |
tk comment ID |
Add a comment in one commit. |
tk edit ID |
Change one or several fields of a row in one commit. |
tk link |
Add a typed edge in one commit. |
tk unlink |
Remove a typed edge in one commit. |
tk close ID -r REASON |
Close a row with its reason. |
tk sync |
Fetch the authority's main and publish the store's commits it lacks. |
tk schema, tk schema change |
Print the schema, or publish a new one with the rows it rewrites. |
tk check |
Check every row against the schema, and the definition anchors against the checkout. |
tk export --jsonl |
Write one snapshot of the store as JSON lines. |
tk stats |
Report flow measures (ages, time to claim, time to close) over a window. |
tk history --json --since TIME --until TIME |
Print the claims, releases, closes and reopens Git history witnesses in a window, with each writer and whether coverage is complete. |
tk hook |
Apply one mq event read on standard input. |
tk init |
Rebuild the SQLite database from Git. It does not create a store. |
tk operator |
File or look up a finding by its external key, for a scheduler. |
tk serve |
Serve the rows as web pages, on the loopback address by default. |
tk serve dispatches to tk-serve. Unknown subcommands run the executable |
|
tk-NAME found next to tk or on PATH, as Git does for git-NAME. Each |
|
command documents its options under --help. |
Several stores #
A checkout may also see a second store, through the link
vendor/mono/.tracker below its root. That path is hard-coded in this
version. Writes never reach the second store. --all merges its rows into
listings.
Prerequisites are resolved in the store whose prefix starts their
identifier, and a dependency on a closed row of the second store counts as
met. An unknown prefix or a dangling second link makes tk ready, tk stats
and tk hook refuse, naming what was unreadable, without partial output.
Working with mq #
mq lands a branch
once the checks listed in its rules pass. Its rules, mq.toml on the
mq/config branch, declare one hook receiving every event. For tk:
[hook]
command = "tk hook"
mq invokes the hook from the repository's main checkout, whose .tracker
designates the store, passing one CloudEvents
JSON line on standard input. Branches name rows through the Issue or Bug
field of the Git branch description, written by mq new NAME --field Bug=ID, and through Bug: ID commit trailers. Identifiers with another
store's prefix are ignored.
queued, underMQ_HOOK=verdict. Exit 0 admits the branch. A named row that is missing, closed, without checks or waiting on an open prerequisite refuses it, and mq quotes the first line of the refusal. A redelivery underMQ_HOOK=deliverychanges nothing.landedinto the checkout's default branch. Each named row whose checks are allcaseorcountchecks that passed is closed. The closure records the event identifier, the landed commit subjects and the SHA-256 of each check's result line. Other named rows becomelanded, keep their claim and get one comment listing the checks that failed or did not run. A landing elsewhere, or an outcome without a landing, changes nothing.needs_work,stuckandunavailable. Each named row not yet closed gets one comment with mq's reason, log and next command. Its status stays.
Other event types are acknowledged and ignored. Each event produces at most
one store commit, and redeliveries produce none. tk hook never claims.
Whoever takes a row runs tk claim ID --as NAME first, then something like
mq new fix-413 --field Bug=ID.
Performance #
--bench makes a query report its elapsed time, SQLite pages, rows scanned
and Git objects read on standard error. These measurements come from an
arm64 Mac and a generated store of 10,000 rows, a third of them open. Warm,
tk list --json --bench >/dev/null reports 7.6 ms, 1,115 pages and one Git
object, and the process takes 19 ms. At 1,000 rows the figures are 3.4 ms
and 111 pages, with 15 ms of process time dominated by start-up. tk show ID reads 14 Git objects at both sizes.
After a write, the next read rebuilds the whole projection. That rebuild takes 1.1 s and 94,014 Git objects at 10,000 rows, against 117 ms and 10,176 objects at 1,000. It is linear in store size.
Status #
Unreleased. Commands, library and store layout may change before 0.1.0. Three limitations matter day to day:
- the first read after a write rebuilds the projection from the whole tree, about a second at 10,000 rows (Performance);
- the second store is read only through
vendor/mono/.tracker(Several stores); - no command creates a store, so its first commit is made with Git (A first session).
The distributed model describes the
coordination contract. This version implements writes through a single
authority's main, without the per-writer histories proposed there for
disconnected machines.
API #
The tracker library is the state model underneath tk. Every module in
lib/ is public. Start from these:
Core: rows, checks, edges and closures, and the pureclaim,releaseandclosetransitions;Row_jsonandSchema_toml: therow.jsonandschema.tomlcodecs;Store_treeandStore_git: reading a store's tree, and the compare-and-swap writes;Store_ops: a claim as the edits one transaction applies;Hook_eventandHook_transition: mq's events and what they do to rows;Sqlite_index: the SQLite projection.
Transitions are pure, taking a row and returning a new row or a refusal. tk claim runs Core.claim via Store_ops.claim_edits inside a Store_git
transaction. Below, a competing claim and a foreign release are both
refused:
# open Tracker.Core
# let row =
Row.v ~id:"demo-e81c5a3f02b96d47" ~title:"Chunked uploads" ~kind:"task"
~checks:[ Check.Case { arm = "test"; name = "chunked_upload" } ]
~priority:1 ~triage:Row.Ready ~filed:"2026-10-04T09:00:00Z" ()
val row : Row.t = <abstr>
# let said r = Result.map_error (error_message ~row:(Row.id row)) r
val said : ('a, error) result -> ('a, string) result = <fun>
# let held =
match claim ~holder:"alice" ~at:"2026-10-04T09:05:00Z" row |> said with
| Ok held -> held
| Error message -> failwith message
val held : Row.t = <abstr>
# Row.claim held
- : (string * string) option = Some ("alice", "2026-10-04T09:05:00Z")
# claim ~holder:"bob" ~at:"2026-10-04T09:06:00Z" held
|> said |> Result.map Row.claim
- : ((string * string) option, string) result =
Error "row demo-e81c5a3f02b96d47 is held by alice"
# release ~holder:"bob" held |> said |> Result.map Row.claim
- : ((string * string) option, string) result =
Error "row demo-e81c5a3f02b96d47 is held by alice"
Errors carry no row, hence error_message ~row. Instants are RFC 3339
strings that claim never validates.
License #
ISC. See LICENSE.md.