tk: Why I Ported a Bash Ticket System to Go

I needed a ticket system that AI agents could use. Not Jira, not Linear — something that lives in the repo, stores tickets as files, and works from a CLI. I found wedow/ticket, a bash script that did exactly this. Then I rewrote it in Go.

The result is tk — a git-backed issue tracker where tickets are markdown files with YAML frontmatter in a .tickets/ directory.

$ tk create "Fix login bug" -t bug -p 1 --tags auth,urgent
tic-a1b2

$ tk start a1b2
Claimed tic-a1b2 -> in_progress

$ tk close a1b2
Updated tic-a1b2 -> closed

Why Files Over a Database?

The frustration that started this: watching an AI agent burn through its context window making GitHub API calls just to figure out what it should work on next. Paginated JSON, auth tokens, rate limits — all overhead that has nothing to do with the actual work.

With tk, tickets are just files. The agent reads them directly. tk ready shows what needs doing in one command. No network calls, no parsing API responses.

And git gives you version control for free. Every status change, every note is a commit. I didn't have to build an audit trail — I got one by default.

Tickets as Markdown

Each ticket is a .md file with YAML frontmatter for structured data and a markdown body for everything else. I chose this format because both humans and machines can work with it naturally — an agent can parse the frontmatter with tk list -t bug --status open, or read the full file to understand context. A human can just open it in their editor.

The Concurrency Problem

This is where the bash version fell apart. When I started building Loop's parallel ticket workflows, two agents would race for the same ticket and both think they got it.

The start command now uses flock to prevent this. The whole read-check-update cycle happens under an exclusive file lock:

func (s *Storage) AtomicClaim(id string) (*domain.Ticket, error) {
    path := filepath.Join(s.ticketsDir, id+".md")
    file, err := os.OpenFile(path, os.O_RDWR, 0644)
    if err != nil {
        return nil, fmt.Errorf("failed to open ticket file: %w", err)
    }
    defer func() { _ = file.Close() }()

    if err := lockFile(file); err != nil {
        return nil, fmt.Errorf("failed to acquire lock: %w", err)
    }
    defer func() { _ = unlockFile(file) }()

    // Read, check status, update — all under the lock
    data, err := os.ReadFile(path)
    // ...
    if ticket.Status != domain.StatusOpen {
        return nil, fmt.Errorf("%w: status is %s", ErrAlreadyClaimed, ticket.Status)
    }
    ticket.Status = domain.StatusInProgress
    // ... write back
}

lockFile is syscall.Flock on Unix, LockFileEx on Windows. Two agents racing to claim tic-a1b2 — one gets it, the other gets ErrAlreadyClaimed. This was the single biggest reason I ported from bash. You can do flock in bash, but getting the error handling right with deferred unlocks is fragile.

Dependencies and Cycle Detection

I added ticket dependencies because of how I structure work — break a feature into pieces, define what blocks what, then let agents pick up whatever's unblocked. tk ready only shows tickets whose dependencies are all resolved. tk blocked shows the opposite.

The tricky part was cycle detection. Without it, you could create A depends on B depends on A and nothing would ever be ready. So adding a dependency runs a DFS first to check for cycles before writing anything.

tk dep tree is probably the command I use most:

[ ] tic-a1b2 - Deploy new auth service
├── [x] tic-c3d4 - Update session handling
└── [~] tic-e5f6 - Migrate user tokens
    └── [x] tic-g7h8 - Add token encryption

At a glance I can see what's done and what's blocking. This saved me a lot of mental overhead when managing multi-ticket features.

Making It Work with AI Agents

The whole point of this project was agent integration, and I wanted the setup to be trivial. For Claude Code, it's two things:

Add to CLAUDE.md:

This project uses `tk` for ticket management. Run `tk` to see available commands.
Use `tk ready` to find work, `tk start <id>` to claim, `tk close <id>` when done.

Add to .claude/settings.local.json:

{
  "permissions": {
    "allow": ["Bash(tk *)"]
  }
}

That's it. The agent discovers commands via tk --help, queries state via tk list and tk ready, and manages tickets through the same CLI a human would use. No special API, no agent-specific protocol.

In Loop, tk powers the parallel ticket workflow — dispatcher, workers in isolated worktrees, merge tickets chaining back into main. I didn't have to write any orchestration code — the CLI was enough.

Why Go Over Bash

The original bash script worked for single-user use. But once I needed atomic operations, cross-platform support (flock_unix.go and flock_windows.go via build tags), and proper testing with race detection, bash wasn't going to cut it. go test -race caught two concurrency bugs during development that would have been silent in production with the bash version.

Distribution was the other win. brew install radutopala/tap/tk or go install — single binary, no dependencies. Try distributing a bash script that needs flock, yq, and specific GNU coreutils.

Install

brew install radutopala/tap/tk

Full source: github.com/radutopala/ticket.


← Back to home