tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
# Work-in-progress limits

A board can cap what each column holds. The limits are configuration, they are
checked **once**, in one service every surface asks, and an authorized override is
the only way past a blocking one. Nothing here is a second status system: the
columns, their categories and what "done" means are still the board's
(`tix workflow`, tix #183).

## The configuration

Limits live in `tix.toml` under `[kanban.<board>.wip]`. Estimate units are
declared first, then one limit per column:

```toml
[kanban.default.wip.units]
d = 1      # a `d` is one base point
w = 5      # a `w` is five

[kanban.default.wip.limits."in progress"]
max_items = 3        # at most three cards
max_estimate = 10    # at most ten `d`
unit = "d"           # what `max_estimate` counts in
policy = "block"     # `warn` or `block` — required, never assumed
enabled = true       # default; `false` keeps the numbers, enforces nothing
```

Every field is validated before the file is written:

| Rule | Why |
|---|---|
| the column must be one of the board's `columns` | a limit on a column nothing renders is a limit nothing enforces |
| at least one ceiling on an **enabled** limit | a limit that limits nothing reads as a rule |
| `policy` required on an enabled limit | C-004: the choice is written down, never defaulted |
| `unit` required with `max_estimate`, refused without it | a unit on an item ceiling describes nothing |
| factors and ceilings at least 1 | a zero is not a ceiling |
| a disabled limit is still validated | it is configuration either way |

A rule for a **board file** project (`tix init` writes `docs/boards/<name>.md`): the
file owns the board's *shape* (its columns, categories and display) and `tix.toml`
owns its **limits**, so `tix wip set` works the same way in both kinds of
repository. (Before this was fixed, a scaffolded repository silently ignored every
limit: the board-file load replaced the whole `[kanban]` section.)

## Commands

```
tix wip show [--board BOARD]                     # units, then each column's limit and usage
tix wip set <column> [--max-items N] [--max-estimate N] [--unit U]
                     [--policy warn|block] [--disabled] [--board BOARD]
tix wip unset <column> [--board BOARD]
tix wip unit <name> <factor> | tix wip unit <name> --remove [--board BOARD]
```

`set` changes only the flags you name — like `tix recurring edit` — so raising a
ceiling keeps the policy and the estimate unit. `show` prints the findings (an
uncountable estimate, a column over its limit) on stderr, so its table stays
parseable.

## Estimate units and conversion

An issue's `estimate` is free text: `<number>`, `<number><unit>`, or a human
duration such as `1 day`, `2 days`, `3 weeks 2 hours`, `1d 30 min`. The unit
symbols are the ones the board declares; the words `day(s)`, `week(s)`, `hour(s)`,
`minute(s)` and `second(s)` fold to `d`, `w`, `h`, `m` and `s` (a one-letter unit is
left exactly as written). A compound duration is converted **term by term** through
the board's units and then summed, so `3 weeks 2 hours` on a board with `d = 1`,
`w = 5`, `h = 1` counts as `17` in `d` (`3 × 5 + 2 × 1`), never a clock-ratio
guess.

Fractions and ranges are read **pessimistically**: `0.5 day` rounds up to `1d`, and
a range takes its upper bound, so `0.5-1 day` is also `1d`. A field that carries
other comma-separated annotations keeps the segments that read as a duration and
ignores the rest: `~300 LOC, 0.5-1 day` counts as `1d`. If no segment reads, the
value is malformed and `tix lint` reports it.

A bare number is **already** in the limit's unit — the limit names it, so
nothing is inferred. Conversion is `value × factor(from) ÷ factor(to)` and it must
divide exactly:

- a unit the board does not declare is `missing_conversion`;
- a conversion that does not divide evenly is `ambiguous_conversion`;
- an estimate that is neither is `malformed_estimate`.

None of the three counts as zero, and none of them guesses: the column's estimate
total becomes **unknown**, which is never read as "within the limit". An issue with
*no* estimate is not an error — it is work that states no size — so it adds nothing
to the total and still counts as one card.

## The decision

One service answers "may this move happen?" — `src/wip/policy.rs` — and every door
asks it:

| Surface | How it asks |
|---|---|
| CLI | `tix move <issue> <column>` (and `--override` with `--reason` and `--yes`) |
| TUI keyboard | `>` / `<` on the selected card |
| TUI pointer | the action menu's *Move to …* (opened by click, `Enter` or right-click), and a drag released over another column — the same function the keyboard uses |
| TUI inbox | the triage inbox's move |
| MCP | `move_issue` (with `override: true`, `reason` and `yes`) |
| bulk | `tix bulk edit --status …`: each item judged against the state *that item* would see, in plan order |
| reporting | `tix status`, `tix list --show wip`, the board's column headers, the detail pane, `tix lint` |

| Outcome | May the move be written? | History |
|---|---|---|
| `allow` | yes | none — a move within the limit is not a decision |
| `warn` | yes, with the decision reported | none — a warning is not an override |
| `block` | **no** | none |
| `override` | yes | exactly one `transition_accepted` entry |
| `rejected` | **no** | none |

The order is fixed and deliberate: the **transition** is judged first (#183's
service, untouched), a same-column move is a no-op before any limit is read, then
the limit and its unit, then the configured policy. So a WIP violation never
excuses a transition that #183 refuses, and a reorder is never a limit question.

## Overriding a blocking limit

An override is a *request*, not a flag that authorizes:

- `tix move <issue> <col> --override --reason "why" --yes`
- MCP: `{ "override": true, "reason": "why", "yes": true }`
- the TUI: the move raises a modal that shows the policy's own decision and asks for
  the reason; `enter` overrides, `esc` cancels and writes nothing.

All of `override`, `reason` and the confirmation are required, and each missing one
is refused by name (`missing_override_field`, `unconfirmed_override`). The override
also **binds the limit the user was shown**: if the ceilings or the unit change
while the prompt is open, the override is `stale_override_limit` rather than a
silent authorization of a limit nobody saw. An applied override writes exactly one
History entry naming the actor, the time, the reason, both columns, the limit it was
authorized against, and the before/after figures.

## Bulk batches

A batch judges **each item against the state that item would see**: four items into
a column capped at three allow the first three and block the fourth, and the preview
says so (`wip: block: …` plus the policy's diagnostics). The apply re-judges against
the current configuration and state, refuses the blocked items with the policy's own
sentence, keeps the ones that fitted — a batch never rolls back — and exits non-zero
because it was partial, which is the bulk contract rather than a WIP rule. There is
no batch-wide override: an item that needs one is refused and moved singly, where the
override is explicit and audited.

## Direct Markdown edits

Editing a file to put a column over its limit is a policy violation, not a broken
file. `tix lint` reports it — `over_limit` for the column, `malformed_estimate` /
`missing_conversion` / `ambiguous_conversion` for an estimate it cannot count — as a
**warning**, so an over-quota repository is not red for the wrong reason. Lint never
repairs: nothing is rewritten, and no History entry is invented for a change nobody
made.

## Boundaries

Not implemented, deliberately, and each an escalation rather than a gap:

- **A hosted workflow service or a database.** Limits are a table in `tix.toml`.
- **Automatic override authorization.** An override needs a person's reason.
- **Silent conversion between estimate units, or a new unit system.** Units are
  declared per board, and a missing conversion is an error.
- **A second status or completion system.** `category_map`, `done_column` and the
  transition policy stay `workflow`'s.
- **A batch-wide override.** See above: the single-issue path is where an override
  is explicit and audited.

## Where the evidence lives

| Evidence | File |
|---|---|
| The schema, the arithmetic, the resolver | `src/wip/config.rs` (inline tests) |
| The aggregate, the unknown-total rule | `src/wip/aggregate.rs`, `tests/wip_aggregate.rs` |
| The decision vocabulary, the override rules | `tests/wip_policy.rs` |
| The CLI gate and `tix wip` | `tests/wip_cli.rs` |
| The TUI keyboard route, the modal, the display | `tests/tui_state.rs` |
| MCP parity | `tests/mcp.rs` |
| Lint, the batch projection, the outcomes | `tests/wip_bulk.rs` |
| Cross-surface agreement, NFR-001, the events | `tests/wip.rs` |

## See also

* [MCP]mcp.md#work-in-progress-limits — the tool contracts and confirmation rules.
* [key bindings]key-bindings.md#board — the board's keys.
* [workflow status categories]mcp.md — `tix workflow`, the authority on what a
  column means.