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