tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
# Cycle capacity: what a scope may commit to

**Capacity** is what a cycle, a team or an assignee is allowed to commit to, counted
in an estimate unit the repository already declares. You configure a ceiling, and
`tix` reports — from one shared calculation — how much of it the scope's issues
commit, how much of that is complete, what remains, how far over the ceiling the
scope has gone, and how the two compare as a percentage.

Everything is local. Capacity lives in `tix.toml` and in the ordinary Markdown
records; a close captures its evidence under `docs/cycle_snapshots/`; no daemon, no
clock, no network and no hosted planner is involved, and no new dependency was
added. The same calculation feeds the CLI, the TUI's **Capacity** tab, the MCP
tools and the portable document, so none of those surfaces can print a number
another one would not.

The two neighbours this guide leans on are
[cycle schedules](cycle-schedules.md) (the generated cycle whose close captures the
capacity evidence) and the [work-in-progress limits](wip.md) (which own *what
estimate units exist, and how they convert*).

## A capacity entry is configuration

Ceilings live in `tix.toml` under `[capacity.<id>]`. The section is optional, and a
`tix.toml` that never held one gains no key and round-trips byte-identically:

```toml
[capacity.cycle-sprint-000001]
cycle = "sprint:000001"     # exactly one of cycle / team / assignee
capacity = 40               # at or above zero
unit = "d"                  # required for an enabled entry
enabled = true              # false keeps the numbers and enforces nothing
```

| Field | Required | Meaning |
| --- | --- | --- |
| `cycle` | one of the three | A `[cycles.<name>]` record's name, or a generated cycle's stable id (`sprint:000001`). |
| `team` | one of the three | A `[teams.<label>]` key. |
| `assignee` | one of the three | An identity in the shape an issue's `assignee` tolerates (a handle, or `Name <email>`). |
| `capacity` | yes (enabled) | The ceiling, at or above zero. A disabled entry may omit it. |
| `unit` | yes (enabled) | The estimate unit the ceiling counts in. A blank unit is refused. |
| `enabled` | no | Defaults to `true`. `false` keeps the scope configured without a ceiling in force. |
| *anything else* | no | Preserved verbatim, never interpreted. |

**A scope reference is a name *or* its stable id, and they are one scope.** For a
cycle, `sprint:000001` and `Weekly sprint 2026-01-05 000001` fold to one identity;
for an assignee, `@ada` and `ada` do. You may configure the ceiling under either
spelling and read it back under the other (C-003).

Validation happens **before** persistence and is field-aware: a value with no unit,
a unit no board declares, an unknown or ambiguous cycle/team/assignee reference, and
a second entry for one scope are each refused **by name**, and a refused write
changes nothing. A disabled entry is listed (the scope stays visible) but leaves
every derived value absent rather than zero.

```bash
tix capacity list
tix capacity show sprint:000001
tix capacity set --scope cycle --id sprint:000001 --value 40 --unit d
tix capacity set --scope team --id Platform --value 8 --unit w --disabled
tix capacity delete --scope cycle --id sprint:000001
```

`list` prints a header plus one tab-separated row per entry — the header prints even
with no entries, so an empty section is visibly empty rather than indistinguishable
from a failure.

## Units: explicit, or nothing at all

Estimate units are declared once, per board, under
`[kanban.<board>.wip.units]` — the same registry the [WIP limits](wip.md) use. A
capacity ceiling names one of them, and **arithmetic happens only after an explicit
conversion**: with `w = 5` declared, an issue estimated `1w` contributes 5 to a `d`
ceiling.

- There is **no implicit conversion** and **no inferred numeric unit**. Prose words
  fold to the symbol the board declares (`day` → `d`, `weeks` → `w`), and a compound
  estimate is converted term by term. Fractions and ranges are pessimistic
  (`0.5-1 day` counts as `1d`), and unreadable comma-separated annotations around a
  readable duration are ignored. A mixed scope is never silently summed.
- An estimate in a unit the board does not declare (or that cannot be read at all)
  is a `missing_conversion` diagnostic naming the scope, the unit and the offending
  issue — and a remedy. It contributes nothing to the total, and it is **never
  counted as zero** (C-004).
- An issue with **no estimate** is visible as `unestimated_issues`; it contributes
  nothing and is not a mistake.
- A value the calculation could not derive is `-` in text and `null` in JSON —
  **never `0`**. `0` is only ever a value that was counted and is zero. A scope with
  no ceiling in force is `unconfigured`, an explicit state; `uncountable` is the
  third state (a ceiling is in force but the snapshot resolved no conversion for its
  unit). A fresh one-shot CLI calculation always includes the ceiling's unit in the
  snapshot, so users normally see `configured`, `unconfigured`, or a per-issue
  `missing_conversion` finding. `uncountable` is the defensive state for a
  caller-held snapshot paired with a later configuration whose ceiling unit that
  snapshot never resolved. The TUI rebuilds the index and snapshot together when it
  reloads `tix.toml`, so it does not keep that mismatched pair after noticing a
  change.

## The formulas

Every value is derived from **one** issue snapshot — the scope's members are
resolved once and aggregated once (see [Performance](#performance)) — and uses the
shared status-category service ([`tix workflow`](../README.md#workflow-status-categories)),
never a status string or the board's `done_column`:

| Value | Definition |
| --- | --- |
| `committed` | Sum of the valid, converted estimates of the scope's issues, in the ceiling's unit. |
| `completed` | The part of `committed` whose issues the shared category service calls **complete** (`canceled` is terminal but not complete). |
| `remaining` | `committed - completed`. |
| `overflow` | `max(committed - capacity, 0)` — `0` at or under the ceiling. |
| `utilization` | `floor(100 * committed / capacity)` as a whole percentage; absent when the ceiling is `0` or unconfigured. |

The three counts sit beside them: `counted_issues`, `uncounted_issues` (each with a
diagnostic) and `unestimated_issues`. All of them come from the same
`CapacityCalculation` DTO on every surface (C-005/C-007).

## Warnings are shared, and a warning is a report

A finding carries a stable machine-readable `kind` and a sentence with a **reason**
*and* a **remedy**:

- `capacity_overflow` — the committed work exceeds the ceiling; it names the scope,
  the current total, the ceiling, the excess, the unit and the contributing issues.
- `missing_conversion` — an estimate could not be counted in the unit.
- `unknown_scope` / `ambiguous_scope` — a reference resolved nowhere, or to two
  things.

On the CLI, `tix capacity calculate --warnings` renders the **overflow** report to
stderr (a finding about an estimate or a reference always renders, because it
explains why a total is short). The values themselves are always on stdout.

**Overflow is a report, never a mutation.** Reading a capacity surface — calculating,
listing, showing, opening the TUI tab, calling an MCP tool, exporting — writes
nothing: no issue moves, no record changes, no snapshot is rewritten, and no
History event is appended (C-006).

## Detail views

- **CLI**: `tix capacity calculate --scope cycle|team|assignee --id <REF>`. Every
  derived field the calculation could not produce prints `-`.
- **TUI**: the `F8` **Schedule** page's **Capacity** tab (`Tab` steps between
  `[Series]`, `[Cycle schedules]` and `[Capacity]`; `j`/`k` move the selection, `q` returns to
  the board). It lists every configured scope with the shared values, an explicit
  empty state that names `tix capacity set`, and a distinct "could not read" state —
  a failed read is never drawn as "nothing is configured". The compact per-cycle cell
  is appended to the board's `[cycle]`-grouped section headers. The tab is
  read-only; rendering never resizes a ceiling.
- **MCP**: `capacity_list`, `capacity_show`, `capacity_calculate`,
  `capacity_snapshot` — the same library, the same DTO; see
  [the MCP reference]mcp.md#capacity-scopes-calculations-and-close-evidence.
- **Issue detail**: an issue's own detail names the scopes its `cycles` and
  `assignee` references give it, its own estimate, and — for an estimate that cannot
  be counted — the unit it could not be counted in.

## Closing a cycle: immutable evidence

`tix cycle schedule close <SCHEDULE> --cycle <ID> --reason <TEXT> --confirm`
captures the scope's calculation **once**, as immutable evidence under
`docs/cycle_snapshots/`:

```bash
tix cycle schedule close sprint --cycle sprint:000001 --reason "shipped" --confirm
tix capacity snapshot sprint:000001
tix cycle schedule reopen sprint --cycle sprint:000001 --reason "correction" --confirm
```

- The snapshot is written once and **never recomputed** from the live schedule; it
  survives a later schedule edit or rename, and a second capture cannot rewrite it
  (NFR-002).
- A **reopen** (or an edit) appends exactly one append-only `## History` event to the
  cycle record and leaves the closed snapshot **byte-identical**. Capacity
  configuration changes affect only current and future calculations; a
  configuration edit to a **closed** cycle is refused by name, and the remedy is the
  explicit reopen.
- The readback `tix capacity snapshot <CYCLE>` reads a **generated** cycle's
  evidence; a hand-written cycle has no generated snapshot and is refused with a
  remedy naming that. A snapshot written before this build carries no capacity
  payload: that reads as `evidence: none` — a value, never an error, and nothing is
  migrated in place.
- The History vocabulary carries the two events a cycle can actually produce:
  `cycle_closed` and `cycle_reopened`. The former reserved
  `cycle_capacity_edited` builder and writer were removed rather than kept as
  unreachable API that no surface could produce.

## The portable document

`tix export --target capacity [--cycle <REF>] [--output <PATH>]` writes a local,
loss-aware JSON document with the `tix-capacity` marker and `version = 1`:

- `scopes` — every configured scope with its configuration, the shared calculation
  and its findings;
- `snapshots` — every closed cycle's immutable evidence;
- `history` — every close/reopen capacity lifecycle event;
- `diagnostics` — document-level findings;
- `extensions` and (per node) the preserved catch-all — unknown metadata, kept in
  **two separate channels** so a preserved key can never shadow a projected field.

Every derived field is serialized as an explicit `null` when absent (never omitted),
and this build re-reads its own output byte-identically. Two boundaries are worth
stating plainly:

- **Preserved metadata is withheld per top-level key.** A preserved table with one
  leaf that has no JSON form (a TOML date is projected as an RFC 3339 *string*; a
  non-finite float, a YAML tag or a non-string key is not) is withheld **whole** and
  reported as a `metadata_not_carried` diagnostic that names the top-level key and
  the offending value — not a path into it. "Preserved verbatim" is a claim about the
  keys a document carries, not a promise that a partially representable value
  survives in part.
- **`breakdown` was narrowed inside `version = 1`.** A snapshot's optional team/
  assignee `breakdown` is a *nested bag* rather than a bare JSON value, so its
  contents get the same faithful projection as the rest; a hand-written non-object
  `breakdown` is refused by the reader. No document this build can write is affected —
  the writer only ever produced an object — and the format is unreleased, so the
  version still reads `1`.

**The remote boundary.** `tix export --target gitlab|linear` (and `tix import`)
mirror **issues** to a remote tracker through `glab`/`linear-cli`; that path is
unchanged and is not a capacity surface. Where a remote target is configured, its
inability to carry capacity is diagnosed explicitly as
`unsupported_remote_field`, naming the target and the field and stating that the
values stay local. A silent drop and an invented mapping are both worse than the
refusal, so local capacity data is never silently dropped. `--target capacity` is
dispatched by name before the remote tool lookup, so the two never mix.

## Performance

A calculation resolves its scope's conversions **once** and aggregates a single
issue snapshot; the CLI, the TUI tab and a multi-scope portable export share that one
pass rather than rescanning per scope. The mission's 10,000-issue requirement
(NFR-001) is measured **structurally** — the number of estimates parsed and
conversions resolved by the index, not a wall clock — by
`tests/capacity.rs::nfr_001_ten_thousand_issues_are_indexed_once_and_calculated_structurally`,
with the absolute-budget half measured in `tests/capacity_estimate.rs`.

## Escalation boundaries

This feature deliberately does **not**: introduce a hosted planning service or a
database; add an estimate unit or a conversion semantic; convert implicitly or infer
capacity from a mixed unit; destructively edit a snapshot or a History record; or
replace the cycle-schedule or status-category semantics (C-001/C-002/C-009). Each of
those is an escalation trigger rather than an implementation choice, and no new
dependency is approved by the mission that added capacity.

## Where the evidence lives

The cross-surface target is
[`tests/capacity.rs`](../tests/capacity.rs): one fixture read through the CLI, the
TUI (Capacity tab and board cell), MCP and the portable document, compared field for
field, with a positive control beside every negative or no-write claim. Per-surface
targets — `capacity_config.rs`, `capacity_estimate.rs`, `capacity_calculate.rs`,
`capacity_snapshot.rs`, `capacity_cli.rs`, `capacity_portable.rs`, `tests/mcp.rs`
and `tests/tui_state.rs` — prove each surface on its own; `mise run ready` runs the
whole suite.