tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
# Cycles: time boxes for issues

A **cycle** is a named time box — a sprint, a release window, a fortnight. Each
cycle is a Markdown file under `docs/cycles/`, and an issue joins it **by
name**, never by date.

## Where cycles live

One file per cycle: `docs/cycles/<slug>.md`. The cycle's identity is the
frontmatter `name`; the filename is just a slug of it, so renaming the file is
harmless as long as `name` follows. Everything in the frontmatter is optional:

```yaml
---
name: Sprint 7
description: September sprint
start_date: 2026-09-01    # YYYY-MM-DD
due_date: 2026-09-14      # YYYY-MM-DD, >= start_date
---

The free-text body is for notes; `tix` never interprets it.
```

Dates are checked when the file loads: a malformed date is refused, and a
`due_date` before its `start_date` is a config error naming both values, so a
broken cycle never loads as a silently ignored one.

### The implicit `Default` cycle

Every project has a `Default` cycle with no end date, so it is always the
current one. It is **implicit**: there is no `default.md`, and a project that
has never added a cycle has no `docs/cycles/` directory at all. `tix init`
leaves the directory absent for exactly this reason.

The directory is created the first time a cycle beyond `Default` is written
(`tix cycle create`). Reading never creates it.

### Legacy `tix.toml` cycles

Older projects declared cycles as tables in `tix.toml`:

```toml
[cycles."Cycle 1"]           # a name and nothing else is a valid cycle

[cycles."Sprint 7"]
description = "September sprint"
start_date = "2026-09-01"
due_date = "2026-09-14"
```

When `docs/cycles/` does **not** exist, those tables load unchanged. When the
directory exists, its files win and the tables are ignored. `tix init` seeds
only the implicit `Default` cycle (written as an empty `[cycles.Default]`
table, with no `docs/cycles/` directory); move a project to the file store
simply by creating a second cycle (`tix cycle create --name …`), which writes
`docs/cycles/` and drops the cycles from `tix.toml`.

## How an issue joins a cycle

An issue carries a `cycles:` list in its frontmatter, and each entry is the
cycle's **name**:

```console
$ tix create --title "Fix login bug" --cycles "Sprint 7"
Created herbert-maschke/2026-10-01-fix-login-bug.md
$ grep -A1 '^cycles' docs/issues/herbert-maschke/2026-10-01-fix-login-bug.md
cycles:
- Sprint 7
```

This is the part people get wrong: the dates on a cycle do **not** put an issue
in it, and they do not keep an issue out. Membership is exactly the list in the
file. A cycle with no `start_date` and no `due_date` is perfectly usable — the
implicit `Default` is exactly that.

### New issues join the running cycle by default

When **exactly one** cycle is running today, an issue created **without** an
explicit cycle selection joins it. A cycle *runs* while its `start_date` is
present and not in the future and its `due_date` is either absent (open-ended)
or still in the future; today counts on both the first and the last day. So an
`October 2026` cycle carrying those dates claims new issues through October
without anyone selecting it:

```console
$ tix cycle list
Default				0
October 2026	October sprint	2026-10-01	2026-10-31	0
$ tix create --title "Fix login bug"
$ grep -A1 '^cycles' docs/issues/herbert-maschke/*fix-login-bug.md
cycles:
- October 2026
```

When the automatic answer is **ambiguous** — no cycle is running, or two or more
overlap today — tix falls back rather than guesses: the configured
`[planning].default_cycle` when it names a cycle that exists, else the implicit
`Default` cycle — which every project has, file or no file. Because the implicit
`Default` has no dates it never runs, so a project that has only `Default`
behaves exactly as before:

```console
$ tix create --title "Untriaged work"
$ grep -A1 '^cycles' docs/issues/unassigned/*untriaged-work.md
cycles:
- Default
```

An explicit selection always wins, and `tix edit <issue> --cycles …` replaces the
list exactly as before (a blank `--cycles ""` clears it) — the automatic
assignment and its fallback are applied on **create** only. The same rule runs
from the CLI and from the TUI's new-issue form.

The name is validated when you write it. A name no cycle declares is refused,
and the error lists the ones that exist:

```console
$ tix create --title "Unknown cycle test" --cycles "Nonexistent"
tix: invalid input: unknown cycle `Nonexistent` — valid cycles: Default, Sprint 7
```

An issue may be in more than one cycle (give `--cycles` a comma-separated list).
`tix edit <issue> --cycles <list>` replaces the whole list, and `--cycles ""`
clears it.

## Managing cycles

```console
$ tix cycle create --name "Sprint 7" --description "September sprint" \
      --start-date 2026-09-01 --due-date 2026-09-14
Sprint 7
$ tix cycle create
Cycle 2
```

`create` prints the name it used and writes `docs/cycles/<slug>.md` (creating
the directory on first use). With no `--name` it takes the next `Cycle N` from
the monotonic `[planning].cycle_number` counter, skipping names already taken,
and bumps the counter for next time. A blank name, or one already in use, is
refused:

```console
$ tix cycle create --name "Sprint 7"
tix: invalid input: cycle `Sprint 7` already exists
```

When a cycle is created **with a start date**, its window is compared against
every existing cycle and the intersecting ones are named on stderr. The cycle is
still created either way, and a cycle created without a start date has no window
and warns about nothing. Windows touch when they share a day, and an open-ended
cycle (no `due_date`) meets every later one:

```console
$ tix cycle create --name "Late October" --start-date 2026-10-20 --due-date 2026-11-10
Late October
warning: cycle `Late October` overlaps existing cycle(s): October 2026
```

The same warning appears as a status note when a cycle is created from the TUI's
planning surface.

`list` prints five tab-separated fields and no header: name, description, start
date, due date, and how many issues name that cycle. Once the file store exists
the implicit `Default` cycle is listed too:

```console
$ tix cycle list
Default				0
Cycle 2				1
Sprint 7	September sprint	2026-09-01	2026-09-14	2
```

`edit` identifies the cycle with `--name` and sets the same fields `create` does;
an empty string clears a field. Renaming is the interesting case:

```console
$ tix cycle edit --name "Sprint 7" --new-name "Sprint Seven"
renamed cycle `Sprint 7` to `Sprint Seven`; updated 2 issue(s)
```

`--new-name` renames the cycle's file and rewrites **every issue that
referenced the old name**, so no file is left pointing at a cycle that no longer
exists. The date fields are validated before anything is saved, and a bad value
writes nothing.

`delete` removes the cycle's file and reports the issues that still name it:

```console
$ tix cycle delete --name "Cycle 2"
cycle `Cycle 2` is referenced by 1 issue(s): herbert-maschke/2026-10-01-docs-pass.md
Delete cycle 'Cycle 2'? [y/N] y
deleted cycle `Cycle 2`
note: issue cycle references were left unchanged
```

Deleting a cycle **does not touch any issue file**. The dangling `- Cycle 2`
entry stays in the frontmatter; `delete` names the issues for you and leaves the
cleanup to you (`tix edit <n> --cycles ...`). `--yes` skips the prompt, and when
stdin is not a terminal the prompt is skipped automatically, so a scripted delete
just proceeds.

## The Cycles mode (F4)

Press `F4` (or pick **Cycles** in the vertical navigation, or click a cycle's
limb under it) to open the Cycles mode. It lists every cycle — including the
implicit `Default` — grouped by where
today falls in each one, and each row carries the cycle's **name**, **start
date**, and **due date**:

```
 cycles ──────────────────────────────────────────────────────────────
 Current (2)
   Default   — → —
 > Ongoing   2026-09-01 → —
 Future (1)
   Later     2027-01-01 → 2027-02-15
 Archived (1)
   Sprint 7  2026-08-01 → 2026-08-14

 Ongoing
 Issues: 4   Completed: 2/4 (50%)

 j/k: move · m: manage cycles/milestones · q: close
```

- **Current** — today is inside the window, or the cycle has **no due date** at
  all (`Default` is always here).
- **Future** — the start date is still ahead.
- **Archived** — the due date has passed.

Within a group the rows run oldest first, so the listing reads chronologically
top to bottom.

The **statistics** under the list describe the selected cycle: how many issues
name it and how many of those are complete, via the same membership and
completion rules as `tix cycle list` and the board. An empty cycle renders
`0/0 (0%)`, never an error.

Everything is operable by keyboard and mouse: `j`/`k` (or the arrow keys) move
the selection, clicking a row selects it, and the vertical navigation highlights
the active mode. Press `m` to open the planning-management surface — the same
one the board opens with `m` — where cycles and milestones are created,
renamed, edited, and deleted (`n`, `e`, `x`, `Tab`, `Esc`). Closing it returns
to whatever opened it: the Cycle mode comes back with its selection intact.

## Rolling an expired cycle forward

A cycle is **expired** when its `due_date` is before today. `tix rollover` moves
every open issue out of an expired cycle and into that cycle's **successor**: the
other cycle with the earliest `start_date` that is today or later. Ties go to the
name, and a cycle with no `start_date` is never a successor.

Issues that are already done, and issues that already name the successor, are
left alone. An expired cycle with no successor keeps its issues.

```console
$ tix cycle list
Sprint Seven	September sprint	2026-09-01	2026-09-20	2
Old Sprint		2026-08-01	2026-09-14	2
New Sprint		2026-10-05	2026-10-18	0
$ tix rollover --cycle "Old Sprint"
moved 1 issue(s) from "Old Sprint" to "New Sprint"
```

`--cycle` restricts the run to one source cycle; without it, every expired cycle
with a successor is rolled in a single pass. `--yes` skips the confirmation, and
when stdin is not a terminal the confirmation is skipped automatically — a script
or CI run applies the rollover. On a terminal you are asked, and anything other
than `y` aborts:

```console
$ tix rollover
Apply rollover for 1 issue(s)? [y/N] n
aborted
```

When nothing is expired, or nothing is left to move, the command says so and
exits successfully:

```console
$ tix rollover
no rollover needed
```

Every other `tix` command prints a one-line hint on stderr while a rollover is
pending, so an expired cycle is hard to forget:

```console
$ tix create --title "Another"
hint: rollover of 1 issue(s) from "Old" to "New" is suggested; run 'tix rollover' to apply
Created herbert-maschke/2026-10-01-another.md
```

## Seeing cycles on the board

`tix list` can carry a `cycle` column (`--columns num,title,status,priority,cycle`),
and `--group cycle` groups the board by cycle with a completion count:

```console
$ tix list --all --group cycle --columns num,title,cycle
NUM  ID        TITLE  CYCLE
-- cycle: Sprint (0/1 done, 0%) --
1    K3M7QZ8A  X      Sprint
```

Both read the same membership: the issues whose `cycles:` list names the cycle.