tix-tracker 0.5.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
# 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`]docs/spec-kitty-integration.md.
> - OpenSpec task mirroring and `tix init-agents` detection output are
>   documented in [`docs/openspec-integration.md`]docs/openspec-integration.md.
> - The MCP surface, including `link_issue`, lives in [`docs/mcp.md`]docs/mcp.md.
> - Task-oriented user guides for
>   [navigation]docs/for-users/navigation.md, [cycles]docs/for-users/cycles.md,
>   [triage]docs/for-users/triage.md,
>   [new issue]docs/for-users/new-issue.md, and
>   [config]docs/for-users/config.md 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](docs/packaging.md) for the full
maintainer workflow (crates.io account, publishing, binary releases).

```bash
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:

```bash
cargo install --path .
```

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

## Quickstart

```bash
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]#archiving) |
| `tix bulk edit\|link\|unlink\|archive` | Batch over a selection; **preview is the default**, `--apply` writes ([details]#batch-editing) |
| `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`](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]docs/for-users/triage.md |
| Cycle | `F3` | cycles grouped current / future / archived, with statistics | [cycles]docs/for-users/cycles.md |
| New Issue | `F4` | the create-issue form, with an explicit Triage/Todo target | [new issue]docs/for-users/new-issue.md |
| Config | `F12` | dashboards, cycles, and milestones in one surface | [config]docs/for-users/config.md |

The shell itself — mode switching, the navigation display states, and per-mode
`?` help — is described in [navigation](docs/for-users/navigation.md).

### 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]docs/for-users/new-issue.md
- **?** — 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]docs/for-users/cycles.md.
- **/** — 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`](docs/key-bindings.md#modal-management-modes).

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](docs/for-users/config.md).

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](docs/for-users/new-issue.md) and
[cycles](docs/for-users/cycles.md).

## 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`:

```markdown
---
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.

```console
$ 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.

```console
$ 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:

```console
$ 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:

```toml
[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:

```toml
# [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](docs/key-bindings.md) 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.

```console
$ 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:

```console
$ 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:

```console
$ 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:

```bash
# 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](#relationships); "part of" is a parent. Using a
parent to mean ordering is the mistake this distinction exists to prevent.

```bash
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.

```bash
# 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](#archiving)); 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.

```console
$ 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:

```console
$ 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:

```console
$ 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](docs/key-bindings.md)).

## 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:

```toml
[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:

```sh
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`](docs/for-users/triage.md);
design reference: [`docs/triage.md`](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.

```toml
[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
```

```console
$ 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`](docs/for-users/config.md) for the Config mode,
[`docs/for-users/navigation.md`](docs/for-users/navigation.md) for the metadata
stores, and [`docs/key-bindings.md`](docs/key-bindings.md) for the complete key
reference.

```toml
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`](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](CHANGELOG.md).

## License

MIT OR Apache-2.0.