trackerctl
v0.5.0
Published
Multi-backend issue-tracking CLI for humans and AI agents — claim races, dependencies, hierarchy, local FTS search, project memory. GitLab adapter first.
Maintainers
Readme
tracker
One CLI for all issue-tracking needs — create, claim, dependencies, hierarchy, search, project memory — built for both humans and AI agents. Provider-agnostic core with a GitLab adapter (self-managed instances supported); GitHub/Jira/Azure DevOps adapters can be added behind the same interface without touching the core.
Install
Requires Bun ≥ 1.1 (the cache uses bun:sqlite). The npm package is
trackerctl; the command it installs is tracker.
bunx trackerctl help # one-off, no install
npx trackerctl help # also works (re-executes via bun; tells you if bun is missing)
bun add -d trackerctl # per project → `bunx tracker <cmd>` / npm scripts
bun add -g trackerctl # global → `tracker <cmd>`From a checkout of this repo:
bun install # dev deps only (typescript, biome); zero runtime deps
bun run tracker help # run from source
bun run build # bundle to dist/tracker.js (what the npm package ships)Configuration
In the project root, scaffold a config:
tracker init --base-url https://gitlab.example.com --project group/project
# or `tracker init` alone, then edit the placeholdersinit writes tracker.config.json and — inside a git repository — git-ignores the
local-only files (tracker.config.json, .tracker/, .env) so instance and project
identifiers can never be committed. The committed tracker.config.example.json holds the
same shape if you prefer copying it by hand. The token is also never committed (env var or
gitignored .env). Config discovery walks up from the current directory, so any
subdirectory of the project works.
{
"provider": "gitlab",
"gitlab": {
"base_url": "https://gitlab.example.com",
"project": "group/project",
"token_env": ["TRACKER_GITLAB_TOKEN", "GITLAB_PERSONAL_ACCESS_TOKEN"],
"native_blocking": true,
"native_status": false
},
"labels": { "in_progress": "status::in-progress" },
"memory": { "enabled": true, "title": "📌 Project Memory", "label": "meta::memory" },
"cache": { "path": ".tracker/cache.sqlite", "stale_minutes": 15 }
}gitlab.project— path (group/repo) or numeric id.gitlab.token_env— env var names tried in order, first in the environment, then in a.envnext to the config (see.env.example). The token needs theapiscope. It is never put in URLs, argv, or error messages.gitlab.native_blocking—trueon Premium (native blocking links). Withfalse, dependencies are stored as aTracker-Blocked-By: #1, #2trailer line in the blocked issue's description — same semantics, works on any tier, zero extra API calls.gitlab.native_status— opt-in (Premium/Ultimate with the work-item Status feature):claimalso moves the item's native Status to In progress andreleaseback to To do, so GitLab boards/filters reflect agent activity without label columns. Statuses are matched by lifecycle category, so custom-named lifecycles work. Close/reopen need nothing: GitLab itself moves closed items to Done and reopened ones back to To do. The label remains the canonical claim signal either way.
Verify a setup with tracker doctor.
Commands
Read commands serve from a local sqlite cache that auto-syncs when older than
stale_minutes; every read command accepts --json (stable shapes, below).
| Command | Description |
| --- | --- |
| tracker sync | Full cache refresh (issues + hierarchy + dependency links, batched — no per-issue calls) |
| tracker ready [--parent <id>] | Items that are open, unblocked, unassigned, not in-progress |
| tracker show <id> | Full detail, including blocked-by/blocks |
| tracker children <id> | Direct children (work-item hierarchy) |
| tracker epic-status <id> | closed/total progress over children |
| tracker search [text] [filters] | Local FTS5 + filters; --remote for server-side |
| tracker comments <id> | List an item's comments, oldest first (system notes hidden) |
| tracker users <query> | Resolve usernames/names to user ids (project members) |
| tracker whoami | Authenticated user |
| tracker doctor | Config/token/connectivity/capability checks with fixes |
| tracker create -t <title> … | Create; --parent builds hierarchy, --blocked-by adds deps |
| tracker claim <id> | Race-safe claim (see protocol below) |
| tracker release <id> | Clear assignee + label, tombstone claim tokens |
| tracker close <id> [--reason <text>] | Close; removes the in-progress label, and clears assignee + tombstones tokens only when a live claim exists (human assignees survive) |
| tracker comment <id> <text> | Post a comment on an item |
| tracker attach <id> <file...> [-m <msg>] | Upload files, reference them from one comment; prints markdown |
| tracker label <id> [--add a,b] [--remove c,d] | Add/remove labels without clobbering the rest |
| tracker spend <id> <duration> | Add time spent (1h30m, 2d; -30m subtracts) |
| tracker spend <id> --since-claim | Log wall-clock elapsed since the latest 🔒 claim note (claim-to-done timing) |
| tracker estimate <id> <duration> | Set the time estimate (0 clears it) |
| tracker dep <id> --blocked-by <o> \| --blocks <o> | Add a dependency edge |
| tracker parent <child> <parent> | Re-parent an item |
| tracker remember <key> <text> | Store a project memory |
| tracker forget <key> | Hide a memory key |
| tracker memories [filter] | List memories (latest per key wins) |
| tracker pr create -t <title> --target <b> … | Open a PR/MR (mr is an alias); -i 42,43 records Closes trailers |
| tracker pr status <id> | State + provider-neutral CI signal (none\|pending\|green\|red) |
| tracker pr merge <id> [--close-issues] | Merge; --close-issues closes trailer-referenced issues explicitly, with the same claim hygiene as tracker close |
| tracker pr comment/comments/close/reopen | Discuss, reject (close -m <reason>), reopen |
Issues and PRs are separate capability ports: provider selects where issues live,
merge_provider (defaults to provider) selects where PRs live — so a Jira + GitHub
mix needs no core changes, only adapters. Issue closing on merge is always explicit via
the issues port: GitLab's Closes #N magic only fires on default-branch targets, and a
GitHub PR can never auto-close a Jira issue, so tracker never relies on provider magic.
Examples:
tracker ready --parent 12 --json
tracker search --assignee mehmet # filters work with no text query
tracker search --state closed # state alone is a valid filter
tracker search "payment timeout" --label backend --state open
tracker comment 42 "blocked on design review"
tracker comments 42 --json
tracker attach 42 before.png after.png -m "reference screenshots"
tracker spend 42 1h30m # durations: w/d/h/m/s, 1d=8h, 1w=5d
tracker estimate 42 2d
tracker search checkout --remote --json # fresher, server-side
tracker create -t "Ship login" -d "OAuth" --parent 12 --blocked-by 7,9 -l auth,backend
tracker claim 42 && do-the-work || echo "someone else got it"
tracker close 42 --reason "fixed in MR !17"
tracker remember deploy-cmd "bun run deploy:prod"
tracker pr create -t "Fix login" --target dev -i 42 --json
tracker pr status 5 --json # poll for ci green/red
tracker pr merge 5 --close-issuesExit codes: 0 success · 2 domain failure (lost claim race, refused claim, not found) · 1 usage/config/network error.
The claim protocol
Multiple agents can race for the same issue safely with nothing but issue notes:
- Refuse up-front if the issue is closed, assigned, already labeled in-progress, or the memory issue.
- Post a claim note:
🔒 tracker-claim agent=<user> token=<ts-rand> at=<iso>. - Wait a 2-second settle window, then re-read all notes.
- Drop claims whose token has a release note (
🔓 tracker-release token=…) and claims older than 5 minutes (crashed claimers expire). - Oldest live claim wins — by timestamp, then note id.
- Loser posts a release note for its own token and exits
2. Winner assigns themself and adds the in-progress label.
tracker release clears assignee + label and posts release marks for every live token, so
stale claims can never win a later election.
Agent usage (paste into CLAUDE.md)
## Issue tracking (tracker CLI)
Use `bun run tracker <cmd>` from the repo (or `tracker` if linked). Exit code 2 means a
domain refusal (e.g. lost a claim race) — pick different work, don't retry.
- Find work: `tracker ready --json` (optionally `--parent <epic-id>`)
- Take work: `tracker claim <id>` — only proceed if exit code is 0
- Finish work: `tracker close <id> --reason "<what was done>"`
- Abandon work: `tracker release <id>`
- Add a task: `tracker create -t "<title>" -d "<details>" [--parent <epic>] [--blocked-by <ids>] --json`
- Dependencies: `tracker dep <id> --blocked-by <other>`
- Find an issue: `tracker search "<text>" --json`, or by person with no text:
`tracker search --assignee <user> --json`
- Inspect: `tracker show <id> --json`, `tracker children <id> --json`,
`tracker epic-status <id> --json`, `tracker comments <id> --json`
- Discuss: `tracker comment <id> "<note for humans or other agents>"`
- Attach evidence: `tracker attach <id> <files...> -m "<context>" --json` — uploads
screenshots/files and references them from a comment; reuse the
returned markdown in descriptions
- Open a PR/MR: `tracker pr create -t "<title>" --target <branch> -i <issue-ids> --json`
(source defaults to the current branch)
- Watch the CI: `tracker pr status <id> --json` — poll until `.ci` is "green" or "red"
- Land it: `tracker pr merge <id> --close-issues` — also closes the `-i` issues
with a comment explaining why; never rely on provider auto-close
- Track time: `tracker spend <id> --since-claim` after landing work — logs the
elapsed claim-to-done wall clock from server timestamps (or an
explicit `tracker spend <id> 1h30m`; durations use 1d=8h, -30m subtracts)
- Project memory: `tracker remember <key> "<fact>"`, `tracker memories --json`,
`tracker forget <key>`
Always pass `--json` on read commands and parse stdout; progress notes go to stderr.
Never edit the `📌 Project Memory` issue or `🔒/🔓` notes by hand.JSON output shapes
WorkItem (returned by ready, show, children, search, create):
{
"id": "42", // string everywhere (Jira-ready)
"kind": "task", // "epic" | "task"
"title": "Ship login",
"state": "open", // "open" | "closed"
"labels": ["auth"],
"assignees": [{ "id": "6377", "username": "alice", "name": "…" }],
"author": { "id": "6377", "username": "alice" }, // or null
"parent": "12", // or null
"blockedBy": ["7", "9"], // ids of open OR closed blockers; `ready` checks openness
"url": "https://gitlab…/issues/42",
"description": "…",
"updatedAt": "2026-06-10T17:21:33.000Z",
"timeSpentSeconds": 3600, // 0 = none recorded
"timeEstimateSeconds": 57600 // 0 = no estimate
}Notes: show --json adds "blocks": ["50"] (reverse edges from the cache). --remote
search results have parent: null and only trailer-derived blockedBy (the REST search
endpoint returns neither).
Other shapes:
// epic-status { "parent": "12", "total": 5, "open": 2, "closed": 3, "pctClosed": 60 }
// memories [{ "key": "deploy-cmd", "text": "bun run deploy:prod", "ts": "2026-…" }]
// comments [{ "id": "991", "body": "…", "author": { "id": "1", "username": "alice" }, "createdAt": "2026-…" }]
// users/whoami [{ "id": "6377", "username": "alice", "name": "…" }]
// doctor { "ok": true, "checks": [{ "name": "auth", "status": "ok", "detail": "…", "fix": "…" }] }Architecture
src/
model/ canonical types (WorkItem, Comment, User…) — no provider words here
adapters/
types.ts TrackerAdapter port + AdapterCapabilities
gitlab/ the only concrete adapter: fetch client (REST /api/v4 + GraphQL),
wire↔canonical mapping, batched hierarchy+links sync, trailer fallback
core/ provider-neutral policies: claim/release, ready, epic-status, memory,
local search, sync — built ONLY on the port + cache
cache/ sqlite (bun:sqlite) canonical snapshot + FTS5 index + meta/staleness
cli/ command parsing, human + --json output, exit codesThe core never imports provider code. The contract test suite
(test/contract/suite.ts) runs identically against a FakeAdapter and the GitLab
adapter over mocked HTTP — that is the proof a new backend only needs to implement
TrackerAdapter to get ready/claim/search/memory for free.
Development
bun test # 100 tests: unit, adapter contract ×2, claim races, CLI
bun run gate # typecheck + lint (biome) + tests
bun run smoke # LIVE end-to-end against the configured project (creates+closes
# clearly marked issues) — sandbox projects onlyPublishing
npm publish runs the full gate and the build automatically (prepublishOnly). The
package ships only bin/ (Node-safe launcher), dist/tracker.js (bundled CLI), the
example config, README, and LICENSE — no sources, tests, or local config.
