My own corner of monopam
README.md

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.json carries 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.md is the optional body.
  • comments/GGG/NNNNN-ID.md is one comment: a JSON header with author and time, a blank line, then text. NNNNN is its zero-based index in the row, the order it was written in, and GGG is 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, under MQ_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 under MQ_HOOK=delivery changes nothing.
  • landed into the checkout's default branch. Each named row whose checks are all case or count checks 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 become landed, 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, stuck and unavailable. 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:

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.