tix-tracker 0.5.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation

tix — a terminal issue tracker

tix is a kanban-style issue tracker that lives entirely in your repository: issues are plain Markdown + YAML frontmatter files. There is no database, no server, and no proprietary format — you get a terminal UI (TUI), a full CLI, and optional GitLab/Linear sync in one Rust binary.

Heavily AI assisted. This project was designed, specified, and built end-to-end with AI agents under a spec-kitty-SPEC style governed workflow (spec → plan → tasks → implement → review → merge). All 6 work packages, 27 functional requirements, and 33 subtasks were implemented and reviewed by AI agents (implementer-ivan, reviewer-renata) steered by a human operator.

  • The human owns the repo and the decisions; the agents wrote the code.
  • Every feature went through a review cycle with real gate checks (cargo test, clippy -D warnings, cargo fmt) before merge.
  • The full mission trail lives in kitty-specs/issue-tracker-tui-01M27PTN/ (spec, plan, tasks, acceptance matrix, review report, retrospective) if you want an audit-grade record of how the software was produced.
  • How Spec Kitty and tix interoperate (.kittify/, missions, merge drivers, task mirroring) is documented in docs/spec-kitty-integration.md.
  • OpenSpec task mirroring and tix init-agents detection output are documented in docs/openspec-integration.md.
  • The MCP surface, including link_issue, lives in docs/mcp.md.
  • Task-oriented user guides for navigation, cycles, triage, new issue, and config live under docs/for-users/.
  • tix init-agents itself writes an AI-agent usage section into AGENTS.md, so other agents can operate this tracker autonomously.

Install

The crate is published to crates.io as tix-tracker (the binary is still called tix). See docs/packaging.md for the full maintainer workflow (crates.io account, publishing, binary releases).

cargo install tix-tracker    # build from source via crates.io

# or, with prebuilt binaries (fast, no Rust toolchain needed):
cargo install cargo-binstall --locked   # one-time, if you don't have it
cargo binstall tix-tracker

Local development installs (same binary) work too:

cargo install --path .

Requires a Rust toolchain (edition 2024, MSRV 1.85). The only runtime dependency is your favorite editor for tix edit.

Quickstart

tix init                        # create the default tix.toml (columns, issues_dir)
tix create --title "First issue" --priority high --labels docs
tix status                      # board summary: column -> count
tix list                        # all issues in a table
tix list --columns title,status # choose and order displayed columns
tix                             # open the kanban TUI

CLI

Command What it does
tix init Create a default tix.toml
tix create New issue (title/body/status/priority/assignee/labels/estimate/files)
tix new The same, in the TUI: opens the board with the create-issue form already open
tix list Table of issues, optional --status filter, --columns selection and --with-archived scope
tix view <issue> Full issue (frontmatter + body)
tix status Board summary: count per column + total
tix search <query> Substring-search issue titles and bodies (case-insensitive; --with-archived includes archived issues)
tix lint Validate issue frontmatter and report diagnostics
tix render Render the current board once without entering the TUI
tix edit <issue> Edit fields via flags, or full file via $EDITOR (--files)
tix move <issue> <column> Move between columns; done records a commit hash (auto-captured from HEAD, or --commit auto/<hash>)
tix link <a> <b> --type <kind> Add a typed relationship — blocks, blocked_by, duplicates, or related (required)
tix link list / tix link edit <id> / tix link remove <id> Inspect, retarget/re-kind, and remove relationships by their stable id
tix unlink <a> <b> --type <kind> Remove a relationship of that kind
tix chain <issue> Print the transitive blocks chain an issue holds up
tix archive <issue> / tix restore <issue> Hide an issue from the default views, or bring it back — by dot-prefixing its filename (details)
tix bulk edit|link|unlink|archive Batch over a selection; preview is the default, --apply writes (details)
tix cycle ... / tix milestone ... Create, list, edit, rename, and delete planning items; creating a cycle warns when its dates overlap another
tix workflow categories | show | transitions Print the status-category vocabulary, how this board resolves each column, and which transitions are permitted
tix delete <issue> --yes Remove an issue file
tix export --target gitlab|linear Push local issues to a remote tracker
tix import --target gitlab|linear Pull open issues from a remote tracker
tix init-agents Write/refresh the AI usage section in AGENTS.md

An issue can be referenced by its number, a unique filename fragment, or a unique title fragment.

tix init-agents detects .kittify/ and openspec/ independently and writes the corresponding tracking blocks into AGENTS.md. When it sees them, it also prints the matching detection lines to stdout so scripts can tell which blocks were emitted.

TUI

Launch with tix (no arguments) from the repository root. The complete, configurable key reference is docs/key-bindings.md; the list below is only a default overview.

tix new starts the same session with the create-issue form already open — one command to file an issue instead of "start the board, then press n".

Modes

Five top-level modes share one shell, listed by the vertical navigation on the left. A mode's function key switches to it; the key switches only when the active mode does not bind it itself, so F2 inside a form or a date field is still the picker. Each mode has its own guide:

Mode Key What it shows Guide
Board F1 the kanban board the keys below
Triage F2 the triage inbox triage
Cycle F3 cycles grouped current / future / archived, with statistics cycles
New Issue F4 the create-issue form, with an explicit Triage/Todo target new issue
Config F12 dashboards, cycles, and milestones in one surface config

The shell itself — mode switching, the navigation display states, and per-mode ? help — is described in navigation.

Board mode

  • Arrows / h/l — move between columns, j/k — select issues
  • PgUp/PgDn — move a screen of cards at a time in the current column
  • Enter — view the full issue, n (or F4) — the New Issue mode
  • Shift+C — clone the selected issue into the New Issue mode, prefilled
  • u — show each issue's sub-issues just below it, indented one level with a └ marker (press again to hide them). Only one level is shown: a sub-issue's own sub-issues are not nested further
  • F4 / n — create an issue: choose the Triage or Todo target, optionally pick a [templates.<name>] template to pre-fill the form, then Enter. See new issue
  • ? — the keybindings overlay: / searches it (substring first, fuzzy when there is no substring match), j/k or ↓/↑ a row, PgUp/PgDn a page, the wheel; the first page in a longer list says how many rows it is showing
  • e — edit the issue in your $EDITOR
  • v — view the issue read-only in the configured viewer (by default the first of mdterm, batcat, most, less, more on your PATH; override with viewer in tix.toml)
  • d — delete (with confirmation)
  • m — manage cycles and milestones (browse with ↓/↑ or j/k, switch segments with Tab/Shift+Tab, n create, e edit, x delete; q/Esc returns to the board). The same lists are the Config mode's Cycles and Milestones sections, where a delete confirmation names how many issues reference the record. See cycles.
  • / — search the live board: a bare term is a case-insensitive substring (so /greedy finds issues containing "greedy"), a bare number such as /55 opens issue 55, archived issues are searched too, and the row beneath the tabs shows the active search
  • q / Esc — quit

Editing an issue while someone else changes it. Pressing e snapshots the issue file and opens a copy in your editor; when the editor exits, tix compares the file's bytes with that snapshot. If something else wrote the issue in the meantime — an agent through MCP, another session, a watcher's repair — the edit is not written. An Edit Conflict prompt says so, names the newer file and the tix-edit-<pid>-<number>.md copy your edit is still in (the temp directory — /tmp, or $TMPDIR), and offers three ways out: o (or y) overwrites the newer file with your edit, a/Esc keeps the newer file and discards your edit, and c (or r) reopens the editor on the copy so you can compare the two, coming back to the same prompt afterwards — re-reading the file, so a change made while you compare is noticed too. Esc is the safe default: only the overwrite chord writes anything, so a concurrent write is never clobbered by a keypress that was not a decision. Keys: docs/key-bindings.md.

In the issue detail view:

  • n — link this issue to another, then name the kind. The target is a selector (3, #3, or a title fragment) and the kind is required — there is no default, so a relationship can never be created untypeable
  • j / k (or ↓ / ↑) — move the relationship cursor; the selected edge is marked with > in the links: row
  • E — re-kind or retarget the selected relationship
  • d — remove the selected relationship, after a confirmation that names the edge
  • c — comment, a — attach an asset, v — view the issue read-only in the configured viewer, ? — help

Every one of these goes through the same service the CLI and MCP use, and reports the same wording, so a relationship created in the TUI is byte-identical to one created with tix link.

The TUI also supports mouse interaction: click to select cards and controls, double-click to open details, drag cards between columns, right-click for issue actions, scroll long views, and hover for visual feedback. Keyboard operation remains available.

In the Config mode (F12, or s) the surface is two panes — the section rail and the active section's body. Left/Right (or h/l) move the focus between the panes, ↑/↓ (or j/k) move inside the focused pane (the section in the rail, the row in the body), Enter steps from the rail into the body and then activates the selected row, and Space toggles the selected row; n, e, and x add, rename, and delete the active section's records. Board edits are written to docs/boards/<name>.md, cycle and milestone edits to their docs/cycles/ and docs/milestones/ files, and every other setting stays in tix.toml. See config.

In the New Issue mode's form (n, F4) and in the filter dialog (f) the Tab / Shift+Tab or ↑ / ↓ keys move between fields/rows. Date fields are masked and picker-assisted: type digits (separators are added automatically) to enter a YYYY-MM-DD date, or press Enter (when empty) or F2 to open a calendar (arrows/PgUp/PgDn navigate, Enter selects, c clears, Esc cancels).

The Status and Priority rows are dropdowns rather than text: Enter or F2 on either opens its options, and typing does not edit the row. Status offers the board's columns plus the row's current value when the board no longer configures it (no inline create); Priority offers low/medium/high plus every priority the project already uses, and + still creates a custom one. An issue created without choosing a cycle joins the single cycle running today; when none or several run, it falls back to the configured [planning].default_cycle, and to the implicit Default when that is unset or unknown. See new issue and cycles.

External sync

tix export/import talks to GitLab through the glab CLI and to Linear through linear-cli. If the tool is missing, tix fails with a clear, actionable error. Repeated exports are idempotent: issues are matched by their gitlab_id / linear_id, and conflicts resolve in favor of the newer updated timestamp.

Storage format

Every issue is a file like docs/issues/alice/2026-09-11-my-title.md:

---
number: 1
title: My issue
status: todo
priority: high
labels: bug, docs
assignee: @alice
links: [12]
files: [openspec/specs/tui-keybindings/spec.md]
created: 2026-09-11
updated: 2026-09-11
---
The body is plain markdown — screenshots and diagrams work as usual.

Human-readable, human-edit-able, git-friendly. No data is locked away.

tix re-renders the frontmatter in its own style on write. A hand-edited file is not preserved byte for byte: list items are re-indented flush left, and strings may come back with different quoting.

$ cat issue.md | grep -A 3 cycles:
cycles:
  - Alpha        # you wrote it indented

$ tix view 1 > copy.md && diff issue.md copy.md
9,10c9,10
<   - Alpha
<   - Beta
---
> - Alpha
> - Beta

Nothing is lost — every field survives and re-parses to the same issue — but tix view <n> > copy.md will show a diff on a hand-edited file. This is pre-existing and general (cycles, milestones, and relationships all normalise); it is noted here because "human-editable" implies your formatting survives, and for the frontmatter it does not. The ## History section is preserved verbatim instead — see kitty-specs/…/data-model.md §8.

Custom frontmatter keys

A top-level key tix does not recognise is preserved, byte for byte, and serde_yaml's re-quoting never touches it. It is spliced back into the frontmatter on every write, after the known keys, in sorted order.

$ cat issue.md
---
number: 1
title: My issue
build_id: ci-4471        # not a tix field
---

$ tix edit 1 --priority high && grep build_id issue.md
build_id: ci-4471        # still there

tix lint reports an unrecognised key as a warning — printed, counted, and exit 0. A file carrying a custom key is a valid file, so failing a build over it would report a non-problem as a broken one:

$ tix lint; echo "exit: $?"
warning[W-tix-lint]: unknown top-level frontmatter key `build_id`: it is preserved
  on write but nothing reads it. If that is deliberate, declare it in
  `[custom_attributes]` in tix.toml; if not, it is probably a typo.
Lint OK with 1 warning: 1 issues checked.
exit: 0

The warning exists because an unrecognised key is usually a typo. titel: round-trips silently and is read by nothing, which is a far worse failure than a key that was dropped. Declare the ones you mean:

[custom_attributes]
keys = ["build_id", "reviewed_by"]

Names only, top-level only — there is no dotted-path syntax, because the thing being declared is a top-level key. Declared keys do not warn.

A new project gets the shape from tix init, which writes a commented example you only need to uncomment when you actually have a custom key:

# [custom_attributes]
# keys = ["build_id", "reviewed_by"]

tix.toml is re-read before an editor return is linted, so declaring a key while a TUI session is open takes effect on the very next edit rather than after a restart. A tix.toml that does not load is reported and the previous configuration is kept, so a half-saved file cannot break the session.

The TUI offers the same fix where the problem appears. Saving a file the frontmatter made unreadable — or one tix lint rejects, or one whose title was lost — raises the edit repair modal, which lists every problem with its diagnostic and a proposed fix. a applies the first proposed fix: for an undeclared key that is the declaration, appended to [custom_attributes] in tix.toml, and d declares it explicitly. (y applies too, because it is the accept key everywhere else.) A key already declared is not appended twice, and a problem with nothing to declare says so.

Nothing is discarded automatically. The editor's output is kept as a retained copy, named in the modal: r reopens the editor on it, i/Enter/Esc acknowledges and keeps it, and only a confirmed x removes it. A fix operates on that copy and re-parses it, so a fix that does not make the file valid leaves everything untouched and re-reports. Before any write replaces the issue, the previous content is saved beside it as <name>.tixbak and named in the status line. The CLI's tix edit behaves the same way: it reports the real parse error and keeps the copy it names rather than deleting it. See key bindings for the modal's keys.

The retired links: key is the exception that proves the rule: it is recognised but never preserved, reported by its own retired_links_key finding and then dropped, so the typed-relationship migration is not silently undone. See the breaking-change note below. files records repository-relative governing specifications or supporting documents; relationships records the typed edges below. tix create, tix edit, tix link, and tix unlink maintain these fields, and the TUI detail view displays them.

Relationships

Four kinds, and the difference is not cosmetic — only two of them affect the board:

--type Meaning Blocks the target?
blocks this issue blocks the target yes
blocked_by the inverse view of the same edge yes (stored canonically as blocks)
duplicates the target restates this issue no
related a non-directional association no

blocks is directional and reads left to right: tix link 12 34 --type blocks means "12 blocks 34", so 34 is blocked. A related edge is order-normalised and stored once, in the canonical source's file — so an issue's own relationships field is deliberately partial. tix link list and the derived views read the whole graph, so both endpoints see it.

$ tix link 12 34 --type blocks
Linked #12 blocks #34 (rel_01JQ8Z4W2M6N7P9R3S5T7V9X1)

$ tix view 34 --show relationships
...
## Relationships
- #12 --blocks--> #34  Refactor store  [rel_01JQ8Z4W2M6N7P9R3S5T7V9X1]

## Dependency chain
No dependency chain for #34

## Blocked
- blocked: Blocked by #12 Fix the parser

Blocked state. An issue with an open inbound blocks edge is blocked. That is visible in tix list --columns blocked --show relationships, in tix view --show relationships, and as a marker on the TUI card.

The blocked-move guard. Moving a blocked issue into the board's completion column is refused unless you confirm it:

$ tix move 34 done
warning: blocked: Blocked by #12 Fix the parser
tix: invalid input: blocked: Blocked by #12 Fix the parser — moving to 'done' with
open blockers requires an explicit confirmation; re-run with --yes to record the
decision

$ tix move 34 done --yes --commit abc1234
Acknowledged blocked move to done

Note that tix move <n> done also needs a commit hash. In a git repository it auto-captures HEAD; outside one you must pass --commit or --commit auto, so the refusal above is about the blockers and the commit is a separate requirement.

tix edit --status is a move too and answers the same way. Confirming records a blocked_move_acknowledged entry in the issue's ## History, so the decision is auditable rather than implicit. The TUI asks before it writes, and the MCP move_issue tool takes yes.

⚠️ Breaking change: the links: frontmatter key is gone

The generic links: [12] field has been removed and replaced by the typed relationships field above.

  • There is no migration, and no compatibility layer. A file that still carries links: parses, but the value is never read as relationship data — it is not converted to related or to anything else.
  • tix lint reports such a file as retired_links_key and tells you what to do.
  • The key disappears on the next write of that issue.
  • tix create --links, tix edit --links, and the MCP links argument are rejected, not ignored. An integration that used to pass one now gets an explicit error naming tix link / the link_issue tool, rather than a success response and silently lost relationships.

To convert, read the old list and re-create each edge:

$ tix link 12 34 --type related     # was: links: [34]
$ tix link 34 35 --type blocks      # was: links: [35] on 34

An empty links: [] is not reported — it has nothing to lose, and flagging it would make tix lint reject every file written before this change.

Migrating a whole repository at once:

# report which files still carry the retired key
tix lint | grep retired_links_key

Parent issues

An issue can have one parent. That makes a decomposition tree — this is part of that — and it is deliberately not a dependency edge. "Must happen before" is a --type blocks relationship; "part of" is a parent. Using a parent to mean ordering is the mistake this distinction exists to prevent.

tix create --title "Refactor store" --parent 3        # a child of #3
tix edit 7 --parent 3                                 # set or change
tix edit 7 --parent ""                                # clear it
tix parent show 3                                     # one issue's hierarchy
tix parent children 3                                 # its direct children
tix parent list                                       # the whole board
tix search "parent:3"                                 # the same set, filtered
$ tix parent show 3
Parent of #3
  parent: none
  children: 2
  completed: 1/2 (direct children only)
Children
  #7 Refactor store
  #9 Add a parser

One parent, never a list. parent: holds a single number. Two parents are not expressible, not merely rejected.

What is refused. An issue cannot be its own parent, and a parent change that would close a cycle is refused. A refused change writes nothing — no file, no number, no history entry — and the reason is the same text whichever surface you asked on (CLI, TUI or MCP).

Rollups count direct children only. 3/2 over a subtree and 3/2 over direct children are different numbers wearing the same clothes, so the completion line always names the population: (direct children only).

Deleting a parent keeps its children. The reference survives, and tix lint reports it as unresolved_parent naming the child and the missing target, so it can be repaired:

$ tix delete 3 --yes
  #7 Refactor store  - keeps `parent: 3`
$ tix lint
error[E-tix-lint]: invalid unresolved_parent value '3'
  --> herbert-maschke/2026-09-29-refactor-store.md:8:9
   |
 8 | parent: 3
   |         ^ 2's parent #3 no longer exists (the reference is kept so it can be repaired)

Cascading a delete is never what happens: silently destroying a subtree because one node went away is not a thing this tool does.

Batch editing

tix bulk does the same work over many issues with one preview and one confirmation. It is the same plan, the same typed patch and the same apply the single-issue path uses — there is no second implementation, on any surface.

# What would change? Nothing is written.
tix bulk edit --filter 'status:todo label:bug' --priority high

# Do it.
tix bulk edit --filter 'status:todo label:bug' --priority high --apply

# Link every selected issue to one target.
tix bulk link --select 1,2,3 --target 42 --type blocks --apply

Selecting

Name exactly one of three grammars. They are different languages, so a bulk operation will not guess which issues you meant:

Flag Grammar
--select 1,2,3 explicit: numbers, filename fragments or title fragments, comma-separated
--filter 'status:todo label:bug' the same structured filter the filter modal builds
--search 'fix crash' the same search the search box runs

--scope chooses active (default), all (active and archived) or archived.

What can change

Nine fields, and the list is closed: status, priority, assignee, labels, start-date, due-date, estimate, cycles, milestones.

--clear <field> empties one of them — anything except status, because a batch that empties a column has no target column. One per invocation; a second is refused rather than quietly dropped.

A blank flag value means unchanged, not empty. --estimate "" leaves the estimate alone. To empty a field, name it in --clear. This is the single most common surprise, and it is deliberate: the alternative makes it impossible to clear a field with a flag and impossible to tell "I left it blank on purpose" from "I did not mention it".

title and body are deliberately not in the list. A batch title rewrite is the one bulk operation nobody has asked for and everybody would regret, and a closed list is what makes "what can a batch do" a single answer.

Preview, apply, and what a partial batch means

Preview is the default; --apply writes. A preview touches no file — not the issue, not its History, not a relationship — and cancelling one changes nothing, because the only write in the whole path is the apply.

Each selected issue is processed independently. A refused or failed item is reported and does not roll back the successful ones; there is no all-or-nothing mode. Every selected issue gets exactly one outcome line:

Status Meaning
applied the requested state was written
no-op it was already in that state — nothing written, nothing appended
rejected refused at plan time; nothing was attempted
failed the write failed; whatever succeeded before it stands

Re-running is idempotent, and says so: a second run reports no-op rather than repeating the work or quietly succeeding.

A partial batch exits non-zero. Zero means everything requested happened, and the output does not claim otherwise.

Pairs and archive

tix bulk link / unlink need --target and --type. The type is the relationship service's own vocabulary — there is no default kind, so an untyped pair is never created by accident — and each selected issue becomes its own pair with the target.

tix bulk archive is not available. Single-issue archive and restore exist (details); doing it to a selection, with a preview and per-item outcomes, is a separate mission's scope, and a second archive implementation is exactly what this tool does not want. The request is refused by name, per selected issue, so you can see what it would have archived.

The TUI and MCP

B opens the batch surface on the board; space marks a card, x unmarks it, X clears the selection. p previews, y arms, y again writes, esc cancels. A batch is bounded at 200 selected issues, and the bound is in the header so you can see where you are.

Over MCP the same work is three tools — bulk_preview (read-only), bulk_apply (requires confirm: true) and bulk_capabilities (what this build supports). The schema is generated from the same whitelist the CLI and the docs above are generated from, so a field cannot be advertised on one surface and ignored on another.

Archiving

Archiving takes an issue out of the default views without deleting it. The marker is the filename: the file is renamed in place to a dot-prefixed name.

$ tix archive 1
Archived herbert-maschke/.2026-10-01-old-thing.md
$ tix list
NUM  TITLE       STATUS  PRIORITY
2    Live thing  todo    medium
$ tix show 1                     # still resolves, by number, title or slug
number: 1
title: Old thing
$ tix restore 1
Restored herbert-maschke/2026-10-01-old-thing.md

Nothing else moves. No file is deleted or moved between directories, and number, status, created and every frontmatter field stay byte-identical — archiving is not a softer tix delete, it is a different thing, and the file is still there. Each operation appends one ## History event (archived / restored), and a redundant call is refused by name rather than recorded twice:

$ tix restore 1
tix: invalid input: not_archived: issue #1 (herbert-maschke/2026-10-01-old-thing.md) is already active

An archived issue keeps its number: tix create allocates above every issue, archived ones included, so a number is never recycled onto a different issue.

Seeing them again. Default views do not read archived files at all — that is the point, and it is what keeps a repository with thousands of archived issues as fast to open as an empty one. --with-archived is the scope flag, and archived:true|false is the criterion:

$ tix list --with-archived
NUM  TITLE       STATUS  PRIORITY  ARCHIVED
1    Old thing   todo    medium    ARCHIVED
2    Live thing  todo    medium    -
$ tix list --with-archived --filter 'archived:true'
NUM  TITLE      STATUS  PRIORITY  ARCHIVED
1    Old thing  todo    medium    ARCHIVED
$ tix search --with-archived "Old thing"
#1 Old thing — todo (herbert-maschke/.2026-10-01-old-thing.md)

The scope flag is what loads the archived set; the criterion only narrows what is loaded, so --filter 'archived:true' on its own has nothing to match. In the TUI, a toggles them and archived cards are marked [archived] (keys).

Workflow status categories

A board's columns are yours. tix does not have a second notion of "state" — a column name is still what status: holds, and there is no database, no hosted workflow engine and no new file to keep in sync. What this adds is a way to say what a column means, once, and have every surface use that answer.

Map your columns into a global, closed vocabulary:

[kanban.default]
columns = ["triage", "wip", "review", "shipped", "abandoned"]
done_column = "shipped"

[kanban.default.category_map]
triage    = "backlog"
wip       = "active"
review    = "active"
shipped   = "completed"
abandoned = "canceled"
Category Meaning
backlog Captured, not started
unstarted Ready, not started
active In progress
blocked Stopped on something
completed The work is done
canceled Terminal, and not done

completed and canceled are both terminal. Only completed is ever counted as finished — a canceled issue reads as "we stopped" in every report, and tix show says so explicitly (workflow: terminal, not complete) so nobody has to infer it from the word.

tix workflow categories prints the vocabulary in the order adjacency is evaluated against. tix workflow show prints what each of your columns resolved to and where that answer came from — category_map or the safe default. tix workflow transitions prints the permitted moves.

Columns with no mapping

A column you configure but do not map resolves to the safe default, unstarted. That choice is deliberate: it is neither terminal nor complete, so an unmapped column is never silently counted as finished. tix lint reports it as a single unmapped_column_default warning per board, naming every affected column — one warning rather than one per column, because a board that declares some mappings and leaves the rest is a normal configuration, not a fault.

A column the board does not configure at all is different, and every surface says so the same way: (unmapped) in tix list --show category, (unmapped) in tix show, null over MCP. It is a configuration error, and it is visible rather than defaulted.

Transition policy, and how to leave a terminal state

A board that declares a category_map gets the transition policy. A board that does not, does not. That split is the whole compatibility story: every existing repository keeps its completion detection and its commit-clearing rule, and gains no new refusals. A category_map is the signal that you have described your categories, and describing them is opting in.

The policy itself:

  • an issue may not move backwards in the vocabulary order;
  • an issue in a terminal category may not leave it without a reason.

The reason is recorded in the issue's own History, next to the accepted decision:

tix move 12 review --reason "wrong epic"
tix edit 12 --status review --reason "wrong epic"

The TUI has no flags, so it prompts for the same reason and shows the identical message; MCP takes a reason argument with the same meaning. One escape, three spellings, one recorded decision.

A board whose category_map exists but omits completed is refused when the config loads — you have said what your categories are and left out the one every board needs. A board with no category_map at all is not refused, because there is no declared policy to violate.

Hand-edited files

tix lint compares each file's status: against the last transition recorded in its own History and reports a disagreement as direct_edit_policy_violation. It writes nothing — no repair, no appended entry, no rewrite. The file stays exactly as you left it and stays readable, and every other command keeps working on it. The finding is a report, not a load failure.

Triage inbox

Unreviewed work can enter a triage inbox before it joins the normal workflow. The inbox is a view over your own board columns — not a second tracker, a second workflow, or a queue database — and every decision is one History event in the issue file. User guide: docs/for-users/triage.md; design reference: docs/triage.md.

It is opt-in: the inbox exists because a [triage] section does — and tix init writes one, so a repository you have just created has an inbox already. Removing the section is what turns it off, and every tix triage command then refuses by naming it.

[triage]
entry_status = "triage"      # required — the inbox column
declined_status = "wontfix"  # required — must map to the `canceled` category
# accepted_status = "todo"   # optional — the default accept target
# deferred_status = "triage" # optional — defaults to entry_status
$ tix triage list
inbox: 4 entries (4 awaiting, 4 deferred)
NUM  AGE      TITLE
1    2465d    Alpha waits
2    unknown  Bravo legacy
3    1d       Charlie recent
4    2d       Delta

awaiting counts the entry column and deferred the deferred one. The fixture above configures no deferred_status, so the two are the same column and both figures are 4 — they answer different questions ("is it in the inbox" and "is it in the deferred column"), and a project that separates them sees them diverge. The ages depend on the day you run it; the counts and the unknown cell do not.

The TUI opens the same queue with t and decides with a/d/u/f (and p for everything marked); MCP exposes it as triage_list, triage_status, triage_preview and triage_apply (the write requires confirm: true). All three read one snapshot through the same service, so the counts, the outcomes and the error kinds are the same document.

Imported issues enter through an explicit, reported policy rather than bypassing review: tix import --target gitlab --triage-policy entry. Absent the flag, an import routes into the inbox only when the project has a [triage] section, and a project without one imports exactly as it did before the inbox existed.

Configuration

tix.toml (at the repository root) defines the issues directory, the milestones directory, optional [export] targets, optional cycles legacy tables, and optional TUI [keybindings]. Boards are records in the file store: each board is one docs/boards/<name>.md file whose frontmatter carries its columns and display settings (tix init writes docs/boards/default.md). Card display defaults to wrapped three-line titles sorted newest-first; these settings can be changed in the TUI config mode (F12). See docs/for-users/config.md for the Config mode, docs/for-users/navigation.md for the metadata stores, and docs/key-bindings.md for the complete key reference.

columns = ["todo", "in progress", "done", "mismatch"]
issues_dir = "docs/issues"

[export]
tokens = { gitlab = "/path/to/token" }
project = { gitlab = "", linear = "" }

Tokens are never serialized back out after configuration.

Two optional top-level keys are not board settings. editor overrides the full-file editor tix edit launches, and viewer overrides the read-only viewer the TUI's v key launches — its default, auto, uses the first of mdterm, batcat, most, less, more found on your PATH.

tix.toml reference

Boards live in docs/boards/<name>.md; the board named default is the one used by every command that takes no --board. A project that has no docs/boards/ directory still reads its legacy [kanban.<name>] tables in tix.toml, so an existing repository keeps working unchanged; once the directory exists, its files win.

Key Type Default Notes
columns string[] triage, todo, in progress, in review, done, wontfix (what tix init writes); todo, in progress, in review, done as the bare fallback The column names. status: holds one of these.
done_column string done if present, else the last column Which column means finished, for commit clearing. A board whose category_map places completed elsewhere gets its completion answer from the category, not from this key.
category_map table absent <column> = "<category>". Every value must be one of the six vocabulary keys. A key naming a column the board does not configure is a load error.
show_mismatch bool false Render a Mismatched column for issues in unconfigured columns.
order_by string created Card ordering within a column.
title_lines integer 3 Card title height.
wrap_titles bool true Wrap card titles.
hidden_columns string[] [] Columns collapsed in the TUI; still reachable by filter and search.

[categories] (top level, optional)

Key Type Default Notes
order string[] all six keys The vocabulary, in the project's preferred presentation order. It may be a subset, and it may not contain a word outside the global six — adding a key here is an unknown_category error, not a new category.
default string unstarted The category an unmapped column resolves to. Must be in order, and must not be completed — otherwise every unmapped column would silently count as finished.

[triage] (top level, optional)

tix init writes one, so a new repository has an inbox from the start; absent — a config written before the feature, or one that removed the section — there is no inbox and every tix triage command refuses, naming the section. Every named column is validated against the active board through the category service, so a typo is a load error naming the column rather than an empty queue. See docs/triage.md.

Key Type Default Notes
entry_status string required when the section exists The column that places an issue in the inbox.
declined_status string required when the section exists Where a decline lands. Must resolve to the canceled category.
accepted_status string absent The default target for an accept; --to overrides it per decision. With neither, an accept is refused missing_target rather than guessed.
deferred_status string entry_status The column an issue sits in while deferred.

[transitions] (top level, optional)

Key Type Default Notes
terminal_locked bool true Whether leaving a terminal category needs a reason. Only consulted on a board that declares a category_map.

All three sections are omitted from disk when untouched, so an existing tix.toml round-trips without acquiring the keys it never had.

Version

Current version: 0.5.0. See CHANGELOG.md.

License

MIT OR Apache-2.0.