Skip to main content

tix/
cli.rs

1//! The clap command tree, the frozen exit-code scheme, and the process
2//! boundary that resolves config, opens the store and hands off to the
3//! per-command handlers.
4//!
5//! The command tree and every long flag mirror `contracts/cli.md` exactly;
6//! the exit-code table there is frozen: `0` success, `1` usage/config/
7//! unknown-error, `2` target-not-found or selection-ambiguous, `3` external
8//! tool missing (`glab`/`linear-cli`, reserved for WP05). Handlers return
9//! [`Result<(), TixError>`]; [`exit_code_for`] maps the typed error surface
10//! to the exit-code enum at the boundary. Human-readable errors go to
11//! stderr; stdout stays clean for the parseability contract.
12
13use std::path::{Path, PathBuf};
14
15use clap::builder::NonEmptyStringValueParser;
16use clap::{Args, Parser, Subcommand};
17
18use crate::commands;
19use crate::config::Config;
20use crate::error::{Result, TixError};
21use crate::storage::Store;
22
23/// Exit codes frozen by `contracts/cli.md`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ExitCode {
26    Success = 0,
27    UsageConfig = 1,
28    NotFoundAmbiguous = 2,
29    ExternalTool = 3,
30}
31
32/// Map a domain error to the frozen exit-code table.
33///
34/// Selection ambiguity shares exit `2` with target-not-found, per the
35/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
36/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
37pub fn exit_code_for(err: &TixError) -> ExitCode {
38    match err {
39        TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
40        TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
41            ExitCode::NotFoundAmbiguous
42        }
43        TixError::External(_) => ExitCode::ExternalTool,
44        _ => ExitCode::UsageConfig,
45    }
46}
47
48#[derive(Parser)]
49#[command(
50    name = "tix",
51    version,
52    about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
53)]
54pub struct Cli {
55    #[command(subcommand)]
56    pub command: Option<Command>,
57}
58
59#[derive(Subcommand)]
60pub enum Command {
61    /// Create a default tix.toml ([kanban.default], issues_dir, [export]).
62    ///
63    /// When `bd` is on PATH and a beads workspace is reachable, an interactive
64    /// run also offers the beads modes; `--beads` decides without a prompt, and a
65    /// non-interactive run never prompts (it keeps `off` unless `--beads` says
66    /// otherwise).
67    Init {
68        /// Beads mode to record: off | mirror | sync
69        ///
70        /// `mirror` reads a beads workspace through `bd` for viewing only;
71        /// `sync` installs the `.beads/hooks` that pull beads changes into tix.
72        #[arg(long, value_name = "off|mirror|sync")]
73        beads: Option<String>,
74    },
75    /// Append or replace the tix block in AGENTS.md (idempotent)
76    InitAgents,
77    /// Print the crate version and how this binary was built
78    ///
79    /// Answers the question `--version` cannot: whether the binary on disk came
80    /// from the source on disk. It reports the commit it was built from and when,
81    /// so a rebuild that did not happen is visible. Needs no `tix.toml`, so it
82    /// works in any directory.
83    Version,
84    /// Read, or write, a [beads](https://github.com/gastownhall/beads) workspace
85    /// through the `bd` CLI
86    ///
87    /// tix never opens the beads Dolt database: every verb here shells out to
88    /// `bd`. The reads (`detect`, `status`, `list`, `show`, `watch`) write
89    /// nothing anywhere; the writes (`push`, `pull`, `reconcile`) are refused
90    /// while the beads mode is `mirror`, which is read-only by contract. See
91    /// `docs/for-users/beads-integration.md`.
92    Beads {
93        #[command(subcommand)]
94        action: BeadsAction,
95    },
96    /// Create an issue
97    Create {
98        /// Issue title (prompted when missing on a TTY)
99        #[arg(long)]
100        title: Option<String>,
101        /// Issue body (markdown)
102        #[arg(long)]
103        body: Option<String>,
104        /// Column name (default: first column)
105        #[arg(long)]
106        status: Option<String>,
107        /// Priority: low | medium | high (default medium)
108        #[arg(long)]
109        priority: Option<String>,
110        /// Assignee (@name)
111        #[arg(long)]
112        assignee: Option<String>,
113        /// Labels, comma-separated
114        #[arg(long)]
115        labels: Option<String>,
116        /// Files governing this issue, comma-separated
117        #[arg(long)]
118        files: Option<String>,
119        /// Estimate
120        #[arg(long)]
121        estimate: Option<String>,
122        /// Cycle names, comma-separated
123        #[arg(long)]
124        cycles: Option<String>,
125        /// Milestone names, comma-separated
126        #[arg(long)]
127        milestones: Option<String>,
128        /// Projects to associate the new issue with: ids or names, comma-separated
129        ///
130        /// Every reference is resolved through the project service **before** the
131        /// issue is written, so an unknown id refuses the whole create rather than
132        /// leaving a half-associated issue. This flag lives on the shared create
133        /// path, which is the handoff point a future template surface (source issue
134        /// #174) would populate (mission `first-class-projects-01M3BYBW`).
135        #[arg(long, value_name = "ID")]
136        projects: Option<String>,
137        /// Epics the new issue references: ids or names, comma-separated
138        ///
139        /// Every reference is resolved through the epic service **before** the issue
140        /// is written, so an unknown id refuses the whole create rather than leaving
141        /// a half-referenced issue. This flag lives on the shared create path, which
142        /// is the handoff point a future template surface (source issue #174) would
143        /// populate. Referencing an epic never changes how the issue itself is
144        /// navigated: it stays an ordinary issue (mission `epics-work-items-01M3BYW4`).
145        #[arg(long, value_name = "ID")]
146        epics: Option<String>,
147        /// Reviewer (free-form name/identity)
148        #[arg(long)]
149        reviewer: Option<String>,
150        /// Start date (`YYYY-MM-DD`)
151        #[arg(long)]
152        start_date: Option<String>,
153        /// Due date (`YYYY-MM-DD`); must be `>= start_date`
154        #[arg(long)]
155        due_date: Option<String>,
156        /// Create as a child of this issue (number, filename or title fragment)
157        ///
158        /// At most one parent, so this takes a single selector and there is no list
159        /// form (C-001). The parent must already exist; a selector that matches no
160        /// issue is refused and nothing is created.
161        #[arg(long, value_name = "SELECTOR")]
162        parent: Option<String>,
163        /// Related issues, comma-separated: a local number/selector, a relative
164        /// or absolute path to another issue file, or a repository URL
165        /// (`host/path#id`). Free-form; stored verbatim.
166        #[arg(long)]
167        related_issues: Option<String>,
168        /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
169        /// `custom_types`.
170        #[arg(long)]
171        r#type: Option<String>,
172        /// Create from a stable template ID (`tix template list`)
173        ///
174        /// The template's defaults and prompts are merged with the flags above
175        /// by the pure resolver, then the resolved request goes through the
176        /// shared create core — there is no second issue writer. Resolution
177        /// never prompts: a required prompt with no value is a diagnostic, the
178        /// template and the store are left untouched, and no issue is written.
179        #[arg(long, value_name = "STABLE-ID")]
180        template: Option<String>,
181        /// Template scope: `repository` | `board:<name>` | `team:<label>`
182        ///
183        /// Only meaningful with `--template` (clap requires it). An omitted
184        /// scope is the explicit `repository` default, never an implicit
185        /// inheritance. The selected template ID and scope are echoed on
186        /// stderr so stdout keeps the `Created <file>` shape.
187        #[arg(long, value_name = "SCOPE", requires = "template")]
188        scope: Option<String>,
189    },
190    /// Create, edit, remove, or list typed relationships
191    ///
192    /// Creating requires `--type`; there is no default kind, so an untyped
193    /// relationship is never recorded by accident (C-001). Omit the
194    /// subcommand to create: `tix link <from> <to> --type <kind>`.
195    Link {
196        /// `edit`, `remove`, or `list`. Omit to create a relationship.
197        ///
198        /// These three words are reserved on `tix link`: an issue whose number,
199        /// filename, or title selector is exactly `edit`, `remove`, or `list`
200        /// must be addressed by number.
201        #[command(subcommand)]
202        action: Option<LinkAction>,
203        /// [create] Issue number, filename fragment or unique title fragment
204        from: Option<String>,
205        /// [create] Issue number, filename fragment or unique title fragment
206        to: Option<String>,
207        /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
208        #[arg(long, value_name = "KIND")]
209        r#type: Option<String>,
210    },
211    /// Batch editing over a selection
212    ///
213    /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
214    /// `--filter` or `--search` — they are different grammars, and a bulk operation
215    /// will not guess which issues you meant.
216    Bulk {
217        #[command(subcommand)]
218        action: BulkAction,
219    },
220    /// Remove a typed relationship between two issues
221    Unlink {
222        /// Issue number, filename fragment or unique title fragment
223        from: String,
224        /// Issue number, filename fragment or unique title fragment
225        to: String,
226        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
227        #[arg(long, value_name = "KIND")]
228        r#type: Option<String>,
229    },
230    /// Print the transitive dependency chain an issue holds up
231    Chain {
232        /// Issue number, filename fragment or unique title fragment
233        issue: String,
234    },
235    /// Inspect the issue hierarchy
236    ///
237    /// `show`, `children`, or `list`. Every figure comes from the hierarchy
238    /// service's typed values, so the CLI, the TUI detail block and the MCP read
239    /// tools report the same numbers for the same issue.
240    Parent {
241        /// `show`, `children`, or `list`
242        #[command(subcommand)]
243        action: ParentAction,
244    },
245    /// Edit an issue
246    Edit {
247        /// Issue number, filename fragment or unique title fragment
248        issue: String,
249        /// New title
250        #[arg(long)]
251        title: Option<String>,
252        /// New body (markdown)
253        #[arg(long)]
254        body: Option<String>,
255        /// New status
256        ///
257        /// Setting the status is a **move**, validated by the same service as
258        /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
259        /// way around the transition policy. `--status ""` clears the field
260        /// (matching every other field here) and is a plain field write, not a
261        /// transition, because there is no target column to judge.
262        #[arg(long)]
263        status: Option<String>,
264        /// New priority: low | medium | high
265        #[arg(long)]
266        priority: Option<String>,
267        /// New assignee (@name)
268        #[arg(long)]
269        assignee: Option<String>,
270        /// New label list, comma-separated
271        #[arg(long)]
272        labels: Option<String>,
273        /// New files list, comma-separated
274        #[arg(long)]
275        files: Option<String>,
276        /// New estimate
277        #[arg(long)]
278        estimate: Option<String>,
279        /// New cycle list, comma-separated (`--cycles ""` clears)
280        #[arg(long)]
281        cycles: Option<String>,
282        /// New milestone list, comma-separated (`--milestones ""` clears)
283        #[arg(long)]
284        milestones: Option<String>,
285        /// Associate the issue with a project (id or name); repeatable
286        ///
287        /// Idempotent: an already-present association is a no-op with no History
288        /// entry. The reference is resolved through the project service before
289        /// anything is written, so an unknown project refuses the edit and leaves
290        /// the file byte-identical.
291        #[arg(long, value_name = "ID")]
292        add_project: Vec<String>,
293        /// Dissociate the issue from a project (id or name); repeatable
294        ///
295        /// Drops only the association — the issue is never deleted. A known project
296        /// that is not associated is a no-op; an unresolvable reference that the
297        /// issue does not carry is refused as `unknown_project`.
298        #[arg(long, value_name = "ID")]
299        remove_project: Vec<String>,
300        /// Replace the whole association list, comma-separated
301        ///
302        /// Only a blank value is accepted here: `--projects ""` clears every
303        /// association, mirroring the empty-string-clears convention the other list
304        /// flags follow. To add or drop individual associations use
305        /// `--add-project` / `--remove-project`; a non-blank value is refused rather
306        /// than guessed at.
307        #[arg(long, value_name = "IDS")]
308        projects: Option<String>,
309        /// Add an epic reference (id or name); repeatable
310        ///
311        /// Idempotent: an already-present reference is a no-op with no History
312        /// entry. The reference is resolved through the epic service before
313        /// anything is written, so an unknown epic refuses the edit and leaves the
314        /// file byte-identical.
315        #[arg(long = "add-epic", value_name = "ID")]
316        add_epic: Vec<String>,
317        /// Remove an epic reference; repeatable
318        ///
319        /// Drops only the reference — the issue is never deleted. A known epic that
320        /// is not referenced is a no-op; an unresolvable reference the issue does not
321        /// carry is refused as `unknown_epic`. A retained stale id can still be
322        /// removed by naming it verbatim.
323        #[arg(long = "remove-epic", value_name = "ID")]
324        remove_epic: Vec<String>,
325        /// Clear every epic reference
326        ///
327        /// Only a blank value is accepted here: `--epics ""` clears every reference,
328        /// mirroring the empty-string-clears convention `--projects` follows. To add
329        /// or drop individual references use `--add-epic` / `--remove-epic`; a
330        /// non-blank value is refused rather than guessed at.
331        #[arg(long, value_name = "IDS")]
332        epics: Option<String>,
333        /// New reviewer (`--reviewer ""` clears)
334        #[arg(long)]
335        reviewer: Option<String>,
336        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
337        #[arg(long)]
338        start_date: Option<String>,
339        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
340        #[arg(long)]
341        due_date: Option<String>,
342        /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
343        #[arg(long)]
344        commit: Option<String>,
345        /// Set or change the parent (number, filename or title fragment)
346        ///
347        /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
348        /// flag leaves it untouched. Validated before any write, so a self-parent,
349        /// an unknown parent, or a change that would close a cycle leaves the file
350        /// byte-identical.
351        #[arg(long, value_name = "SELECTOR")]
352        parent: Option<String>,
353        /// Why a transition policy is being overridden by `--status`
354        ///
355        /// `--status` is validated exactly as `tix mv` is, so moving an issue out
356        /// of a terminal category needs the same reasoned override, and the reason
357        /// is recorded in the same History decision. `--status` deliberately has
358        /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
359        /// giving this flag a second spelling would give the audit trail two.
360        #[arg(long, value_name = "TEXT")]
361        reason: Option<String>,
362        /// New related-issues list, comma-separated (`--related-issues ""` clears).
363        /// A local number/selector, a relative or absolute path to another issue
364        /// file, or a repository URL (`host/path#id`).
365        #[arg(long)]
366        related_issues: Option<String>,
367        /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
368        /// in `custom_types`.
369        #[arg(long)]
370        r#type: Option<String>,
371    },
372    /// Move an issue to a column
373    Move {
374        /// Issue number, filename fragment or unique title fragment
375        issue: String,
376        /// Target column
377        column: String,
378        /// Commit hash to record when moving to a completion column (`auto`
379        /// resolves it from git history; omitted, the current HEAD is captured
380        /// automatically)
381        ///
382        /// "Completion" is whatever the board's category mapping calls
383        /// `completed` — not a column literally named `done`. On a board with
384        /// no `[kanban.<name>.category_map]` the board's `done_column` is the
385        /// completion column.
386        #[arg(long, value_name = "HASH")]
387        commit: Option<String>,
388        /// Why a transition policy is being overridden
389        ///
390        /// Required to move an issue *out of* a terminal category (`completed`
391        /// or `canceled`) when the board declares a `category_map`. The reason
392        /// is recorded in the issue's History, so the audit trail explains
393        /// itself later. A blank value is not a reason and is refused.
394        ///
395        /// A board with no `category_map` has no transition policy to override:
396        /// it keeps the completion definition and the commit rule, and moving
397        /// out of `done` stays permitted.
398        #[arg(long, value_name = "TEXT")]
399        reason: Option<String>,
400        /// Confirm without prompting
401        ///
402        /// Also the acknowledgement a move toward the board's completion column
403        /// needs when the issue has open blockers. Without it that move is
404        /// refused, and a recorded acknowledgement is appended to the issue's
405        /// History so the decision stays auditable.
406        #[arg(long)]
407        yes: bool,
408        /// Authorize overriding a **blocking work-in-progress limit**
409        ///
410        /// `--override` is a request, not an authorization. The move only proceeds
411        /// when `--reason` states why and `--yes` confirms it, and the override is
412        /// recorded — actor, timestamp, reason and the limit it was authorized
413        /// against — as a History entry on the issue. Without `--override` a move
414        /// that would exceed a `block` limit is refused and nothing is written; a
415        /// `warn` limit never needs one.
416        #[arg(long)]
417        r#override: bool,
418    },
419    /// Inspect or configure work-in-progress limits
420    ///
421    /// Limits live in `tix.toml` under `[kanban.<board>.wip]`: a column's ceilings
422    /// (`max_items`, `max_estimate`), the unit an estimate ceiling counts in, and
423    /// whether exceeding it `warn`s or `block`s. A board with no limits behaves
424    /// exactly as it did before they existed.
425    Wip {
426        #[command(subcommand)]
427        action: WipAction,
428    },
429    /// Print an issue, or manage saved views (#172)
430    ///
431    /// `tix view <issue>` prints the issue file, unchanged. Without an issue
432    /// selector the subcommands below manage repository-owned **saved views**:
433    /// named, stable-ID query definitions with explicit scope metadata and a
434    /// normalized plan over the existing filter/sort/group/visibility grammar.
435    ///
436    /// The nine subcommand words (`list`, `show`, `create`, `update`,
437    /// `duplicate`, `delete`, `apply`, `status`, `share`) are **reserved** on
438    /// `tix view`: an issue whose selector is exactly one of them must be
439    /// addressed by number, exactly as `tix link` reserves `edit` / `remove` /
440    /// `list`.
441    #[command(visible_alias = "show")]
442    View {
443        /// `list`, `show`, `create`, `update`, `duplicate`, `delete`, `apply`,
444        /// `status`, or `share`. Omit to print an issue.
445        #[command(subcommand)]
446        action: Option<ViewAction>,
447        /// [print] Issue number, filename fragment or unique title fragment
448        issue: Option<String>,
449        /// [print] Extra information to append, comma-separated
450        ///
451        /// `relationships` appends the service-derived relationship block, the
452        /// transitive chain, and the blocked reason. Without it the issue file
453        /// is printed verbatim, so `tix view <n> > file` still round-trips.
454        #[arg(long, value_name = "relationships")]
455        show: Option<String>,
456    },
457    /// Manage recurring issue series (tix #175)
458    ///
459    /// A series is a **normal issue** that carries a `series:` table in its
460    /// frontmatter: the issue holds the rule, the lifecycle state, and the
461    /// template an occurrence is created from; each occurrence is another normal
462    /// issue carrying the series id, the date and the idempotency key.
463    ///
464    /// `detect` is read-only and reports what is due; `generate` and every
465    /// lifecycle action need `--yes`, because no refresh, timer or detection may
466    /// change a schedule on its own.
467    Recurring {
468        #[command(subcommand)]
469        action: RecurringAction,
470    },
471    /// One line per issue: number, id and title
472    ///
473    /// The default listing is **scoped**: your issues (the git author identity,
474    /// when it can be derived) in the board's entry column — `todo` on the board
475    /// `tix init` writes. `--all` drops the scope, and `--status`/`--filter`
476    /// replace it. The title is cropped to the terminal's width, so every issue
477    /// occupies exactly one line; the full board is `tix render`.
478    List {
479        /// Only issues in this status
480        #[arg(long)]
481        status: Option<String>,
482        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
483        #[arg(long)]
484        filter: Option<String>,
485        /// Restrict the listing to one project (stable id, or a display name)
486        ///
487        /// Resolved through the project service and applied through the shared
488        /// `project:` criterion, so `tix list --project <id>` and
489        /// `tix search "project:<id>"` return the same matching set.
490        #[arg(long)]
491        project: Option<String>,
492        /// Restrict the listing to one epic (stable id, or a display name)
493        ///
494        /// Resolved through the epic service and applied through the shared
495        /// `epic:` criterion, so `tix list --epic <id>` and
496        /// `tix search "epic:<id>"` return the same matching set. An unknown
497        /// reference matches nothing, so a typo yields a visible empty listing
498        /// rather than a silent unfiltered one.
499        #[arg(long)]
500        epic: Option<String>,
501        /// Include archived issues, marked with an ARCHIVED column
502        ///
503        /// Off by default, and off for a reason: archived issues are skipped
504        /// without being read, so a repository with thousands of them lists fast.
505        #[arg(long)]
506        with_archived: bool,
507        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `project`, `epic`, `status`, `priority`)
508        #[arg(long)]
509        sort: Option<String>,
510        /// Group key (`cycle` | `milestone` | `project` | `epic`)
511        #[arg(long)]
512        group: Option<String>,
513        /// Comma-separated columns to display
514        ///
515        /// The full set: `num`, `id`, `title`, `status`, `priority`, `cycle`,
516        /// `milestone`, `reviewer`, `start`, `due`, `overdue`, `blocked`,
517        /// `relations`, `progress`, `projects`, `epics`, `category`, `archived`,
518        /// `wip`, `health`. The `value_name` used to list only the first ten, so a
519        /// column this build had supported for two missions looked unsupported in
520        /// `--help`; a column left out of this list is the same lie about a smaller
521        /// set.
522        #[arg(long, value_name = "COLUMNS")]
523        columns: Option<String>,
524        /// Extra information to include, comma-separated
525        ///
526        /// `relationships` adds a relationship summary and a blocked marker
527        /// per issue, read from the relationship service so the CLI and the
528        /// TUI cannot disagree.
529        ///
530        /// `progress` adds a parent rollup; `category` adds the issue's resolved
531        /// category label, read from the shared service so this surface and the
532        /// TUI cannot spell one category two ways.
533        ///
534        /// `wip` adds the column's usage against its limit, and `health` adds the
535        /// issue's health (its own value, with the derived rollup beside it when the
536        /// two differ), read from the one portable health document.
537        #[arg(long, value_name = "SHOW")]
538        show: Option<String>,
539        /// List every issue, ignoring the default scope
540        ///
541        /// The default listing is scoped to your issues in the board's entry
542        /// column. `--all` drops both halves of that scope — the listing is then
543        /// every active issue, exactly as it was before the scope existed.
544        #[arg(long)]
545        all: bool,
546        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
547        ///
548        /// One record per issue, keyed by the columns being displayed, under the
549        /// single root `issues` — so `--columns` chooses the fields. JSON is
550        /// deliberately not one of them.
551        #[arg(long, value_name = "FORMAT")]
552        format: Option<String>,
553    },
554    /// The next issue to start: unblocked, highest priority first
555    ///
556    /// The `tix list` a working session opens with. It shows every issue that
557    /// nothing blocks and that has not begun — a column the board maps to `backlog`
558    /// or `unstarted`, no further along than the column new work lands in — ordered
559    /// by priority (highest first, then oldest first), so the **first row** is the
560    /// issue to pick up next. Blocked means an **open** blocker: the same graph the
561    /// `blocked` column reads, so finishing a blocker returns its dependents here.
562    Ready {
563        /// Comma-separated columns to display
564        ///
565        /// The default set is `num,id,priority,title`: the identity pair, the
566        /// priority the list is ordered by, and the title.
567        #[arg(long, value_name = "COLUMNS")]
568        columns: Option<String>,
569        /// Show at most this many issues
570        ///
571        /// Applied after the ordering, so `--limit 1` is the single next issue.
572        #[arg(long, value_name = "N")]
573        limit: Option<usize>,
574        /// Restrict the listing to one project (stable id, or a display name)
575        #[arg(long)]
576        project: Option<String>,
577        /// Restrict the listing to one epic (stable id, or a display name)
578        #[arg(long)]
579        epic: Option<String>,
580        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
581        #[arg(long, value_name = "FORMAT")]
582        format: Option<String>,
583    },
584    /// Inspect the status-category vocabulary, a board's mapping, and the
585    /// effective transition policy
586    ///
587    /// A read-only surface. It never writes and never repairs, so it is safe to run
588    /// on a repository whose `tix.toml` is mid-edit.
589    Workflow {
590        #[command(subcommand)]
591        action: WorkflowAction,
592    },
593    /// Board summary: count per column + total
594    Status {
595        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
596        #[arg(long)]
597        filter: Option<String>,
598        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
599        #[arg(long)]
600        sort: Option<String>,
601        /// Group key (`cycle` | `milestone` | `project` | `epic`)
602        #[arg(long)]
603        group: Option<String>,
604        /// Add the health summary: issue counts per state, with diagnostics
605        ///
606        /// Read from the same portable health document `tix health board` and the
607        /// TUI render, so the figures are the same figures. Off by default: the board
608        /// summary is about columns, and health is metadata about the work (C-006).
609        #[arg(long)]
610        health: bool,
611    },
612    /// Project and issue health: read a scope, roll it up, or record a change
613    ///
614    /// Health is **metadata**, not the board's status: it never replaces a status
615    /// category and never means "done". The current value lives in an issue's
616    /// frontmatter, every change is an append-only History entry carrying an actor, a
617    /// date and a rationale, and a scope's derived value comes from what contributes
618    /// to it under a versioned policy (`health-v1`).
619    ///
620    /// `set` and `override` are the mutations, and both refuse — having written
621    /// nothing — when an audit field is missing. `show`, `rollup`, `history` and
622    /// `board` read.
623    Health {
624        #[command(subcommand)]
625        action: HealthAction,
626    },
627    /// Read and act on your `@mentions` (issue-mentions)
628    ///
629    /// A mention is `@handle` or `@name@host` in an issue's title or body, recorded
630    /// per mentioned target under `docs/mentions/`. This command is the
631    /// **non-interactive** surface: it lists what is pending for you and applies the
632    /// three answers without a prompt, so the flow works from a script and without a
633    /// TTY. The TUI has the same three answers on the bottom indicator row's chord.
634    Mentions {
635        #[command(subcommand)]
636        action: MentionsAction,
637    },
638    /// Triage the inbox: read-only queue views and the four decisions
639    Triage {
640        #[command(subcommand)]
641        action: TriageAction,
642    },
643    /// Delete an issue file
644    Delete {
645        /// Issue number, filename fragment or unique title fragment
646        issue: String,
647        /// Confirm without prompting
648        #[arg(long)]
649        yes: bool,
650    },
651    /// Archive issue files: rename each in place to a dot-prefixed name
652    ///
653    /// The default views (`tix list`, `tix search`) skip archived issues; pass
654    /// their `--with-archived` flag or the `archived:true` filter to see them.
655    Archive {
656        /// Issue numbers, filename fragments or unique title fragments
657        #[arg(required = true, value_name = "ISSUE")]
658        issues: Vec<String>,
659    },
660    /// Restore archived issue files: the inverse of `tix archive`
661    Restore {
662        /// Issue numbers, filename fragments or unique title fragments
663        #[arg(required = true, value_name = "ISSUE")]
664        issues: Vec<String>,
665    },
666    /// Manage planning cycles
667    Cycle {
668        #[command(subcommand)]
669        command: CycleCommand,
670    },
671    /// Configure and inspect cycle, team and assignee capacity
672    ///
673    /// A ceiling is a scope (one cycle, one team label, or one assignee identity)
674    /// with a non-negative value in an estimate unit the active board declares.
675    /// `calculate` reports the shared committed/completed/remaining/overflow/
676    /// utilization values and never mutates an issue; `snapshot` reads back the
677    /// immutable evidence a cycle close captured.
678    Capacity {
679        #[command(subcommand)]
680        command: CapacityCommand,
681    },
682    /// Record and review actual effort against issues, in whole minutes
683    ///
684    /// A time entry is a duration, an optional `YYYY-MM-DD` calendar date, an author
685    /// and an optional note — there is no timer, no timezone conversion and no overlap
686    /// rule. Current entries live in the issue's own frontmatter, every correction and
687    /// deletion appends an attributable History event, and a deletion removes the
688    /// current value only. `add`, `edit` and `delete` are the mutations (`edit` and
689    /// `delete` require `--reason`; `delete` also requires `--confirm`), and `show`,
690    /// `history` and `total` are reads whose numbers come from one shared calculation.
691    Time {
692        #[command(subcommand)]
693        command: TimeCommand,
694    },
695    /// Manage milestones
696    Milestone {
697        #[command(subcommand)]
698        command: MilestoneCommand,
699    },
700    /// Create, edit, list, inspect, delete and populate projects
701    ///
702    /// A project is a stable id (a registry key in `tix.toml`) with a mutable
703    /// display name. An issue carries **project ids**, so a rename breaks no
704    /// association: the id is the identity and the name is a label. Every listing
705    /// prints the id beside the name, because without it a rename is
706    /// indistinguishable from a new project.
707    ///
708    /// `delete` removes the **registry entry only**. The issues that referenced
709    /// the project keep their references, which then read as diagnosable missing
710    /// references (`tix lint` names each one), so nothing is destroyed by deleting
711    /// a project and there is deliberately no `--cascade` and no bulk-delete of
712    /// issues.
713    ///
714    /// `add`, `remove` and `bulk-add` process each issue selector
715    /// **independently**: a rejection on one selector is reported and never aborts
716    /// the rest. Their scope is project association only — general bulk editing is
717    /// a different feature.
718    Project {
719        #[command(subcommand)]
720        command: ProjectCommand,
721    },
722    /// Create, edit, list, inspect, navigate and delete epics; associate work items
723    ///
724    /// An epic is a stable id (a registry key in `tix.toml`) with a mutable
725    /// display name. A work item references epic **ids**, so a rename breaks no
726    /// reference: the id is the identity and the name is a label. Every listing
727    /// prints the id beside the name, because without it a rename is
728    /// indistinguishable from a new epic.
729    ///
730    /// A "task" is an ordinary issue: `tix edit <sel> --labels task --add-epic
731    /// <id>` adds a reference to an issue. No task entity, file or frontmatter key
732    /// is created, and direct issue navigation stays available from every view.
733    ///
734    /// `delete` removes the **registry entry only**. The issues that referenced the
735    /// epic keep their references (which then read as missing references) and the
736    /// child epics keep their recorded parent (which then reads as unresolved), so
737    /// nothing is destroyed by deleting an epic and there is deliberately no
738    /// `--cascade`.
739    ///
740    /// `add`, `remove` and `bulk-add` process each issue selector
741    /// **independently**: a rejection on one selector is reported and never aborts
742    /// the rest. Their scope is epic reference only — general bulk editing is a
743    /// different feature.
744    Epic {
745        #[command(subcommand)]
746        command: EpicCommand,
747    },
748    /// Create, edit, list, inspect, delete and associate roadmap initiatives
749    ///
750    /// An initiative is a stable id (a registry key in `tix.toml`) with a mutable
751    /// display name, an optional target date/status/owner, and explicit
752    /// project/epic/milestone associations. The id is the identity, so a rename
753    /// breaks no association. Every listing prints the id, because without it a
754    /// rename is indistinguishable from a new initiative.
755    ///
756    /// `delete` removes the **registry entry only**. The record's own references are
757    /// reported and nothing cascades: no project, epic, milestone or issue is
758    /// touched, and no association is silently repaired.
759    ///
760    /// `associate`/`dereference` name exactly one of `--project`, `--epic` or
761    /// `--milestone`; a well-formed reference that names nothing is stored and stays
762    /// diagnosable (`tix roadmap` and `tix lint` report it), and a repeated
763    /// association is an idempotent no-op.
764    Initiative {
765        #[command(subcommand)]
766        command: InitiativeCommand,
767    },
768    /// List and inspect the roadmap: initiatives ordered by target date
769    ///
770    /// Read-only. Each row carries the target date (or the explicit `undated`
771    /// state), the source-attributed summary — one contribution per referenced
772    /// project/epic/milestone with its own value and shared policy version — and the
773    /// ordered diagnostics, so a missing reference is visible on the row and an
774    /// empty summary is explicit. No figure is blended across sources.
775    Roadmap {
776        #[command(subcommand)]
777        command: RoadmapCommand,
778    },
779    /// Read the burndown and scope analytics of a cycle or milestone, or write its
780    /// deterministic chart document
781    ///
782    /// Read-only. `burndown` prints the daily remaining/completed series the shared fold
783    /// produces, `projection` the read-only end-date forecast at the caller's `--as-of`,
784    /// and `export` the versioned local chart document (WP04's target, called by this
785    /// verb rather than re-serialized). The period comes from the cycle/milestone's own
786    /// planning data and `--from`/`--to` replace the bound they name; count and estimate
787    /// modes are explicit and an estimate without a compatible unit is refused rather
788    /// than guessed. A value the record cannot support prints as `-` and never as a zero,
789    /// a day with no recorded history is explicit about its coverage, and nothing here
790    /// mutates an issue, a cycle or a snapshot. The remote `gitlab`/`linear` export
791    /// namespaces are a different command (`tix export`) and are never consulted.
792    Analytics {
793        #[command(subcommand)]
794        command: AnalyticsCommand,
795    },
796    /// Export local issues to an external tracker via glab/linear-cli, or write
797    /// them into a beads workspace via `bd`
798    Export {
799        /// Target tracker: gitlab | linear | ics | beads
800        #[arg(long, value_name = "gitlab|linear|ics|beads")]
801        target: String,
802        /// Print what would change without invoking the remote tracker
803        #[arg(long)]
804        dry_run: bool,
805        /// Override the configured project path (gitlab) or team (linear)
806        #[arg(long)]
807        project: Option<String>,
808        /// Cycle to export for ICS output
809        #[arg(long)]
810        cycle: Option<String>,
811        /// Output path for ICS export
812        #[arg(long)]
813        output: Option<String>,
814        /// List the per-issue diagnostics — which local fields beads cannot hold
815        ///
816        /// Off by default: a board of a few thousand issues produces hundreds of
817        /// them, and a run that sends three issues should not rehearse the notes
818        /// for the other thousand. The counts are always reported either way.
819        #[arg(long)]
820        verbose: bool,
821    },
822    /// Import open issues from an external tracker via glab/linear-cli, mirror a
823    /// local GitHub Spec Kit project, or pull a beads workspace through `bd`
824    Import {
825        /// Target: gitlab | linear (remote trackers) | speckit (local mirror) |
826        /// beads (a beads workspace, through `bd`)
827        #[arg(long, value_name = "gitlab|linear|speckit|beads")]
828        target: String,
829        /// Where an imported issue lands in the local triage inbox (WP07)
830        ///
831        /// `entry` routes into `[triage].entry_status` and refuses a project with
832        /// no `[triage]` section instead of quietly falling back; `remote` keeps
833        /// the column the remote mapping named. Absent is the documented default:
834        /// route when the project has an inbox, otherwise leave the mapping alone.
835        /// Either way the report names the mapping that was used.
836        #[arg(long, value_name = "entry|remote")]
837        triage_policy: Option<String>,
838    },
839    /// Roll open issues forward from expired cycles to the next successor
840    Rollover {
841        /// Restrict rollover to one source cycle
842        #[arg(long)]
843        cycle: Option<String>,
844        /// Skip interactive confirmation
845        #[arg(long)]
846        yes: bool,
847    },
848    /// Search issue files for a string (case-insensitive substring over full markdown)
849    Search {
850        /// Case-insensitive substring to match against the full markdown of every issue
851        #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
852        string: String,
853        /// Include archived issues in the search corpus
854        #[arg(long)]
855        with_archived: bool,
856    },
857    /// Render the current board once and exit
858    Render,
859    /// Prune the centralized backups and remove the scattered ones
860    ///
861    /// Backups live in `.tix-backups/` beside the issues directory (#1581);
862    /// `--keep` (default 10) bounds how many are kept per issue. A legacy
863    /// `*.tixbak` or `*.md.bak` from an older release, still beside an issue, is
864    /// removed too — by default each such file is confirmed interactively.
865    Clean {
866        /// Remove every legacy scattered backup without prompting
867        #[arg(short, long)]
868        force: bool,
869        /// Backups to keep per issue in `.tix-backups/`
870        #[arg(long, default_value_t = 10)]
871        keep: usize,
872    },
873    /// Open the TUI with the create-issue form already open
874    ///
875    /// The session `tix` starts, with the modal that `n` opens already in front of
876    /// you: one command to file an issue, instead of "start the board, then press
877    /// n". It is the interactive twin of `tix create`, which never opens the TUI.
878    New,
879    /// Validate frontmatter option values in all issue files
880    ///
881    /// Every finding renders as a rustc-style diagnostic on stderr: the file and
882    /// line, the offending value, and a `help:` line naming the fix. An **error**
883    /// sets the exit code (a broken file); a **warning** does not (a valid file
884    /// that is worth a look) — so a store with warnings still exits `0`.
885    ///
886    /// What is checked:
887    ///
888    /// - per issue: `status` against the board's columns; `priority`, `title`,
889    ///   `type`, `created`/`updated`, `estimate`; the planning fields `cycles`,
890    ///   `milestones`, `start_date`, `due_date`; a `## History` section that is
891    ///   not first in the body (fenced code examples are skipped) or an entry the
892    ///   grammar cannot read; and unrecognised top-level keys (a warning, since
893    ///   the key is preserved on write);
894    /// - the typed relationships: self-links, missing or conflicting targets,
895    ///   cycles, an unknown kind, and the retired `links:` key;
896    /// - the `parent:` hierarchy: `self_parent`, `unresolved_parent`,
897    ///   `unknown_parent`, `cycle`;
898    /// - `related_issues` entries: blank, or a local selector that resolves to
899    ///   nothing (paths and URLs are accepted verbatim);
900    /// - the configuration: board column/category mapping, `[layout]` drift, and
901    ///   a `status:` that contradicts the issue's own recorded History.
902    ///
903    /// Exit: `0` clean or warnings only, `1` any error, `2` usage or an internal
904    /// failure.
905    Lint {
906        /// Do not lint the foreign repositories reachable from `related_issues`
907        /// for this run (overrides `lint_foreign_repositories` in tix.toml).
908        #[arg(long)]
909        no_foreign: bool,
910    },
911    /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
912    Layout {
913        #[command(subcommand)]
914        action: LayoutAction,
915    },
916    /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
917    Mcp,
918    /// Manage stable-ID issue/form templates
919    ///
920    /// Templates live in `[issue_templates.<id>]` in `tix.toml`, keyed by an
921    /// immutable stable ID with a mutable display name and an explicit scope.
922    /// `list`/`show` are read-only; `create`/`update`/`duplicate`/`delete`
923    /// persist through one atomic save, and a refused operation writes nothing.
924    Template {
925        #[command(subcommand)]
926        action: TemplateAction,
927    },
928}
929
930/// The `tix template` management tree (`contracts/cli-surface.md`).
931///
932/// `list`/`show` are read-only; the four mutations call one service that
933/// validates the complete candidate registry before a single atomic
934/// `tix.toml` save, so a refused operation leaves the file byte-identical.
935/// Identity is the immutable stable ID: `update` changes a display name
936/// without touching scope, defaults, prompts, references, or preserved
937/// unknown metadata.
938#[derive(Subcommand)]
939pub enum TemplateAction {
940    /// List templates: one `id<TAB>name<TAB>scope` row each, in declaration order
941    List,
942    /// Show one template by stable ID (or a unique display name)
943    Show {
944        /// Stable ID, or a display name that names exactly one template
945        reference: String,
946    },
947    /// Create a template under a new stable ID
948    Create {
949        /// The new stable ID: ASCII letters, digits, `-` or `_`
950        #[arg(long, value_name = "ID")]
951        id: String,
952        /// Display name; omitted means the ID is shown in its place
953        #[arg(long, value_name = "NAME")]
954        name: Option<String>,
955        /// Scope: `repository` (default) | `board:<name>` | `team:<label>`
956        #[arg(long, value_name = "SCOPE")]
957        scope: Option<String>,
958        /// A normal create default, `FIELD=VALUE` (repeatable). Supplying any
959        /// `--default` replaces the whole defaults block.
960        #[arg(long = "default", value_name = "FIELD=VALUE")]
961        defaults: Vec<String>,
962        /// A structured prompt,
963        /// `key=<field>[,kind=text|select|multi-select|date][,required=true|false][,default=<v>][,label=<l>][,options_ref=<source>]`
964        /// (repeatable). Supplying any `--prompt` replaces the whole prompt list.
965        #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
966        prompts: Vec<String>,
967        /// A typed reference,
968        /// `kind=label|assignee|file|cycle|milestone|relationship,value=<v>[,required=true|false]`
969        /// (repeatable). Supplying any `--reference` replaces the whole list.
970        #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
971        references: Vec<String>,
972    },
973    /// Update the flags supplied, leaving every other stored field as it was
974    Update {
975        /// Stable ID, or a display name that names exactly one template
976        reference: String,
977        /// New display name (the stable ID never changes)
978        #[arg(long, value_name = "NAME")]
979        name: Option<String>,
980        /// New scope: `repository` | `board:<name>` | `team:<label>`
981        #[arg(long, value_name = "SCOPE")]
982        scope: Option<String>,
983        /// Replacement defaults, `FIELD=VALUE` (repeatable)
984        #[arg(long = "default", value_name = "FIELD=VALUE")]
985        defaults: Vec<String>,
986        /// Replacement prompts (repeatable; same grammar as `create`)
987        #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
988        prompts: Vec<String>,
989        /// Replacement references (repeatable; same grammar as `create`)
990        #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
991        references: Vec<String>,
992    },
993    /// Copy a template to a new stable ID, leaving the source untouched
994    Duplicate {
995        /// Stable ID, or a display name that names exactly one template
996        reference: String,
997        /// The new stable ID; omitted allocates `<source>-copy`, `-copy-2`, …
998        #[arg(long, value_name = "ID")]
999        id: Option<String>,
1000        /// Display name for the copy; omitted keeps the source's label
1001        #[arg(long, value_name = "NAME")]
1002        name: Option<String>,
1003    },
1004    /// Delete a template definition (never the issues created from it)
1005    Delete {
1006        /// Stable ID, or a display name that names exactly one template
1007        reference: String,
1008        /// Confirm without prompting
1009        #[arg(long)]
1010        yes: bool,
1011    },
1012}
1013
1014/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
1015///
1016/// `list` and `status` are read-only queue views; the four decision verbs take a
1017/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
1018/// default and writing only under an explicit `--apply`.
1019///
1020/// ## Why the reason flag is `Option` on `accept` and the rest
1021///
1022/// `contracts/decision-history.md` splits it: decline, duplicate and defer
1023/// *require* a reason, accept records one when supplied. A required clap argument
1024/// on three of the four would make the fourth's absence look like an oversight,
1025/// and — more importantly — the enforcement belongs to the service, which refuses
1026/// `missing_reason` before any write on every surface (NFR-003). The flag is
1027/// therefore optional everywhere and the *rule* is the service's, so the CLI,
1028/// TUI and MCP cannot end up with three different ideas of which decision needs
1029/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
1030/// with the contract's own kind.
1031/// `tix health`'s actions.
1032#[derive(Subcommand)]
1033pub enum HealthAction {
1034    /// Record an issue's health state (append-only; needs --rationale)
1035    ///
1036    /// Writes the frontmatter value and **one** History entry, together or not at
1037    /// all. Any of the four states is accepted; anything else is refused by name.
1038    Set {
1039        /// The issue, by number or slug
1040        issue: String,
1041        /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
1042        #[arg(long)]
1043        state: String,
1044        /// Who is making the change (defaults to the configured git identity)
1045        #[arg(long)]
1046        actor: Option<String>,
1047        /// When, as `YYYY-MM-DD` (defaults to today)
1048        #[arg(long)]
1049        date: Option<String>,
1050        /// Why — required: a health change nobody can explain is not recorded
1051        #[arg(long)]
1052        rationale: Option<String>,
1053    },
1054    /// Show one issue's health: its own value, its derived rollup, its diagnostics
1055    Show {
1056        /// The issue, by number or slug
1057        issue: String,
1058    },
1059    /// Show a scope's derived health, with its contributors and policy version
1060    Rollup {
1061        /// `issue` | `project` | `epic` | `cycle` | `milestone`
1062        #[arg(long)]
1063        scope: String,
1064        /// The scope's stable id (an issue number for `--scope issue`)
1065        #[arg(long)]
1066        id: String,
1067    },
1068    /// Layer an explicit override over a derived scope's health
1069    ///
1070    /// An override is a deliberate act about a whole scope, so it needs `--confirm`:
1071    /// it does not replace what the children say, it is recorded *over* it.
1072    Override {
1073        /// `project` | `epic` | `cycle` | `milestone`
1074        #[arg(long)]
1075        scope: String,
1076        /// The scope's stable id
1077        #[arg(long)]
1078        id: String,
1079        /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
1080        #[arg(long)]
1081        state: String,
1082        /// Who is making the change (defaults to the configured git identity)
1083        #[arg(long)]
1084        actor: Option<String>,
1085        /// When, as `YYYY-MM-DD` (defaults to today)
1086        #[arg(long)]
1087        date: Option<String>,
1088        /// Why — required
1089        #[arg(long)]
1090        rationale: Option<String>,
1091        /// Confirm an override of derived health
1092        #[arg(long)]
1093        confirm: bool,
1094    },
1095    /// The append-only health History entries about a scope
1096    History {
1097        /// A scope: `issue:42`, `cycle:2026-10`, `milestone:m1`, …
1098        scope: String,
1099    },
1100    /// Every scope's health, with what contributes to each
1101    Board,
1102}
1103
1104#[derive(Subcommand)]
1105pub enum TriageAction {
1106    /// Read the inbox queue: one row per entry (FR-001)
1107    List {
1108        /// Sort by entry age, oldest first; an unknown age sorts last
1109        #[arg(long)]
1110        age: bool,
1111        /// Print the counts only, with no entries
1112        #[arg(long)]
1113        count: bool,
1114    },
1115    /// Read the inbox summary: decision counts and the age histogram (FR-005)
1116    Status,
1117    /// Accept: move into the normal workflow, validated by #183's transition policy
1118    Accept {
1119        /// Issue number, filename fragment or unique title fragment
1120        issue: String,
1121        /// Reason recorded in History; accept records one when supplied
1122        #[arg(long)]
1123        reason: Option<String>,
1124        /// Target column; defaults to `[triage].accepted_status`
1125        #[arg(long, value_name = "COLUMN")]
1126        to: Option<String>,
1127        /// Who decided; defaults to the git identity
1128        #[arg(long)]
1129        actor: Option<String>,
1130    },
1131    /// Decline: move to `[triage].declined_status`; never deletes or archives
1132    Decline {
1133        /// Issue number, filename fragment or unique title fragment
1134        issue: String,
1135        /// Why it is declined (required)
1136        #[arg(long)]
1137        reason: Option<String>,
1138        /// Who decided; defaults to the git identity
1139        #[arg(long)]
1140        actor: Option<String>,
1141    },
1142    /// Duplicate: record a typed `duplicates` relation; never merges files
1143    Duplicate {
1144        /// Issue number, filename fragment or unique title fragment
1145        issue: String,
1146        /// The issue this one duplicates
1147        #[arg(long, value_name = "ISSUE")]
1148        of: String,
1149        /// Why it is a duplicate (required)
1150        #[arg(long)]
1151        reason: Option<String>,
1152        /// Who decided; defaults to the git identity
1153        #[arg(long)]
1154        actor: Option<String>,
1155    },
1156    /// Defer: stay in the inbox with a refreshed entry time
1157    Defer {
1158        /// Issue number, filename fragment or unique title fragment
1159        issue: String,
1160        /// Why it is deferred (required)
1161        #[arg(long)]
1162        reason: Option<String>,
1163        /// Who decided; defaults to the git identity
1164        #[arg(long)]
1165        actor: Option<String>,
1166    },
1167    /// One decision across a selection: preview by default, `--apply` to write
1168    Batch {
1169        /// Comma-separated selectors: number, filename or title fragment
1170        #[arg(long, value_name = "LIST")]
1171        select: Option<String>,
1172        /// A `tix list --filter` query selecting the issues
1173        #[arg(long, value_name = "QUERY")]
1174        filter: Option<String>,
1175        /// Which decision to make: accept | decline | duplicate | defer
1176        #[arg(long, value_name = "KIND")]
1177        decision: String,
1178        /// Reason recorded on every applied event; required for three of four
1179        #[arg(long)]
1180        reason: Option<String>,
1181        /// Who decided; defaults to the git identity
1182        #[arg(long)]
1183        actor: Option<String>,
1184        /// The duplicate target selector, for `--decision duplicate`
1185        #[arg(long, value_name = "ISSUE")]
1186        of: Option<String>,
1187        /// The accept target column, for `--decision accept`
1188        #[arg(long, value_name = "COLUMN")]
1189        to: Option<String>,
1190        /// Apply the previewed plan; without it nothing is written
1191        #[arg(long)]
1192        apply: bool,
1193    },
1194}
1195
1196#[derive(Subcommand)]
1197pub enum CycleCommand {
1198    Create {
1199        #[arg(long)]
1200        name: Option<String>,
1201        #[arg(long)]
1202        description: Option<String>,
1203        #[arg(long = "start-date")]
1204        start_date: Option<String>,
1205        #[arg(long = "due-date")]
1206        due_date: Option<String>,
1207    },
1208    List,
1209    Edit {
1210        #[arg(long)]
1211        name: String,
1212        #[arg(long = "new-name")]
1213        new_name: Option<String>,
1214        #[arg(long)]
1215        description: Option<String>,
1216        #[arg(long = "start-date")]
1217        start_date: Option<String>,
1218        #[arg(long = "due-date")]
1219        due_date: Option<String>,
1220    },
1221    Delete {
1222        #[arg(long)]
1223        name: String,
1224        #[arg(long)]
1225        yes: bool,
1226    },
1227    /// Manage repeating cycle schedules (`[cycle_schedules.<id>]`)
1228    ///
1229    /// A schedule is a stable id (the registry key) with a mutable display name;
1230    /// generated cycle instances are ordinary cycle records this family creates
1231    /// idempotently from the schedule's definition. Detection is read-only; every
1232    /// mutation requires `--confirm`.
1233    Schedule {
1234        #[command(subcommand)]
1235        command: CycleScheduleCommand,
1236    },
1237}
1238
1239/// The `tix cycle schedule` subcommands (mission
1240/// `repeating-cycle-schedules-01M3C7W1`, WP06).
1241///
1242/// The surface contract's spelling (`contracts/cli-surface.md`): a schedule
1243/// namespace under `tix cycle`, with stable-ID selectors, explicit date/timezone
1244/// context and confirmed mutations. This enum is a thin adapter — the schedule
1245/// record, the read model, evaluation, generation and the lifecycle all live in
1246/// `crate::cycle_schedule`; nothing here re-derives a cadence, a cursor or a
1247/// History entry.
1248#[derive(Debug, Clone, Subcommand)]
1249pub enum CycleScheduleCommand {
1250    /// List schedules, one tab-separated row per schedule
1251    ///
1252    /// Columns: `id`, `name`, `state`, `cadence`, `duration`, `start_date`,
1253    /// `look_ahead`, `next_sequence`, `generated`.
1254    List,
1255    /// Show one schedule: identity, cadence, duration, cursor and its instances
1256    Show {
1257        /// Stable schedule id, or its unique display name
1258        #[arg(value_name = "SCHEDULE")]
1259        schedule: String,
1260    },
1261    /// Define a new repeating schedule; validated before persistence, requires `--confirm`
1262    Create(CycleScheduleCreateArgs),
1263    /// Change a schedule's fields, keeping its stable id; requires `--confirm`
1264    ///
1265    /// Only the flags you name change; the rest of the record (including its
1266    /// generation cursor and preserved metadata) is carried over.
1267    Edit {
1268        /// Stable schedule id, or its unique display name
1269        #[arg(value_name = "SCHEDULE")]
1270        schedule: String,
1271        #[command(flatten)]
1272        args: CycleScheduleEditArgs,
1273        /// Confirm the change without prompting
1274        #[arg(long)]
1275        confirm: bool,
1276    },
1277    /// Delete a schedule's registry entry; requires `--confirm`
1278    ///
1279    /// The generated cycles it created and their snapshots are **not** deleted:
1280    /// they keep their schedule reference, which then reads as a diagnosable
1281    /// missing schedule, so nothing is destroyed by deleting a definition.
1282    Delete {
1283        /// Stable schedule id, or its unique display name
1284        #[arg(value_name = "SCHEDULE")]
1285        schedule: String,
1286        /// Confirm the deletion without prompting
1287        #[arg(long)]
1288        confirm: bool,
1289    },
1290    /// Report ended/due/look-ahead warnings, read-only
1291    ///
1292    /// Prints the warnings and the explicit decisions each accepts. It writes
1293    /// nothing: no file, no cursor move, no History entry.
1294    Detect {
1295        /// Evaluate against this `YYYY-MM-DD` date instead of today
1296        #[arg(long, value_name = "YYYY-MM-DD")]
1297        date: Option<String>,
1298    },
1299    /// Create the schedule's future cycles; idempotent, requires `--confirm`
1300    ///
1301    /// A repeated call under the same stable keys is a no-op, never a duplicate.
1302    Generate {
1303        /// Stable schedule id, or its unique display name
1304        #[arg(value_name = "SCHEDULE")]
1305        schedule: String,
1306        /// Generate every candidate whose start date is on or before this date
1307        #[arg(long, value_name = "YYYY-MM-DD")]
1308        through: Option<String>,
1309        /// Confirm the generation without prompting
1310        #[arg(long)]
1311        confirm: bool,
1312    },
1313    /// Close an instance: capture its immutable snapshot and mark it closed
1314    Close {
1315        /// Stable schedule id, or its unique display name
1316        #[arg(value_name = "SCHEDULE")]
1317        schedule: String,
1318        #[command(flatten)]
1319        args: CycleScheduleReasonArgs,
1320    },
1321    /// Reopen a closed instance: restore `active` and append one History event
1322    ///
1323    /// The closed cycle's capacity snapshot is **not** rewritten: it stays the
1324    /// immutable evidence the close captured, and the appended `cycle_reopened`
1325    /// event names it. Reopening an open instance is a no-op, and the action
1326    /// requires both a reason and the instance it names.
1327    Reopen {
1328        /// Stable schedule id, or its unique display name
1329        #[arg(value_name = "SCHEDULE")]
1330        schedule: String,
1331        #[command(flatten)]
1332        args: CycleScheduleReasonArgs,
1333    },
1334    /// Push an instance's due date out by a cadence period
1335    Extend {
1336        /// Stable schedule id, or its unique display name
1337        #[arg(value_name = "SCHEDULE")]
1338        schedule: String,
1339        #[command(flatten)]
1340        args: CycleScheduleExtendArgs,
1341    },
1342    /// Record a sequence as skipped so it is never regenerated
1343    Skip {
1344        /// Stable schedule id, or its unique display name
1345        #[arg(value_name = "SCHEDULE")]
1346        schedule: String,
1347        #[command(flatten)]
1348        args: CycleScheduleReasonArgs,
1349    },
1350    /// Pause a schedule and its non-terminal instances
1351    Pause {
1352        /// Stable schedule id, or its unique display name
1353        #[arg(value_name = "SCHEDULE")]
1354        schedule: String,
1355        #[command(flatten)]
1356        args: CycleScheduleLifecycleArgs,
1357    },
1358    /// Resume a paused schedule and its instances
1359    Resume {
1360        /// Stable schedule id, or its unique display name
1361        #[arg(value_name = "SCHEDULE")]
1362        schedule: String,
1363        #[command(flatten)]
1364        args: CycleScheduleLifecycleArgs,
1365    },
1366    /// Re-derive every future instance's dates from the schedule's definition
1367    AdjustFuture {
1368        /// Stable schedule id, or its unique display name
1369        #[arg(value_name = "SCHEDULE")]
1370        schedule: String,
1371        #[command(flatten)]
1372        args: CycleScheduleReasonArgs,
1373    },
1374    /// Close a warning without deciding anything; nothing is written
1375    Ignore {
1376        /// Stable schedule id, or its unique display name
1377        #[arg(value_name = "SCHEDULE")]
1378        schedule: String,
1379        #[command(flatten)]
1380        args: CycleScheduleLifecycleArgs,
1381    },
1382}
1383
1384/// The flags that define a schedule (`tix cycle schedule create`).
1385#[derive(Debug, Clone, Args)]
1386pub struct CycleScheduleCreateArgs {
1387    /// Stable schedule id (letters, digits, `-`, `_`, `:`); never reused
1388    #[arg(long, value_name = "ID")]
1389    pub id: String,
1390    /// Display label, unique case-insensitively within the registry
1391    #[arg(long, value_name = "NAME")]
1392    pub name: String,
1393    /// Cadence family: `daily`, `weekly`, `monthly` or `interval`
1394    ///
1395    /// `interval` is the family that carries `--unit`; the other three are the
1396    /// calendar-step families and refuse a unit.
1397    #[arg(long, value_name = "KIND")]
1398    pub cadence: String,
1399    /// How many cadence units between cycle starts (1-1000; absent means 1)
1400    #[arg(long, value_name = "N")]
1401    pub interval: Option<u32>,
1402    /// The unit an `interval` cadence counts in: `days`, `weeks`, `months`
1403    #[arg(long, value_name = "UNIT")]
1404    pub unit: Option<String>,
1405    /// How long one cycle lasts, as a positive count
1406    #[arg(long, value_name = "N")]
1407    pub duration: u32,
1408    /// The duration's unit: `days`, `weeks`, `months`
1409    #[arg(long = "duration-unit", value_name = "UNIT")]
1410    pub duration_unit: String,
1411    /// The schedule's stable anchor, a `YYYY-MM-DD` calendar date
1412    #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1413    pub start_date: String,
1414    /// Fixed UTC offset the schedule is expressed in: `UTC` or `±HH:MM`
1415    ///
1416    /// `allow_hyphen_values` so a west-of-UTC offset (`-05:00`) reaches the
1417    /// validator instead of being swallowed by clap as an unknown flag.
1418    #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1419    pub timezone: String,
1420    /// How many future cycles to consider beyond the current one (0 when absent)
1421    #[arg(long = "look-ahead", value_name = "N")]
1422    pub look_ahead: Option<u32>,
1423    /// Confirm the creation without prompting
1424    #[arg(long)]
1425    pub confirm: bool,
1426}
1427
1428/// What `tix cycle schedule edit` may change. Every field is optional; only the
1429/// named ones change, so a partial edit keeps the rest of the record.
1430#[derive(Debug, Clone, Args)]
1431pub struct CycleScheduleEditArgs {
1432    /// New display label
1433    #[arg(long, value_name = "NAME")]
1434    pub name: Option<String>,
1435    /// New cadence family: `daily`, `weekly`, `monthly` or `interval`
1436    #[arg(long, value_name = "KIND")]
1437    pub cadence: Option<String>,
1438    /// New cadence interval (1-1000)
1439    #[arg(long, value_name = "N")]
1440    pub interval: Option<u32>,
1441    /// New cadence unit (only meaningful for the `interval` family)
1442    #[arg(long, value_name = "UNIT")]
1443    pub unit: Option<String>,
1444    /// New duration count
1445    #[arg(long, value_name = "N")]
1446    pub duration: Option<u32>,
1447    /// New duration unit
1448    #[arg(long = "duration-unit", value_name = "UNIT")]
1449    pub duration_unit: Option<String>,
1450    /// New anchor date, `YYYY-MM-DD`
1451    #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
1452    pub start_date: Option<String>,
1453    /// New timezone: `UTC` or `±HH:MM` (`allow_hyphen_values` for `-05:00`)
1454    #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
1455    pub timezone: Option<String>,
1456    /// New look-ahead count
1457    #[arg(long = "look-ahead", value_name = "N")]
1458    pub look_ahead: Option<u32>,
1459}
1460
1461/// The flags every `tix cycle schedule` lifecycle action shares.
1462#[derive(Debug, Clone, Args)]
1463pub struct CycleScheduleLifecycleArgs {
1464    /// Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)
1465    #[arg(long, value_name = "CYCLE")]
1466    pub cycle: Option<String>,
1467    /// Select the instance by its start date, `YYYY-MM-DD`
1468    #[arg(long, value_name = "YYYY-MM-DD")]
1469    pub from: Option<String>,
1470    /// Confirm the action without prompting
1471    #[arg(long)]
1472    pub confirm: bool,
1473}
1474
1475/// The lifecycle flags plus the reason the recording actions require.
1476#[derive(Debug, Clone, Args)]
1477pub struct CycleScheduleReasonArgs {
1478    #[command(flatten)]
1479    pub lifecycle: CycleScheduleLifecycleArgs,
1480    /// Why; recorded in the instance's History
1481    #[arg(long, value_name = "TEXT")]
1482    pub reason: Option<String>,
1483}
1484
1485/// `tix cycle schedule extend`'s flags: the reason plus the period count.
1486#[derive(Debug, Clone, Args)]
1487pub struct CycleScheduleExtendArgs {
1488    #[command(flatten)]
1489    pub reason: CycleScheduleReasonArgs,
1490    /// How many cadence periods to extend by; the shared lifecycle supports 1
1491    #[arg(long, value_name = "N")]
1492    pub by: Option<u32>,
1493}
1494
1495/// The `tix capacity` subcommands (mission `cycle-capacity-planning-01M3C85C`, WP05).
1496///
1497/// A thin adapter: the `[capacity]` section, its validation, the shared calculation,
1498/// the findings and the close evidence all live in `crate::capacity`. This enum owns
1499/// only the spelling of the surface — the scope and the unit are always explicit
1500/// (C-003/C-004), and no flag here can change an issue.
1501#[derive(Debug, Clone, Subcommand)]
1502pub enum BeadsAction {
1503    /// Report what `bd` and its workspace look like (read-only)
1504    ///
1505    /// Runs only `bd version`, `bd where` and `bd config get`, so it is safe on a
1506    /// repository that has not opted in — and it names a workspace whose events
1507    /// journal is off, because a live mirror would then see nothing.
1508    Detect,
1509    /// A read-only summary of the beads workspace
1510    ///
1511    /// The open/closed/blocked/ready figures are computed the way beads derives
1512    /// them — from the `blocks` dependencies — so they agree with `bd ready`
1513    /// rather than guessing.
1514    Status,
1515    /// List beads issues, read-only
1516    List {
1517        /// Include closed issues
1518        #[arg(long)]
1519        all: bool,
1520    },
1521    /// Show one beads issue, read-only
1522    Show {
1523        /// Beads id, or a unique fragment of one (or of a title)
1524        issue: String,
1525    },
1526    /// Follow the beads events journal, printing each change as it commits
1527    ///
1528    /// Reads the journal in pages and re-reads from its own checkpoint, so it
1529    /// survives a gap between reads. Requires the journal to be enabled wherever
1530    /// beads writes; `detect` says whether it is.
1531    Watch {
1532        /// Sequence to resume from (default: the beginning of what is retained)
1533        #[arg(long)]
1534        since: Option<u64>,
1535        /// Seconds between reads
1536        #[arg(long, default_value_t = 1)]
1537        interval: u64,
1538    },
1539    /// Write local issues into beads through `bd import`
1540    ///
1541    /// **Incremental by default**: only issues whose exported row changed since
1542    /// the last push are sent, so a repeat run with nothing changed invokes `bd`
1543    /// not at all. `--all` forces every issue through, which is what a discarded
1544    /// state file or a rebuilt beads workspace needs.
1545    Push {
1546        /// Print the JSONL instead of importing it
1547        #[arg(long)]
1548        dry_run: bool,
1549        /// Send every issue, ignoring the export watermark
1550        #[arg(long)]
1551        all: bool,
1552        /// List the per-issue diagnostics (which local fields beads cannot hold)
1553        #[arg(long)]
1554        verbose: bool,
1555    },
1556    /// Pull beads issues into local files
1557    Pull {
1558        /// Print nothing on success (used by the beads hooks)
1559        #[arg(long)]
1560        quiet: bool,
1561    },
1562    /// Pull, then push — the full both-ways pass
1563    Reconcile {
1564        /// Print nothing on success beyond the push report
1565        #[arg(long)]
1566        quiet: bool,
1567    },
1568}
1569
1570#[derive(Debug, Clone, Subcommand)]
1571pub enum CapacityCommand {
1572    /// List every configured ceiling, one tab-separated row per entry
1573    ///
1574    /// Columns: `id`, `scope`, `reference`, `display`, `capacity`, `unit`,
1575    /// `enabled`. The header prints even with no entries, so an empty section is
1576    /// visibly empty rather than indistinguishable from a failure.
1577    List,
1578    /// Show one ceiling by the reference it names
1579    ///
1580    /// A reference is a cycle's name or stable id, a team label, or an assignee
1581    /// identity; `--scope` narrows it when one reference names two scopes.
1582    Show {
1583        /// Cycle name/stable id, team label, or assignee identity
1584        #[arg(value_name = "REFERENCE")]
1585        reference: String,
1586        /// Restrict the match to one scope: `cycle`, `team` or `assignee`
1587        #[arg(long, value_name = "SCOPE")]
1588        scope: Option<String>,
1589    },
1590    /// Set one scope's ceiling; validated before persistence
1591    ///
1592    /// The scope and the unit are explicit: a value with no unit, a unit no board
1593    /// declares, an unknown cycle/team, or a second ceiling for one scope is
1594    /// refused before `tix.toml` changes.
1595    Set {
1596        /// Scope dimension: `cycle`, `team` or `assignee`
1597        #[arg(long, value_name = "SCOPE")]
1598        scope: String,
1599        /// The scope's reference (a cycle name or stable id, a team label, an identity)
1600        #[arg(long, value_name = "ID")]
1601        id: String,
1602        /// The ceiling, in `--unit`, at or above zero
1603        ///
1604        /// A negative value is accepted by the parser and refused by the shared
1605        /// validator with its field path (`capacity.<id>.capacity`), which is a better
1606        /// message than clap's "unexpected argument".
1607        #[arg(long, value_name = "N", allow_hyphen_values = true)]
1608        value: i64,
1609        /// The estimate unit the ceiling counts in (declared by the active board)
1610        #[arg(long, value_name = "UNIT")]
1611        unit: String,
1612        /// Keep the value and the unit, but enforce nothing
1613        #[arg(long)]
1614        disabled: bool,
1615    },
1616    /// Delete one scope's ceiling
1617    Delete {
1618        /// Scope dimension: `cycle`, `team` or `assignee`
1619        #[arg(long, value_name = "SCOPE")]
1620        scope: String,
1621        /// The scope's reference (a cycle name or stable id, a team label, an identity)
1622        #[arg(long, value_name = "ID")]
1623        id: String,
1624    },
1625    /// Calculate one scope's committed/completed/remaining/overflow/utilization
1626    ///
1627    /// Every value comes from the shared calculation DTO; a value the calculation
1628    /// could not derive prints as `-`, never as a zero. Findings go to stderr.
1629    Calculate {
1630        /// Scope dimension: `cycle`, `team` or `assignee`
1631        #[arg(long, value_name = "SCOPE")]
1632        scope: String,
1633        /// The scope's reference (a cycle name or stable id, a team label, an identity)
1634        #[arg(long, value_name = "ID")]
1635        id: String,
1636        /// Also render the overflow report (estimate and reference findings always render)
1637        #[arg(long)]
1638        warnings: bool,
1639    },
1640    /// Read back a closed cycle's immutable capacity evidence
1641    Snapshot {
1642        /// Cycle name or stable `<schedule-id>:<sequence>` id
1643        #[arg(value_name = "CYCLE")]
1644        cycle: String,
1645        /// Also render the overflow finding the close recorded
1646        #[arg(long)]
1647        warnings: bool,
1648    },
1649}
1650
1651/// The `tix time` subcommands (mission `per-issue-time-tracking-01M3C9A4`, WP04).
1652///
1653/// A thin adapter: the entry model and its validation, the mutation service and its
1654/// append-only History, and every filter, total and finding live under
1655/// `crate::time_tracking`. This enum owns only the spelling of the surface — the
1656/// duration is whole minutes, a date is a calendar `YYYY-MM-DD`, and there is no flag
1657/// here that could express a timer, a timezone, an overlap rule or a billing unit
1658/// (C-002/C-009).
1659#[derive(Debug, Clone, Subcommand)]
1660pub enum TimeCommand {
1661    /// Record effort against an issue, in whole minutes
1662    ///
1663    /// The date is optional and is a calendar date, never an instant: an entry without
1664    /// one is real, and a period-bounded read reports it as undated rather than as zero.
1665    Add {
1666        /// Issue number, filename fragment or unique title fragment
1667        #[arg(value_name = "ISSUE")]
1668        issue: String,
1669        /// The effort, in whole minutes at or above zero
1670        ///
1671        /// A negative value is accepted by the parser and refused by the shared entry
1672        /// validator with its field name, which is a better message than clap's
1673        /// "unexpected argument".
1674        #[arg(long, value_name = "N", allow_hyphen_values = true)]
1675        minutes: i64,
1676        /// The calendar date the effort belongs to, `YYYY-MM-DD`
1677        #[arg(long, value_name = "YYYY-MM-DD")]
1678        date: Option<String>,
1679        /// Who did the work: a handle (`ada`, `@ada`) or a `Name <email>` identity
1680        #[arg(long, value_name = "IDENTITY")]
1681        author: String,
1682        /// Free-text context, preserved verbatim
1683        #[arg(long, value_name = "TEXT")]
1684        note: Option<String>,
1685    },
1686    /// Correct one entry, naming only the fields that change
1687    ///
1688    /// A field the command does not name is left exactly as it was, including the entry's
1689    /// date, note and any metadata another build attached. `--clear-date` and
1690    /// `--clear-note` are how an optional value is removed (an empty `--note ""` would be
1691    /// a value, not an absence). A correction requires `--reason`: the prior value stays
1692    /// in History, and the reason is what makes the change attributable.
1693    Edit {
1694        /// Issue number, filename fragment or unique title fragment
1695        #[arg(value_name = "ISSUE")]
1696        issue: String,
1697        /// The stable id of the entry to correct (`tix time show <ISSUE>` lists them)
1698        #[arg(long, value_name = "ID")]
1699        entry: String,
1700        #[command(flatten)]
1701        change: TimeEditArgs,
1702    },
1703    /// Remove one entry from the issue's current entries
1704    ///
1705    /// The entry leaves the frontmatter; its value and its event stay in History, so a
1706    /// deletion is auditable rather than a rewrite. Requires `--reason` and `--confirm`.
1707    Delete {
1708        /// Issue number, filename fragment or unique title fragment
1709        #[arg(value_name = "ISSUE")]
1710        issue: String,
1711        /// The stable id of the entry to remove
1712        #[arg(long, value_name = "ID")]
1713        entry: String,
1714        /// Why the entry is being removed; recorded in History
1715        #[arg(long, value_name = "TEXT")]
1716        reason: Option<String>,
1717        /// Confirm the removal without prompting
1718        #[arg(long)]
1719        confirm: bool,
1720    },
1721    /// Show an issue's current entries and its total
1722    Show {
1723        /// Issue number, filename fragment or unique title fragment
1724        #[arg(value_name = "ISSUE")]
1725        issue: String,
1726    },
1727    /// Show what History retains for an issue: current entries beside its audit
1728    History {
1729        /// Issue number, filename fragment or unique title fragment
1730        #[arg(value_name = "ISSUE")]
1731        issue: String,
1732    },
1733    /// Total recorded effort, by issue, assignee and calendar period
1734    ///
1735    /// Every value comes from the shared calculation; a scope with nothing to total and a
1736    /// scope whose entries carry no usable duration print `-` with their state, never a
1737    /// zero. Deleted entries are excluded unless `--audit` asks for them.
1738    Total {
1739        /// Issue number, filename fragment or unique title fragment (alternative to `--issue`)
1740        #[arg(value_name = "ISSUE")]
1741        selector: Option<String>,
1742        #[command(flatten)]
1743        filter: TimeTotalArgs,
1744    },
1745}
1746
1747/// The fields an entry correction can name (T020).
1748///
1749/// A patch, not a replacement: a flag that is absent leaves its field alone, which is why
1750/// removal is spelled `--clear-date`/`--clear-note` rather than an empty value. The reason
1751/// is required — by the service, which refuses a missing one by name — and is what the
1752/// History event keeps once the prior value is replaced.
1753#[derive(Debug, Clone, Args)]
1754pub struct TimeEditArgs {
1755    /// Replacement duration, in whole minutes
1756    #[arg(long, value_name = "N", allow_hyphen_values = true)]
1757    pub minutes: Option<i64>,
1758    /// Replacement calendar date, `YYYY-MM-DD`
1759    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "clear_date")]
1760    pub date: Option<String>,
1761    /// Remove the entry's date
1762    #[arg(long)]
1763    pub clear_date: bool,
1764    /// Replacement note
1765    #[arg(long, value_name = "TEXT", conflicts_with = "clear_note")]
1766    pub note: Option<String>,
1767    /// Remove the entry's note
1768    #[arg(long)]
1769    pub clear_note: bool,
1770    /// Replacement author identity
1771    #[arg(long, value_name = "IDENTITY")]
1772    pub author: Option<String>,
1773    /// Why the entry is being corrected; recorded in History, and required
1774    #[arg(long, value_name = "TEXT")]
1775    pub reason: Option<String>,
1776}
1777
1778/// The read-only scope a `tix time total` is evaluated under (T021).
1779///
1780/// Every criterion is optional and every named one must match, and the period is
1781/// inclusive. The dates are the caller's: this surface never resolves a `today`, so a
1782/// total means exactly the calendar window it was asked for.
1783#[derive(Debug, Clone, Args)]
1784pub struct TimeTotalArgs {
1785    /// Stable issue number to scope the total to
1786    #[arg(long, value_name = "N")]
1787    pub issue: Option<u32>,
1788    /// The identity that did the work (an entry's author)
1789    #[arg(long, value_name = "IDENTITY")]
1790    pub assignee: Option<String>,
1791    /// Inclusive period start, `YYYY-MM-DD`
1792    #[arg(long, value_name = "YYYY-MM-DD")]
1793    pub from: Option<String>,
1794    /// Inclusive period end, `YYYY-MM-DD`
1795    #[arg(long, value_name = "YYYY-MM-DD")]
1796    pub to: Option<String>,
1797    /// Also report the entries deletions left in History (never added to the total)
1798    #[arg(long)]
1799    pub audit: bool,
1800    /// Break the total down: `issue`, `assignee` or `date`
1801    #[arg(long, value_name = "DIMENSION")]
1802    pub by: Option<String>,
1803}
1804
1805#[derive(Subcommand)]
1806pub enum MilestoneCommand {
1807    Create {
1808        #[arg(long)]
1809        name: String,
1810        #[arg(long = "target-date")]
1811        target_date: Option<String>,
1812        #[arg(long)]
1813        description: Option<String>,
1814    },
1815    List,
1816    Edit {
1817        #[arg(long)]
1818        name: String,
1819        #[arg(long = "new-name")]
1820        new_name: Option<String>,
1821        #[arg(long = "target-date")]
1822        target_date: Option<String>,
1823        #[arg(long)]
1824        description: Option<String>,
1825    },
1826    Delete {
1827        #[arg(long)]
1828        name: String,
1829        #[arg(long)]
1830        yes: bool,
1831    },
1832}
1833
1834/// The `tix project` subcommands (mission `first-class-projects-01M3BYBW`, WP05).
1835///
1836/// A **required** subcommand, like `tix milestone` and `tix cycle`: `tix project`
1837/// alone is a usage error, not a listing, because guessing which of ten verbs the
1838/// user meant is worse than asking.
1839///
1840/// Selection is by the stable id (the registry key); a name is accepted as a
1841/// convenience and resolves to the same project, since `tix.toml` guarantees the
1842/// names are unique.
1843#[derive(Subcommand)]
1844pub enum ProjectCommand {
1845    /// Create a project and allocate a stable id
1846    ///
1847    /// The name is a display label, not the identity: it can be changed later with
1848    /// `tix project edit --name`, and every issue association survives because the
1849    /// issues reference the id.
1850    Create {
1851        /// Display name (unique within the registry, case-insensitively)
1852        #[arg(value_name = "NAME")]
1853        name: String,
1854        /// Long-form context
1855        #[arg(long)]
1856        description: Option<String>,
1857        /// Free-form project status (not a board column)
1858        #[arg(long)]
1859        status: Option<String>,
1860        /// Lead identity string
1861        #[arg(long)]
1862        lead: Option<String>,
1863        /// Start date (`YYYY-MM-DD`)
1864        #[arg(long = "start-date")]
1865        start_date: Option<String>,
1866        /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1867        #[arg(long = "due-date")]
1868        due_date: Option<String>,
1869    },
1870    /// Edit a project's fields; a rename changes the label only
1871    ///
1872    /// Nothing is re-keyed: the id stays, so every association and every derived
1873    /// figure is untouched. An optional text flag with a blank value clears it
1874    /// (`--lead ""`), the same convention `tix edit` uses.
1875    Edit {
1876        /// Project id (a name is accepted as a convenience)
1877        #[arg(value_name = "ID")]
1878        reference: String,
1879        /// New display name
1880        #[arg(long)]
1881        name: Option<String>,
1882        /// New long-form context (`--description ""` clears)
1883        #[arg(long)]
1884        description: Option<String>,
1885        /// New free-form status (`--status ""` clears)
1886        #[arg(long)]
1887        status: Option<String>,
1888        /// New lead identity string (`--lead ""` clears)
1889        #[arg(long)]
1890        lead: Option<String>,
1891        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
1892        #[arg(long = "start-date")]
1893        start_date: Option<String>,
1894        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
1895        #[arg(long = "due-date")]
1896        due_date: Option<String>,
1897    },
1898    /// List projects in declaration order, with ids and member counts
1899    List,
1900    /// Show one project's record and its members
1901    Show {
1902        /// Project id (a name is accepted as a convenience)
1903        #[arg(value_name = "ID")]
1904        reference: String,
1905    },
1906    /// Delete a project's registry entry (its issues are never deleted)
1907    ///
1908    /// The issues that referenced the project keep their references; the report
1909    /// names them so the dangling references can be repaired deliberately.
1910    Delete {
1911        /// Project id (a name is accepted as a convenience)
1912        #[arg(value_name = "ID")]
1913        reference: String,
1914        /// Confirm without prompting
1915        #[arg(long)]
1916        yes: bool,
1917    },
1918    /// Associate one or more issues with a project
1919    ///
1920    /// Each selector is processed independently: a rejection on one is reported
1921    /// and does not abort the rest.
1922    Add {
1923        /// Project id (a name is accepted as a convenience)
1924        #[arg(value_name = "ID")]
1925        reference: String,
1926        /// Issue numbers, filename fragments or unique title fragments
1927        #[arg(required = true, value_name = "ISSUE")]
1928        issues: Vec<String>,
1929    },
1930    /// Dissociate one or more issues from a project
1931    ///
1932    /// Drops only the association; no issue is deleted, and each selector is
1933    /// processed independently.
1934    ///
1935    /// A reference that does not name a live project is used **verbatim**, because
1936    /// removing a retained stale id is the repair path a project deletion leaves
1937    /// open (C-005). A typo that matches nothing is therefore an inert no-op that
1938    /// reports `0 changed`, not a refusal — which is the one place this differs
1939    /// from `tix edit <issue> --remove-project <id>`, where an unknown reference the
1940    /// issue does not carry is refused as `unknown_project`.
1941    Remove {
1942        /// Project id (a name is accepted as a convenience)
1943        #[arg(value_name = "ID")]
1944        reference: String,
1945        /// Issue numbers, filename fragments or unique title fragments
1946        #[arg(required = true, value_name = "ISSUE")]
1947        issues: Vec<String>,
1948    },
1949    /// Bulk-associate one or more issues with a project
1950    ///
1951    /// Project association only — general bulk editing is a different feature.
1952    /// Every selector is processed independently and every outcome is reported,
1953    /// so one unknown selector does not discard the associations that succeeded.
1954    ///
1955    /// An unknown **project** reference refuses the whole command before anything
1956    /// is written (all entries then fail with `unknown_project`); use
1957    /// `tix project remove` to drop a retained stale id, whose reference is matched
1958    /// verbatim and reported as `0 changed` rather than refused.
1959    BulkAdd {
1960        /// Project id (a name is accepted as a convenience)
1961        #[arg(value_name = "ID")]
1962        reference: String,
1963        /// Issue numbers, filename fragments or unique title fragments
1964        #[arg(required = true, value_name = "ISSUE")]
1965        issues: Vec<String>,
1966    },
1967}
1968
1969/// The `tix epic` subcommands (mission `epics-work-items-01M3BYW4`, WP05).
1970///
1971/// A **required** subcommand, like `tix project`. Selection is by the stable id
1972/// (the registry key); a name resolves to the same epic as a convenience. A work
1973/// item is an ordinary issue (`tix create --epics`, `tix edit --add-epic`), so
1974/// there is no task verb here.
1975#[derive(Subcommand)]
1976pub enum EpicCommand {
1977    /// Create an epic and allocate a stable id
1978    ///
1979    /// The name is a display label, not the identity: `tix epic edit --name`
1980    /// changes it and every issue reference survives, because issues reference the id.
1981    Create {
1982        /// Display name (unique within the registry, case-insensitively)
1983        #[arg(value_name = "NAME")]
1984        name: String,
1985        /// Long-form context
1986        #[arg(long)]
1987        description: Option<String>,
1988        /// Free-form epic status (not a board column, not a category)
1989        #[arg(long)]
1990        status: Option<String>,
1991        /// Owner identity string
1992        #[arg(long)]
1993        owner: Option<String>,
1994        /// Start date (`YYYY-MM-DD`)
1995        #[arg(long = "start-date")]
1996        start_date: Option<String>,
1997        /// Due date (`YYYY-MM-DD`); must be `>= start-date`
1998        #[arg(long = "due-date")]
1999        due_date: Option<String>,
2000        /// Positive relative size, used by the epic rollup
2001        ///
2002        /// `allow_hyphen_values` so `--weight -5` reaches the service's
2003        /// `invalid_weight` refusal rather than being swallowed by clap as an
2004        /// unknown flag — the same invalid input must report one thing whichever
2005        /// spelling is used.
2006        #[arg(long, allow_hyphen_values = true)]
2007        weight: Option<String>,
2008        /// Project ids this epic concerns, comma-separated
2009        ///
2010        /// Shape-checked only, never resolved: a well-formed id the registry does
2011        /// not hold is stored and diagnosed by `tix lint`, so it is repairable.
2012        #[arg(long, value_name = "ID")]
2013        projects: Option<String>,
2014        /// Milestone name; stored verbatim, never resolved
2015        #[arg(long)]
2016        milestone: Option<String>,
2017        /// Cycle name; stored verbatim, never resolved
2018        #[arg(long)]
2019        cycle: Option<String>,
2020    },
2021    /// Edit an epic's fields; a rename changes the label only
2022    ///
2023    /// Nothing is re-keyed, so every issue reference and derived figure stays
2024    /// intact. A blank optional value clears the field (`--owner ""`).
2025    Edit {
2026        /// Epic id (a name is accepted as a convenience)
2027        #[arg(value_name = "ID")]
2028        reference: String,
2029        /// New display name
2030        #[arg(long)]
2031        name: Option<String>,
2032        /// New long-form context (`--description ""` clears)
2033        #[arg(long)]
2034        description: Option<String>,
2035        /// New free-form status (`--status ""` clears)
2036        #[arg(long)]
2037        status: Option<String>,
2038        /// New owner identity string (`--owner ""` clears)
2039        #[arg(long)]
2040        owner: Option<String>,
2041        /// New start date (`--start-date ""` clears)
2042        #[arg(long = "start-date")]
2043        start_date: Option<String>,
2044        /// New due date (`--due-date ""` clears)
2045        #[arg(long = "due-date")]
2046        due_date: Option<String>,
2047        /// New positive relative size (`--weight ""` clears)
2048        ///
2049        /// `allow_hyphen_values` so `--weight -5` reaches the service's
2050        /// `invalid_weight` refusal rather than clap's unknown-flag error.
2051        #[arg(long, allow_hyphen_values = true)]
2052        weight: Option<String>,
2053        /// Set or change the parent epic; `--parent ""` detaches it
2054        ///
2055        /// Validated by the epic service before any write, so a self-parent or a
2056        /// cycle-closing change leaves the registry byte-identical.
2057        #[arg(long, value_name = "ID")]
2058        parent: Option<String>,
2059        /// Replace the whole project list, comma-separated (`--projects ""` clears)
2060        ///
2061        /// Shape-checked only, never resolved: a dangling but well-formed id is
2062        /// stored and diagnosed by `tix lint`.
2063        #[arg(long, value_name = "ID")]
2064        projects: Option<String>,
2065        /// New milestone name (`--milestone ""` clears); stored verbatim
2066        #[arg(long)]
2067        milestone: Option<String>,
2068        /// New cycle name (`--cycle ""` clears); stored verbatim
2069        #[arg(long)]
2070        cycle: Option<String>,
2071    },
2072    /// List epics in declaration order, with ids, member and child counts
2073    List,
2074    /// Show one epic's record, hierarchy, progress and members
2075    Show {
2076        /// Epic id (a name is accepted as a convenience)
2077        #[arg(value_name = "ID")]
2078        reference: String,
2079    },
2080    /// Navigate an epic's hierarchy: parent, children, ancestors and depth
2081    ///
2082    /// Direct issue navigation stays available: the member list is included and a
2083    /// member is an ordinary issue, reachable with `tix show <number>`.
2084    Navigate {
2085        /// Epic id (a name is accepted as a convenience)
2086        #[arg(value_name = "ID")]
2087        reference: String,
2088    },
2089    /// Delete an epic's registry entry (its issues and child epics are never deleted)
2090    ///
2091    /// The issues that referenced the epic keep their references and the child
2092    /// epics keep their parent reference; both are reported so the dangling
2093    /// references can be repaired deliberately.
2094    Delete {
2095        /// Epic id (a name is accepted as a convenience)
2096        #[arg(value_name = "ID")]
2097        reference: String,
2098        /// Confirm without prompting
2099        #[arg(long)]
2100        yes: bool,
2101    },
2102    /// Reference an epic from one or more issues
2103    ///
2104    /// Each selector is processed independently: a rejection on one is reported
2105    /// and does not abort the rest.
2106    Add {
2107        /// Epic id (a name is accepted as a convenience)
2108        #[arg(value_name = "ID")]
2109        reference: String,
2110        /// Issue numbers, filename fragments or unique title fragments
2111        #[arg(required = true, value_name = "ISSUE")]
2112        issues: Vec<String>,
2113    },
2114    /// Remove an epic reference from one or more issues
2115    ///
2116    /// Drops only the reference; no issue is deleted. A reference that does not
2117    /// name a live epic is used **verbatim**, so a retained stale id can still be
2118    /// removed and is reported as `0 changed` rather than refused.
2119    Remove {
2120        /// Epic id (a name is accepted as a convenience)
2121        #[arg(value_name = "ID")]
2122        reference: String,
2123        /// Issue numbers, filename fragments or unique title fragments
2124        #[arg(required = true, value_name = "ISSUE")]
2125        issues: Vec<String>,
2126    },
2127    /// Bulk-reference one or more issues from an epic
2128    ///
2129    /// Epic reference only — general bulk editing is a different feature. Every
2130    /// selector is independent, so one unknown selector does not discard the rest.
2131    /// An unknown **epic** reference refuses the whole command before anything is
2132    /// written; use `tix epic remove` to drop a retained stale id.
2133    BulkAdd {
2134        /// Epic id (a name is accepted as a convenience)
2135        #[arg(value_name = "ID")]
2136        reference: String,
2137        /// Issue numbers, filename fragments or unique title fragments
2138        #[arg(required = true, value_name = "ISSUE")]
2139        issues: Vec<String>,
2140    },
2141}
2142
2143/// The `tix initiative` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
2144///
2145/// A **required** subcommand, like `tix project` and `tix epic`. Selection is by the
2146/// stable id (the registry key); a name resolves to the same initiative as a
2147/// convenience. A rename through `edit --name` changes the label only: the id is not
2148/// re-keyed, so every association and derived value survives.
2149#[derive(Subcommand)]
2150pub enum InitiativeCommand {
2151    /// Create an initiative and allocate a stable id
2152    ///
2153    /// The name is a display label, not the identity: `tix initiative edit --name`
2154    /// changes it and every association survives, because the record is keyed by id.
2155    Create {
2156        /// Display name (unique within the registry, case-insensitively)
2157        #[arg(value_name = "NAME")]
2158        name: String,
2159        /// Optional strategic outcome
2160        #[arg(long)]
2161        outcome: Option<String>,
2162        /// Owner identity string; no lookup, never inferred
2163        #[arg(long)]
2164        owner: Option<String>,
2165        /// Free-form initiative status (not a board column)
2166        #[arg(long)]
2167        status: Option<String>,
2168        /// Initiative priority (a known spelling is normalized; an unknown one is kept)
2169        #[arg(long)]
2170        priority: Option<String>,
2171        /// Filterable labels, comma-separated
2172        #[arg(long)]
2173        labels: Option<String>,
2174        /// Target date (`YYYY-MM-DD`)
2175        #[arg(long = "target-date")]
2176        target_date: Option<String>,
2177        /// Project ids this initiative concerns, comma-separated
2178        ///
2179        /// Shape-checked only, never resolved: a well-formed id the registry does not
2180        /// hold is stored and diagnosed by `tix roadmap`/`tix lint`, so it is
2181        /// repairable.
2182        #[arg(long, value_name = "ID")]
2183        projects: Option<String>,
2184        /// Epic ids this initiative concerns, comma-separated
2185        #[arg(long, value_name = "ID")]
2186        epics: Option<String>,
2187        /// Milestone names this initiative concerns, comma-separated
2188        #[arg(long, value_name = "NAME")]
2189        milestones: Option<String>,
2190    },
2191    /// Edit an initiative's fields; a rename changes the label only
2192    ///
2193    /// Nothing is re-keyed, so every association and derived value stays intact. An
2194    /// optional value with a blank spelling clears the field (`--owner ""`), and a
2195    /// blank reference list clears it (`--projects ""`).
2196    Edit {
2197        /// Initiative id (a name is accepted as a convenience)
2198        #[arg(value_name = "ID")]
2199        reference: String,
2200        /// New display name
2201        #[arg(long)]
2202        name: Option<String>,
2203        /// New strategic outcome (`--outcome ""` clears)
2204        #[arg(long)]
2205        outcome: Option<String>,
2206        /// New owner identity string (`--owner ""` clears)
2207        #[arg(long)]
2208        owner: Option<String>,
2209        /// New free-form status (`--status ""` clears)
2210        #[arg(long)]
2211        status: Option<String>,
2212        /// New priority (`--priority ""` clears)
2213        #[arg(long)]
2214        priority: Option<String>,
2215        /// Replace the label list, comma-separated (`--labels ""` clears)
2216        #[arg(long)]
2217        labels: Option<String>,
2218        /// New target date (`YYYY-MM-DD`; `--target-date ""` clears)
2219        #[arg(long = "target-date")]
2220        target_date: Option<String>,
2221        /// Replace the project ids, comma-separated (`--projects ""` clears)
2222        #[arg(long, value_name = "ID")]
2223        projects: Option<String>,
2224        /// Replace the epic ids, comma-separated (`--epics ""` clears)
2225        #[arg(long, value_name = "ID")]
2226        epics: Option<String>,
2227        /// Replace the milestone names, comma-separated (`--milestones ""` clears)
2228        #[arg(long, value_name = "NAME")]
2229        milestones: Option<String>,
2230    },
2231    /// List initiatives in declaration order, with ids, target dates and status
2232    List,
2233    /// Show one initiative's record, metadata and references
2234    Show {
2235        /// Initiative id (a name is accepted as a convenience)
2236        #[arg(value_name = "ID")]
2237        reference: String,
2238    },
2239    /// Delete an initiative's registry entry (nothing else is ever deleted)
2240    ///
2241    /// The record's own references are reported and nothing cascades: no project,
2242    /// epic, milestone or issue is touched, and no association is silently repaired.
2243    Delete {
2244        /// Initiative id (a name is accepted as a convenience)
2245        #[arg(value_name = "ID")]
2246        reference: String,
2247        /// Confirm without prompting
2248        #[arg(long)]
2249        yes: bool,
2250    },
2251    /// Associate an initiative with exactly one project, epic or milestone
2252    ///
2253    /// Name exactly one of `--project`, `--epic`, `--milestone`; the kind is part of
2254    /// the record, so guessing is worse than asking. A well-formed reference that
2255    /// names nothing is stored and stays diagnosable, and a repeated association is
2256    /// an idempotent no-op that writes no bytes.
2257    Associate {
2258        /// Initiative id (a name is accepted as a convenience)
2259        #[arg(value_name = "ID")]
2260        reference: String,
2261        /// Project **id** to associate
2262        #[arg(long, value_name = "ID")]
2263        project: Option<String>,
2264        /// Epic **id** to associate
2265        #[arg(long, value_name = "ID")]
2266        epic: Option<String>,
2267        /// Milestone **name** to associate
2268        #[arg(long, value_name = "NAME")]
2269        milestone: Option<String>,
2270    },
2271    /// Drop an explicit association from an initiative
2272    ///
2273    /// Name exactly one of `--project`, `--epic`, `--milestone`. Dropping an
2274    /// association that is not recorded is an idempotent no-op that writes no bytes.
2275    Dereference {
2276        /// Initiative id (a name is accepted as a convenience)
2277        #[arg(value_name = "ID")]
2278        reference: String,
2279        /// Project **id** to drop
2280        #[arg(long, value_name = "ID")]
2281        project: Option<String>,
2282        /// Epic **id** to drop
2283        #[arg(long, value_name = "ID")]
2284        epic: Option<String>,
2285        /// Milestone **name** to drop
2286        #[arg(long, value_name = "NAME")]
2287        milestone: Option<String>,
2288    },
2289}
2290
2291/// The `tix roadmap` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
2292///
2293/// Read-only. `list` applies the one normalized [`crate::roadmap::filter::RoadmapFilter`];
2294/// `show` prints the single-row detail in the same rendering.
2295#[derive(Subcommand)]
2296pub enum RoadmapCommand {
2297    /// List initiatives in roadmap order: target date ascending, then stable id
2298    ///
2299    /// An undated initiative sorts after every dated one and renders the explicit
2300    /// `undated` state — never a blank or an invented date. A bounded period filter
2301    /// excludes it, because inventing a period is exactly what the contract forbids.
2302    List {
2303        /// Inclusive lower bound on the target date (`YYYY-MM-DD`)
2304        #[arg(long, value_name = "DATE")]
2305        from: Option<String>,
2306        /// Inclusive upper bound on the target date (`YYYY-MM-DD`)
2307        #[arg(long, value_name = "DATE")]
2308        to: Option<String>,
2309        /// Owner to match, compared trimmed and case-insensitively
2310        #[arg(long, value_name = "ID")]
2311        owner: Option<String>,
2312        /// Initiative status to match, compared trimmed and case-insensitively
2313        #[arg(long, value_name = "STATE")]
2314        status: Option<String>,
2315        /// A label the initiative must carry; repeat for an AND over labels
2316        #[arg(long = "label", value_name = "LABEL")]
2317        labels: Vec<String>,
2318        /// Free text matched case-insensitively against the searchable fields
2319        #[arg(long, value_name = "TEXT")]
2320        query: Option<String>,
2321    },
2322    /// Show one initiative's roadmap row: summary contributions and diagnostics
2323    Show {
2324        /// Initiative id (a name is accepted as a convenience)
2325        #[arg(value_name = "ID")]
2326        reference: String,
2327    },
2328}
2329
2330/// The `tix parent` subcommands.
2331///
2332/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
2333/// has a meaningful bare form (create); `tix parent` with no argument is a user
2334/// mistake, and guessing which of three reports they wanted is worse than asking.
2335#[derive(Debug, Clone, Subcommand)]
2336pub enum ParentAction {
2337    /// Report the parent, direct children, child count and rollup for one issue
2338    Show {
2339        /// Issue number, filename fragment or unique title fragment
2340        issue: String,
2341    },
2342    /// List an issue's direct children, ordered by issue number
2343    Children {
2344        /// Issue number, filename fragment or unique title fragment
2345        issue: String,
2346    },
2347    /// List every issue that has children, with a child count
2348    List,
2349}
2350
2351/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
2352///
2353/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
2354/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
2355/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
2356/// alone is a usage error rather than an empty success — printing the vocabulary
2357/// when the user asked for the policy would be guessing at their intent.
2358#[derive(Subcommand)]
2359pub enum WorkflowAction {
2360    /// The global category vocabulary, in order
2361    Categories,
2362    /// A board's columns, resolved categories, and where each resolution came from
2363    Show {
2364        /// Board name; omitted means the active board (`default`, or the first
2365        /// declared). A name that is not configured is an error listing the boards
2366        /// that are.
2367        board: Option<String>,
2368    },
2369    /// The effective transition policy and its override escape hatch
2370    Transitions,
2371}
2372
2373/// `tix layout` subcommands.
2374#[derive(Subcommand)]
2375pub enum LayoutAction {
2376    /// Print the effective `[layout]` template and a worked example
2377    Show,
2378    /// Relocate every issue to the configured layout (dry run unless `--apply`)
2379    Migrate {
2380        /// Write the moves; without it the command only prints the plan
2381        #[arg(long)]
2382        apply: bool,
2383    },
2384}
2385
2386/// The `tix link` subcommands.
2387///
2388/// `tix link <from> <to> --type <kind>` is the create form and carries no
2389/// subcommand, so the three verbs below are optional rather than required
2390/// (C-001 removes the old untyped symmetric form, not the bare create form the
2391/// CLI surface contract specifies).
2392#[derive(Subcommand)]
2393pub enum LinkAction {
2394    /// Retarget and/or re-kind an existing relationship, keeping its id
2395    Edit {
2396        /// Relationship id, as printed by `tix link list`
2397        id: String,
2398        /// New target issue number, filename fragment or unique title fragment
2399        #[arg(long)]
2400        to: Option<String>,
2401        /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2402        #[arg(long, value_name = "KIND")]
2403        r#type: Option<String>,
2404    },
2405    /// Remove a relationship by id. An absent id is an idempotent no-op
2406    Remove {
2407        /// Relationship id, as printed by `tix link list`
2408        id: String,
2409    },
2410    /// List relationships for one issue, or for the whole board
2411    List {
2412        /// Issue number, filename fragment or unique title fragment. Omit for every issue.
2413        issue: Option<String>,
2414        /// Only relationships of this kind
2415        #[arg(long, value_name = "KIND")]
2416        kind: Option<String>,
2417    },
2418}
2419
2420/// What `tix recurring` does.
2421///
2422/// Every read-only subcommand resolves its date explicitly (`--date`), and every
2423/// mutating one requires the confirmation flag it documents. The subcommands are a
2424/// thin adapter: they parse, call one service, and print.
2425#[derive(Debug, Clone, Subcommand)]
2426pub enum RecurringAction {
2427    /// List every series, one tab-separated row per series
2428    ///
2429    /// Columns: `id`, `name`, `state`, `next_cursor`, `skipped`.
2430    List,
2431    /// Show one series: identity, rule, state, cursor, skipped dates, occurrences
2432    Show {
2433        /// Stable series id, or its unique display name
2434        series: String,
2435    },
2436    /// Create a series: the issue that *is* the definition
2437    ///
2438    /// The issue's own fields are the template an occurrence is created from, so
2439    /// `--title`, `--labels` and the rest are also what each occurrence inherits.
2440    /// The rule is validated **before** the issue is written.
2441    Create(RecurringCreateArgs),
2442    /// Change a series' name and/or rule, keeping its stable id
2443    ///
2444    /// Only the flags you name change; the rest of the rule is carried over, so
2445    /// `tix recurring edit <id> --name X` is a rename and nothing else.
2446    Edit {
2447        /// Stable series id, or its unique display name
2448        series: String,
2449        #[command(flatten)]
2450        args: RecurringEditArgs,
2451    },
2452    /// Report what is due, read-only, for an explicit date
2453    Detect {
2454        /// Evaluate against this `YYYY-MM-DD` date instead of today
2455        #[arg(long, value_name = "YYYY-MM-DD")]
2456        date: Option<String>,
2457        /// Timezone policy to carry on the context, e.g. `+02:00`
2458        #[arg(long, value_name = "TZ")]
2459        timezone: Option<String>,
2460    },
2461    /// Create the occurrence for one due date; requires `--yes`
2462    ///
2463    /// Idempotent: the occurrence key is checked first, so a repeated call is a
2464    /// no-op and never a second issue.
2465    Generate {
2466        /// Stable series id, or its unique display name
2467        series: String,
2468        /// The occurrence date, `YYYY-MM-DD`
2469        #[arg(long, value_name = "YYYY-MM-DD")]
2470        date: String,
2471        #[command(flatten)]
2472        confirmation: RecurringLifecycleArgs,
2473    },
2474    /// Suspend generation until an explicit resume
2475    Pause {
2476        /// Stable series id, or its unique display name
2477        series: String,
2478        #[command(flatten)]
2479        confirmation: RecurringLifecycleArgs,
2480    },
2481    /// Resume a paused series; never inferred from a refresh
2482    Resume {
2483        /// Stable series id, or its unique display name
2484        series: String,
2485        #[command(flatten)]
2486        confirmation: RecurringLifecycleArgs,
2487    },
2488    /// End a series: terminal for generation
2489    End {
2490        /// Stable series id, or its unique display name
2491        series: String,
2492        #[command(flatten)]
2493        confirmation: RecurringLifecycleArgs,
2494    },
2495    /// Record one occurrence date as skipped, so it is never generated
2496    Skip {
2497        /// Stable series id, or its unique display name
2498        series: String,
2499        /// The occurrence date, `YYYY-MM-DD`
2500        #[arg(long, value_name = "YYYY-MM-DD")]
2501        date: String,
2502        #[command(flatten)]
2503        confirmation: RecurringLifecycleArgs,
2504    },
2505    /// Move the candidate past a date without recording it as skipped
2506    Defer {
2507        /// Stable series id, or its unique display name
2508        series: String,
2509        /// The occurrence date, `YYYY-MM-DD`
2510        #[arg(long, value_name = "YYYY-MM-DD")]
2511        date: String,
2512        #[command(flatten)]
2513        confirmation: RecurringLifecycleArgs,
2514    },
2515    /// Stop offering one occurrence date: recorded, like `skip`
2516    Ignore {
2517        /// Stable series id, or its unique display name
2518        series: String,
2519        /// The occurrence date, `YYYY-MM-DD`
2520        #[arg(long, value_name = "YYYY-MM-DD")]
2521        date: String,
2522        #[command(flatten)]
2523        confirmation: RecurringLifecycleArgs,
2524    },
2525}
2526
2527/// `tix wip`'s subcommands (mission `configurable-wip-limits-01M3C6WX`, WP04).
2528///
2529/// The CLI parses and routes; the policy service owns every evaluation, and the
2530/// schema's own validation decides whether a configuration is acceptable. Nothing
2531/// here decides what a limit *means*.
2532#[derive(Debug, Clone, Subcommand)]
2533pub enum WipAction {
2534    /// Show the configured limits, their units, and the current usage
2535    Show {
2536        /// Board name; default is the active board
2537        #[arg(long, value_name = "BOARD")]
2538        board: Option<String>,
2539    },
2540    /// Set or update one column's limit
2541    ///
2542    /// Only the flags you name change, exactly as `tix recurring edit` works, so
2543    /// `tix wip set "in progress" --max-items 4` keeps the column's estimate
2544    /// ceiling, unit and policy.
2545    Set {
2546        /// Column name, as `[kanban.<board>.columns]` spells it
2547        column: String,
2548        /// The most cards the column may hold
2549        #[arg(long, value_name = "N")]
2550        max_items: Option<u32>,
2551        /// The most estimate the column's cards may add up to
2552        #[arg(long, value_name = "N")]
2553        max_estimate: Option<u32>,
2554        /// The unit `--max-estimate` counts in; it must be declared with `tix wip unit`
2555        #[arg(long, value_name = "UNIT")]
2556        unit: Option<String>,
2557        /// What exceeding the limit does: `warn` or `block`
2558        #[arg(long, value_name = "warn|block")]
2559        policy: Option<String>,
2560        /// Keep the numbers but enforce nothing
2561        #[arg(long)]
2562        disabled: bool,
2563        /// Board name; default is the active board
2564        #[arg(long, value_name = "BOARD")]
2565        board: Option<String>,
2566    },
2567    /// Remove one column's limit
2568    Unset {
2569        /// Column name
2570        column: String,
2571        /// Board name; default is the active board
2572        #[arg(long, value_name = "BOARD")]
2573        board: Option<String>,
2574    },
2575    /// Declare an estimate unit, or remove one
2576    ///
2577    /// A unit is a name and its factor to the board's base unit: with `d = 1` and
2578    /// `w = 5`, an issue estimated `1w` counts as `5d`. A unit the board does not
2579    /// declare is a `missing_conversion` refusal wherever it is used, never a zero.
2580    Unit {
2581        /// Unit name, e.g. `d`
2582        name: String,
2583        /// The factor to the board's base unit (at least 1)
2584        factor: Option<u32>,
2585        /// Remove the unit instead of declaring it
2586        #[arg(long)]
2587        remove: bool,
2588        /// Board name; default is the active board
2589        #[arg(long, value_name = "BOARD")]
2590        board: Option<String>,
2591    },
2592}
2593
2594/// The rule flags `tix recurring create` and `edit` share.
2595#[derive(Debug, Clone, Args)]
2596pub struct RecurringRuleArgs {
2597    /// Family: `daily`, `weekly`, `monthly`, `interval`, `cron-like`
2598    #[arg(long, value_name = "KIND")]
2599    pub rule: Option<String>,
2600    /// Schedule origin, `YYYY-MM-DD` (required by every family)
2601    #[arg(long, value_name = "YYYY-MM-DD")]
2602    pub anchor: Option<String>,
2603    /// Cadence, where the family takes one (1-1000)
2604    #[arg(long, value_name = "N")]
2605    pub interval: Option<u32>,
2606    /// What an `interval` rule counts in: `days`, `weeks`, `months`
2607    #[arg(long, value_name = "UNIT")]
2608    pub unit: Option<String>,
2609    /// Cron-like expression: `@daily`, `@weekly`, `@monthly`
2610    #[arg(long, value_name = "EXPR")]
2611    pub expression: Option<String>,
2612    /// Timezone policy, e.g. `+02:00`
2613    #[arg(long, value_name = "TZ")]
2614    pub timezone: Option<String>,
2615}
2616
2617impl RecurringRuleArgs {
2618    /// Whether the caller named any rule field at all.
2619    pub const fn is_any(&self) -> bool {
2620        self.rule.is_some()
2621            || self.anchor.is_some()
2622            || self.interval.is_some()
2623            || self.unit.is_some()
2624            || self.expression.is_some()
2625            || self.timezone.is_some()
2626    }
2627}
2628
2629/// The confirmation every mutating `tix recurring` subcommand takes.
2630#[derive(Debug, Clone, Args)]
2631pub struct RecurringLifecycleArgs {
2632    /// Confirm the action without prompting
2633    #[arg(long)]
2634    pub yes: bool,
2635    /// Why; recorded in the series' History
2636    #[arg(long, value_name = "TEXT")]
2637    pub reason: Option<String>,
2638}
2639
2640/// The fields of the definition issue plus its rule.
2641#[derive(Debug, Clone, Args)]
2642pub struct RecurringCreateArgs {
2643    /// Title of the series issue (and the default series name)
2644    #[arg(long, value_name = "TITLE")]
2645    pub title: String,
2646    /// Body an occurrence inherits
2647    #[arg(long, value_name = "BODY")]
2648    pub body: Option<String>,
2649    /// Column the definition (and its occurrences) starts in
2650    #[arg(long, value_name = "STATUS")]
2651    pub status: Option<String>,
2652    /// Priority: `low`, `medium`, `high`
2653    #[arg(long, value_name = "PRIORITY")]
2654    pub priority: Option<String>,
2655    /// Labels, comma-separated
2656    #[arg(long, value_name = "LABELS")]
2657    pub labels: Option<String>,
2658    /// Assignee (`@name`)
2659    #[arg(long, value_name = "ASSIGNEE")]
2660    pub assignee: Option<String>,
2661    /// Display name; defaults to the title
2662    #[arg(long, value_name = "NAME")]
2663    pub name: Option<String>,
2664    /// Stable series id; generated when omitted
2665    #[arg(long, value_name = "ID")]
2666    pub id: Option<String>,
2667    #[command(flatten)]
2668    pub rule: RecurringRuleArgs,
2669}
2670
2671/// What `tix recurring edit` may change.
2672#[derive(Debug, Clone, Args)]
2673pub struct RecurringEditArgs {
2674    /// New display name; omitted means keep the current one
2675    #[arg(long, value_name = "NAME")]
2676    pub name: Option<String>,
2677    #[command(flatten)]
2678    pub rule: RecurringRuleArgs,
2679}
2680
2681/// The `tix view` saved-view subcommands (#172; mission
2682/// `saved-issue-views-01M3C926`, WP04).
2683///
2684/// A **real** subcommand tree, not an optional-verb enum like [`LinkAction`]:
2685/// there is no meaningful bare `tix view` (bare `tix view` would be the
2686/// issue-printer, which takes an issue selector), so every saved-view operation
2687/// names its verb. The nine verbs are the surface contract's spelling
2688/// (`contracts/cli-surface.md`).
2689///
2690/// The plan-shaping flags are [`ViewPlanArgs`] and the scope flags are
2691/// [`ViewScopeArgs`], flattened into the verbs that take them so `create`,
2692/// `update`, `share`, and `status` cannot drift apart.
2693#[derive(Subcommand)]
2694pub enum ViewAction {
2695    /// List saved views, in declaration order, as tab-separated rows
2696    List {
2697        /// Only views in this scope family: `repository` | `project` | `user`
2698        #[arg(long, value_name = "repository|project|user")]
2699        scope: Option<String>,
2700    },
2701    /// Show one saved view: stable id, name, scope, normalized plan, diagnostics
2702    Show {
2703        /// Stable view id, or its display name
2704        reference: String,
2705    },
2706    /// Save a new view from the existing query grammar
2707    ///
2708    /// The id is **generated** and stable (spec §3: "use stable generated IDs;
2709    /// names are display labels"); it is printed on the first stdout line so a
2710    /// script can capture it.
2711    Create {
2712        /// Display label
2713        #[arg(long, value_name = "NAME")]
2714        name: String,
2715        #[command(flatten)]
2716        scope: ViewScopeArgs,
2717        #[command(flatten)]
2718        plan: ViewPlanArgs,
2719    },
2720    /// Replace an existing view's definition, keeping its stable id
2721    ///
2722    /// Only the fields you name change; the rest are carried over from the
2723    /// stored definition, so `tix view update <id> --name X` is a rename and
2724    /// nothing else (identity survives a rename, C-002).
2725    Update {
2726        /// Stable view id, or its display name
2727        reference: String,
2728        /// New display label; omitted means keep the current one
2729        #[arg(long, value_name = "NAME")]
2730        name: Option<String>,
2731        #[command(flatten)]
2732        plan: ViewPlanArgs,
2733    },
2734    /// Copy a view under a **new** stable id
2735    Duplicate {
2736        /// Stable view id, or its display name
2737        reference: String,
2738        /// Label for the copy; omitted means the source's label
2739        #[arg(long, value_name = "NAME")]
2740        name: Option<String>,
2741    },
2742    /// Remove a view **definition** only; requires `--confirm`
2743    ///
2744    /// Never deletes an issue, comment, or relationship.
2745    Delete {
2746        /// Stable view id, or its display name
2747        reference: String,
2748        /// Acknowledge that only the saved definition is removed
2749        #[arg(long)]
2750        confirm: bool,
2751    },
2752    /// Apply a saved view read-only: render its plan, result, and active marker
2753    Apply {
2754        /// Stable view id, or its display name
2755        reference: String,
2756    },
2757    /// Report the saved plan against a live plan, field by field
2758    ///
2759    /// Without an id it reports the (absent) active marker — never a silent
2760    /// "in sync". With an id, the live plan is assembled from the plan flags
2761    /// given here, in the existing grammar.
2762    Status {
2763        /// Stable view id, or its display name; omitted reports the active marker
2764        reference: Option<String>,
2765        #[command(flatten)]
2766        live: ViewPlanArgs,
2767    },
2768    /// Change a view's explicit sharing/visibility scope metadata
2769    ///
2770    /// Scope is metadata: `user` scope stores an owner/shared-with label and
2771    /// grants no permission and creates no account (C-003/C-009).
2772    Share {
2773        /// Stable view id, or its display name
2774        reference: String,
2775        #[command(flatten)]
2776        scope: ViewScopeArgs,
2777    },
2778}
2779
2780/// The explicit scope flags shared by `create` and `share`.
2781///
2782/// One flatten per verb rather than two hand-maintained copies, so a new scope
2783/// field cannot be added to one and forgotten on the other.
2784#[derive(Args, Debug, Clone, PartialEq, Eq)]
2785pub struct ViewScopeArgs {
2786    /// Scope family: `repository` (the default) | `project` | `user`
2787    #[arg(long, value_name = "repository|project|user")]
2788    pub scope: Option<String>,
2789    /// Project id, for `--scope project`
2790    ///
2791    /// Resolved through the project registry; a project this repository does
2792    /// not know is refused by the registry, not by this flag.
2793    #[arg(long, value_name = "PROJECT")]
2794    pub project: Option<String>,
2795    /// Owner identity, for `--scope user` (metadata only — not an account)
2796    #[arg(long, value_name = "IDENTITY")]
2797    pub owner: Option<String>,
2798    /// Comma-separated shared-with identities, for `--scope user`
2799    #[arg(long, value_name = "LIST")]
2800    pub shared_with: Option<String>,
2801}
2802
2803/// The plan-shaping flags shared by `create`, `update`, and `status`.
2804///
2805/// The grammar is the **existing** one: `--filter` is the token query
2806/// `tix list --filter` accepts, `--sort`/`--group` are that grammar's control
2807/// tokens, and the visibility pair mirrors the board's `hidden_columns` /
2808/// `show_mismatch`. Nothing here parses a second query language (C-004).
2809#[derive(Args, Debug, Clone, PartialEq, Eq)]
2810pub struct ViewPlanArgs {
2811    /// Existing token query (`status:todo`, `label:urgent`, `category:done`, …)
2812    #[arg(long, value_name = "QUERY")]
2813    pub filter: Option<String>,
2814    /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`,
2815    /// `status`, `priority`)
2816    #[arg(long, value_name = "KEY")]
2817    pub sort: Option<String>,
2818    /// Group key (`cycle` | `milestone`)
2819    #[arg(long, value_name = "KEY")]
2820    pub group: Option<String>,
2821    /// Board columns to hide when the view is applied (comma-separated)
2822    ///
2823    /// Giving this **or** `--show-mismatch` makes visibility an absolute
2824    /// snapshot; omitting both means the view inherits the live board's
2825    /// visibility.
2826    #[arg(long, value_name = "LIST")]
2827    pub hide_columns: Option<String>,
2828    /// Show the computed "Mismatched" column when applied (`true` | `false`)
2829    #[arg(long, value_name = "true|false", num_args = 1, action = clap::ArgAction::Set)]
2830    pub show_mismatch: Option<bool>,
2831}
2832
2833impl ViewPlanArgs {
2834    /// Whether the caller named **any** plan field.
2835    ///
2836    /// The question `update` asks: no field named means "leave the stored plan
2837    /// alone", so a rename cannot silently wipe it.
2838    #[must_use]
2839    pub const fn is_any(&self) -> bool {
2840        self.filter.is_some()
2841            || self.sort.is_some()
2842            || self.group.is_some()
2843            || self.hide_columns.is_some()
2844            || self.show_mismatch.is_some()
2845    }
2846}
2847
2848/// `tix bulk <action>` — four actions, and each carries **its own** flags.
2849///
2850/// The flags are on the subcommand rather than on the parent, which is what makes the
2851/// surface unambiguous by construction rather than by a runtime check:
2852/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
2853/// accepts a field flag and ignores it. `edit` is the only action with field flags,
2854/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
2855/// there is no spelling of `tix bulk` that a flag can apply to by accident.
2856///
2857/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
2858/// would suggest it is one.
2859/// The flags every `tix bulk` action carries: which issues, and whether to write.
2860///
2861/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
2862/// parses a parent's flags *before* its subcommand — so a parent would spell the
2863/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
2864/// is a different surface from the one the contract names. Four `#[command(flatten)]`
2865/// of one struct gives the contract's spelling and one vocabulary, so a new flag
2866/// cannot be added to one action and forgotten on the other three.
2867#[derive(Args, Debug, Clone, PartialEq, Eq)]
2868pub struct BulkSelection {
2869    /// Comma-separated issue selectors: number, filename fragment or unique title
2870    /// fragment
2871    #[arg(long, value_name = "LIST")]
2872    pub select: Option<String>,
2873    /// A `--filter` query, in the existing filter grammar
2874    #[arg(long, value_name = "QUERY")]
2875    pub filter: Option<String>,
2876    /// A `--search` query, in the existing search grammar
2877    #[arg(long, value_name = "QUERY")]
2878    pub search: Option<String>,
2879    /// Which issues are in play: `active`, `active+archived` or `archived`
2880    #[arg(long, value_name = "SCOPE")]
2881    pub scope: Option<String>,
2882    /// Write the batch. Omit for a preview, which is the default.
2883    #[arg(long)]
2884    pub apply: bool,
2885}
2886
2887impl BulkAction {
2888    /// The shared selection flags, whichever action carries them.
2889    ///
2890    /// One accessor for four flattened fields, so the routing code reads the flags
2891    /// once and a new action cannot forget to pass them.
2892    pub fn selection(&self) -> &BulkSelection {
2893        match self {
2894            Self::Edit { selection, .. }
2895            | Self::Link { selection, .. }
2896            | Self::Unlink { selection, .. }
2897            | Self::Archive { selection } => selection,
2898        }
2899    }
2900
2901    /// Whether `--apply` was given. A preview is the default, so this is a positive
2902    /// question rather than "is this not a preview".
2903    pub fn is_apply(&self) -> bool {
2904        self.selection().apply
2905    }
2906}
2907
2908#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
2909pub enum BulkAction {
2910    /// Apply a typed field patch to every selected issue
2911    Edit {
2912        #[command(flatten)]
2913        selection: BulkSelection,
2914        /// Target column
2915        #[arg(long, value_name = "COLUMN")]
2916        status: Option<String>,
2917        /// `low`, `medium`, `high`, or `` for unset
2918        #[arg(long, value_name = "PRIORITY")]
2919        priority: Option<String>,
2920        /// Bare handle or full identity
2921        #[arg(long, value_name = "ASSIGNEE")]
2922        assignee: Option<String>,
2923        /// Comma-separated labels
2924        #[arg(long, value_name = "LABELS")]
2925        labels: Option<String>,
2926        /// `YYYY-MM-DD`
2927        #[arg(long, value_name = "DATE")]
2928        start_date: Option<String>,
2929        /// `YYYY-MM-DD`
2930        #[arg(long, value_name = "DATE")]
2931        due_date: Option<String>,
2932        /// Free-form estimate
2933        #[arg(long, value_name = "ESTIMATE")]
2934        estimate: Option<String>,
2935        /// Comma-separated cycle names
2936        #[arg(long, value_name = "CYCLES")]
2937        cycles: Option<String>,
2938        /// Comma-separated milestone names
2939        #[arg(long, value_name = "MILESTONES")]
2940        milestones: Option<String>,
2941        /// Empty a field. Takes one of the clearable field names.
2942        #[arg(long, value_name = "FIELD")]
2943        clear: Option<String>,
2944    },
2945    /// Link every selected issue to `--target`
2946    Link {
2947        #[command(flatten)]
2948        selection: BulkSelection,
2949        /// The other end of the relationship
2950        #[arg(long, value_name = "SELECTOR")]
2951        target: String,
2952        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2953        #[arg(long, value_name = "KIND")]
2954        r#type: Option<String>,
2955    },
2956    /// Unlink every selected issue from `--target`
2957    Unlink {
2958        #[command(flatten)]
2959        selection: BulkSelection,
2960        /// The other end of the relationship
2961        #[arg(long, value_name = "SELECTOR")]
2962        target: String,
2963        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
2964        #[arg(long, value_name = "KIND")]
2965        r#type: Option<String>,
2966    },
2967    /// Ask the archive service to archive every selected issue
2968    Archive {
2969        #[command(flatten)]
2970        selection: BulkSelection,
2971    },
2972}
2973
2974/// The `tix mentions` verbs.
2975///
2976/// `list` and `show` are read-only. `read` is the one mutating answer; `ignore` is
2977/// deliberately the **absence** of a write, which is what makes the mention show
2978/// again the next time the issue is opened.
2979#[derive(Subcommand)]
2980pub enum MentionsAction {
2981    /// List the mentions pending for you
2982    List,
2983    /// Print one mention in full
2984    Show {
2985        /// Mention id, as shown by `tix mentions list`
2986        id: String,
2987    },
2988    /// Mark a mention read, so it is never shown again
2989    Read {
2990        /// Mention id, as shown by `tix mentions list`
2991        id: String,
2992    },
2993    /// Leave a mention pending (writes nothing; it shows again later)
2994    Ignore {
2995        /// Mention id, as shown by `tix mentions list`
2996        id: String,
2997    },
2998    /// Print where a mention sits in its issue
2999    Jump {
3000        /// Mention id, as shown by `tix mentions list`
3001        id: String,
3002    },
3003}
3004
3005/// The `tix analytics` subcommands (mission `burndown-scope-analytics-01M3C9J1`, WP05).
3006///
3007/// A thin adapter over the shared analytics DTOs. The daily fold, the unit conversion
3008/// and the projection live in `crate::analytics`, and the versioned chart document is
3009/// WP04's (`crate::commands::export::chart_document`), which every verb here calls rather
3010/// than re-composing. This enum owns only the spelling of the surface: the scope, the
3011/// period and the mode/unit are always explicit, and no flag here can change an issue, a
3012/// cycle date, a milestone or a snapshot.
3013#[derive(Debug, Clone, Subcommand)]
3014pub enum AnalyticsCommand {
3015    /// Print the daily remaining/completed series for one cycle or milestone
3016    ///
3017    /// A tab-separated table with a `date` column followed by the point's own fields —
3018    /// `scope_total`, `remaining`, `completed`, `scope_added`, `completions` — and the
3019    /// day's `coverage`. A value the record cannot support is `-`, never a zero, and a
3020    /// day with no recorded history is `unknown` or `coverage_limited`. The scope,
3021    /// period, mode, unit, formula version and coverage tally print above the header
3022    /// row, and the findings render to stderr with their remedies.
3023    Burndown {
3024        #[command(flatten)]
3025        args: AnalyticsScopeArgs,
3026    },
3027    /// Print the read-only end-date forecast over one cycle or milestone
3028    ///
3029    /// The planning end date is the scope's own (`--to` overrides it) and `--as-of` is
3030    /// the caller's day, never a clock. A forecast the record cannot answer prints its
3031    /// `status` (`insufficient_data` or `unknown`) with `-` for every value it could not
3032    /// derive — no completion date is ever invented, and no planning date or issue
3033    /// changes.
3034    Projection {
3035        #[command(flatten)]
3036        args: AnalyticsScopeArgs,
3037        /// The forecast origin, a `YYYY-MM-DD` calendar date
3038        #[arg(long, value_name = "YYYY-MM-DD")]
3039        as_of: String,
3040    },
3041    /// Write the deterministic local chart document (WP04's target)
3042    ///
3043    /// The document goes to stdout, or to `--output` through the atomic export writer the
3044    /// other local targets use. `--mode` is required: the document states the mode it was
3045    /// folded in, so a default would be a guess about which arithmetic produced the
3046    /// numbers. `--as-of` asks the same document for a forecast. The remote
3047    /// `gitlab`/`linear` namespaces are never consulted and cannot collide with a chart
3048    /// document.
3049    Export {
3050        #[command(flatten)]
3051        args: AnalyticsScopeArgs,
3052        /// Ask the document for a forecast at this origin, a `YYYY-MM-DD` calendar date
3053        #[arg(long, value_name = "YYYY-MM-DD")]
3054        as_of: Option<String>,
3055        /// Write the document to this path instead of stdout
3056        #[arg(long, value_name = "PATH")]
3057        output: Option<String>,
3058    },
3059}
3060
3061/// The scope, period, mode and unit flags every `tix analytics` verb shares.
3062///
3063/// The period is the scope's own planning data unless a bound is named here, and the mode
3064/// is explicit rather than inferred: an estimate valuation is only drawn through the
3065/// project's configured unit conversion, so a mode the record cannot support is refused
3066/// instead of silently falling back to a count.
3067#[derive(Debug, Clone, Args)]
3068pub struct AnalyticsScopeArgs {
3069    /// Scope kind: `cycle` or `milestone`
3070    #[arg(long, value_name = "cycle|milestone")]
3071    pub scope: String,
3072    /// The scope's planning identity: a cycle name or stable id, or a milestone name
3073    #[arg(long, value_name = "ID")]
3074    pub id: String,
3075    /// The series' inclusive start, `YYYY-MM-DD`; absent means the scope's own start
3076    #[arg(long, value_name = "YYYY-MM-DD")]
3077    pub from: Option<String>,
3078    /// The series' inclusive end, `YYYY-MM-DD`; absent means the scope's own planning end
3079    #[arg(long, value_name = "YYYY-MM-DD")]
3080    pub to: Option<String>,
3081    /// How the series counts: `count` (issue counts, the default) or `estimate`
3082    ///
3083    /// `estimate` requires `--unit` and a unit the project declares a conversion for;
3084    /// a missing or incompatible unit is refused with the shared remedy, never guessed.
3085    #[arg(long, value_name = "count|estimate")]
3086    pub mode: Option<String>,
3087    /// The unit estimate mode counts in; count mode ignores it (an issue count is unitless)
3088    #[arg(long, value_name = "UNIT")]
3089    pub unit: Option<String>,
3090}
3091
3092/// Process entry: route a parsed [`Cli`] through config resolution, store
3093/// opening and dispatch, then return the exit code for `main` to exit with.
3094pub fn run(cli: Cli) -> u8 {
3095    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3096    match &cli.command {
3097        Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
3098        Some(Command::Mcp) => match run_mcp(&cwd) {
3099            Ok(()) => ExitCode::Success as u8,
3100            Err(e) => {
3101                eprintln!("{}", crate::error::render_error(&e));
3102                exit_code_for(&e) as u8
3103            }
3104        },
3105        Some(Command::Render) => match run_render(&cwd) {
3106            Ok(()) => ExitCode::Success as u8,
3107            Err(e) => {
3108                eprintln!("{}", crate::error::render_error(&e));
3109                exit_code_for(&e) as u8
3110            }
3111        },
3112        // A bulk batch is the one command whose **status** is a result rather than an
3113        // error: a partial batch completes and reports non-zero, with every outcome
3114        // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
3115        // code is decided, and not through `commands::dispatch`, whose arms are all
3116        // `Result<()>`.
3117        Some(Command::Bulk { action }) => {
3118            let result = resolve(&cwd).and_then(|(_, store)| {
3119                commands::bulk::run(
3120                    &store,
3121                    action,
3122                    action.selection(),
3123                    if action.is_apply() {
3124                        commands::bulk::Mode::Apply
3125                    } else {
3126                        commands::bulk::Mode::Preview
3127                    },
3128                )
3129            });
3130            match result {
3131                Ok(code) => code as u8,
3132                Err(e) => {
3133                    eprintln!("{}", crate::error::render_error(&e));
3134                    exit_code_for(&e) as u8
3135                }
3136            }
3137        }
3138        // `tix triage` shares bulk's exit-code shape for the same reason: a batch
3139        // that partly succeeded is a *completed* command with a status, not an
3140        // error, so `--apply` can print one outcome per selected issue and still
3141        // exit non-zero (T026). It is routed here rather than through
3142        // `commands::dispatch`, whose arms are all `Result<()>`.
3143        Some(Command::Triage { action }) => {
3144            let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
3145            match result {
3146                Ok(code) => code as u8,
3147                Err(e) => {
3148                    eprintln!("{}", crate::error::render_error(&e));
3149                    exit_code_for(&e) as u8
3150                }
3151            }
3152        }
3153        // `tix archive`/`tix restore` share the batch exit-code shape: every
3154        // selector is processed independently, a failure on one does not roll back
3155        // another, and the process still exits non-zero if any failed. Routed here
3156        // for the same reason `tix bulk` and `tix triage` are.
3157        Some(Command::Archive { issues }) => {
3158            route_archive(&cwd, commands::archive::Action::Archive, issues)
3159        }
3160        Some(Command::Restore { issues }) => {
3161            route_archive(&cwd, commands::archive::Action::Restore, issues)
3162        }
3163        // `tix project` needs a **mutable** `Config` — an accepted create/edit/delete
3164        // *is* `tix.toml` changing — which `commands::dispatch` (whose arms all borrow
3165        // `&Config`) cannot express, exactly as `tix wip` and `tix view <subcommand>`
3166        // do. Its batch verbs (`add`, `remove`, `bulk-add`) also carry a **status**
3167        // rather than an error: each selector is independent and a partly-successful
3168        // run exits non-zero. Both properties are why it is routed here, where the
3169        // config is owned and the exit code is decided.
3170        Some(Command::Project { command }) => {
3171            let result = resolve(&cwd).and_then(|(mut config, store)| {
3172                commands::project::run(&mut config, &store, command)
3173            });
3174            match result {
3175                Ok(code) => code as u8,
3176                Err(e) => {
3177                    eprintln!("{}", crate::error::render_error(&e));
3178                    exit_code_for(&e) as u8
3179                }
3180            }
3181        }
3182        // `tix epic` shares `tix project`'s two properties exactly: its lifecycle
3183        // verbs (`create`/`edit`/`delete`) mutate `tix.toml`, so it needs the
3184        // **mutable** `Config` only `run` owns, and its batch verbs
3185        // (`add`/`remove`/`bulk-add`) carry a **status** rather than an error,
3186        // because each selector is independent and a partly-successful run exits
3187        // non-zero. Both are why it is routed here.
3188        Some(Command::Epic { command }) => {
3189            let result = resolve(&cwd)
3190                .and_then(|(mut config, store)| commands::epic::run(&mut config, &store, command));
3191            match result {
3192                Ok(code) => code as u8,
3193                Err(e) => {
3194                    eprintln!("{}", crate::error::render_error(&e));
3195                    exit_code_for(&e) as u8
3196                }
3197            }
3198        }
3199        // `tix initiative` shares `tix project`'s property: its lifecycle verbs
3200        // (`create`/`edit`/`delete`) and its association verbs mutate `tix.toml`, so
3201        // it needs the **mutable** `Config` only `run` owns, and an accepted change
3202        // is written through the registry's atomic boundary. `tix roadmap` is
3203        // read-only and fits `commands::dispatch`, so it is routed there.
3204        Some(Command::Initiative { command }) => {
3205            let result = resolve(&cwd).and_then(|(mut config, store)| {
3206                commands::roadmap::run_initiative(&mut config, &store, command)
3207            });
3208            match result {
3209                Ok(code) => code as u8,
3210                Err(e) => {
3211                    eprintln!("{}", crate::error::render_error(&e));
3212                    exit_code_for(&e) as u8
3213                }
3214            }
3215        }
3216        // Only `tix cycle schedule …` needs the **mutable** config: an accepted
3217        // `schedule create|edit|delete` *is* `tix.toml` changing, and
3218        // `commands::dispatch` (whose arms all borrow `&Config`) cannot provide it.
3219        // The frozen legacy verbs are deliberately **not** intercepted: they fall
3220        // through to `run_subcommand`, so they keep `commands::dispatch`'s legacy
3221        // arm *and* the rollover advisory that every `tix cycle …` emitted at the
3222        // baseline — behavior WP06 must not change.
3223        Some(Command::Cycle {
3224            command: CycleCommand::Schedule { command },
3225        }) => {
3226            let result = resolve(&cwd).and_then(|(mut config, store)| {
3227                commands::cycle::run_schedule(&mut config, &store, command)
3228            });
3229            match result {
3230                Ok(()) => ExitCode::Success as u8,
3231                Err(e) => {
3232                    eprintln!("{}", crate::error::render_error(&e));
3233                    exit_code_for(&e) as u8
3234                }
3235            }
3236        }
3237        // `tix capacity set|delete` mutate `tix.toml`, so they need the **mutable**
3238        // `Config` only the process entry owns — the same reason `tix wip` is routed
3239        // here. The read verbs (`list`/`show`/`calculate`/`snapshot`) ride along so
3240        // the family has one boundary, and none of them writes.
3241        Some(Command::Capacity { command }) => {
3242            let result = resolve(&cwd).and_then(|(mut config, store)| {
3243                commands::capacity::run(&mut config, &store, command)
3244            });
3245            match result {
3246                Ok(()) => ExitCode::Success as u8,
3247                Err(e) => {
3248                    eprintln!("{}", crate::error::render_error(&e));
3249                    exit_code_for(&e) as u8
3250                }
3251            }
3252        }
3253        // `tix template`: management mutates `tix.toml`, so it needs a **mutable**
3254        // config and the path it came from — neither of which `commands::dispatch`
3255        // carries. Routed here, before the store is opened, exactly like the TUI
3256        // and `tix mcp`.
3257        Some(Command::Template { action }) => match commands::templates::run(&cwd, action) {
3258            Ok(()) => ExitCode::Success as u8,
3259            Err(e) => {
3260                eprintln!("{}", crate::error::render_error(&e));
3261                exit_code_for(&e) as u8
3262            }
3263        },
3264        // `tix version` needs neither a store nor a config, so it is answered
3265        // before any repository resolution: it works in a directory where every
3266        // other command would refuse to start, which is exactly where someone
3267        // checking "did this build?" may be standing.
3268        Some(Command::Version) => {
3269            commands::version::print();
3270            ExitCode::Success as u8
3271        }
3272        Some(Command::Beads { action }) => {
3273            let result = resolve(&cwd)
3274                .and_then(|(config, store)| commands::beads::run(&config, &store, action));
3275            match result {
3276                Ok(()) => ExitCode::Success as u8,
3277                Err(e) => {
3278                    eprintln!("{}", crate::error::render_error(&e));
3279                    exit_code_for(&e) as u8
3280                }
3281            }
3282        }
3283        _ => {
3284            let result = match &cli.command {
3285                None => run_tui(&cwd),
3286                Some(Command::New) => run_tui_new(&cwd),
3287                Some(Command::Init { beads }) => commands::init::run_with(&cwd, beads.as_deref()),
3288                Some(Command::InitAgents) => {
3289                    let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
3290                    commands::agents::run(&root).map(|_| ())
3291                }
3292                Some(cmd) => run_subcommand(&cwd, cmd),
3293            };
3294            match result {
3295                Ok(()) => ExitCode::Success as u8,
3296                Err(e) => {
3297                    eprintln!("{}", crate::error::render_error(&e));
3298                    exit_code_for(&e) as u8
3299                }
3300            }
3301        }
3302    }
3303}
3304
3305/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
3306///
3307/// Each selector is independent: an outcome is printed per item and a failure on
3308/// one item neither aborts the rest nor rolls back a successful rename. The
3309/// process exits with the first failure's code, or `0` when every item succeeded.
3310fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
3311    match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
3312        Ok(code) => code as u8,
3313        Err(e) => {
3314            eprintln!("{}", crate::error::render_error(&e));
3315            exit_code_for(&e) as u8
3316        }
3317    }
3318}
3319
3320/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
3321/// findings, `2` usage/internal) does not match the generic
3322/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
3323fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
3324    let (config, store) = match resolve(cwd) {
3325        Ok(pair) => pair,
3326        Err(e) => {
3327            eprintln!("{}", crate::error::render_error(&e));
3328            return 2;
3329        }
3330    };
3331    match commands::lint::run_with(&config, &store, !no_foreign) {
3332        Ok(true) => 1,
3333        Ok(false) => 0,
3334        Err(e) => {
3335            eprintln!("{}", crate::error::render_error(&e));
3336            2
3337        }
3338    }
3339}
3340
3341/// `tix` with no subcommand: launch the TUI, or fail with the contract's
3342/// exact message when no `tix.toml` is reachable (exit 1).
3343///
3344/// tix #1632: on a terminal, a missing `tix.toml` is offered a one-key escape —
3345/// create the default configuration and continue into the TUI — rather than
3346/// being turned away with a message naming a command to run. A declined prompt,
3347/// and a stdin that cannot be asked (not a terminal), both keep the exact
3348/// contract message.
3349fn run_tui(cwd: &Path) -> Result<()> {
3350    let config = tui_config(cwd)?;
3351    crate::app::run(&config)
3352}
3353
3354/// `tix new`: the same session, opened on the create-issue form instead of the
3355/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml`
3356/// fails with the same message rather than opening a form that could not be
3357/// submitted.
3358fn run_tui_new(cwd: &Path) -> Result<()> {
3359    let config = tui_config(cwd)?;
3360    crate::app::run_new_issue(&config)
3361}
3362
3363/// Repository resolution for the TUI commands, offering to scaffold a repository
3364/// first when none is reachable (tix #1632).
3365fn tui_config(cwd: &Path) -> Result<Config> {
3366    if !offer_first_run_init(cwd)? {
3367        return Err(TixError::Config(
3368            "no tix.toml found — run \"tix init\" first".into(),
3369        ));
3370    }
3371    repo_config(cwd)
3372}
3373
3374/// Ask, once, whether a missing `tix.toml` should be created with the defaults.
3375///
3376/// Returns `true` when the caller may proceed (a repository was already there, or
3377/// the user said yes to `tix init` default values). Returns `false` when there is
3378/// no repository and the user declined — or could not be asked at all, since a
3379/// non-terminal stdin cannot consent, and silently writing a project into a pipe's
3380/// directory would be the wrong answer.
3381fn offer_first_run_init(cwd: &Path) -> Result<bool> {
3382    use std::io::IsTerminal;
3383
3384    if Config::project_root(cwd).is_some() {
3385        return Ok(true);
3386    }
3387    if !std::io::stdin().is_terminal() {
3388        return Ok(false);
3389    }
3390    eprint!("tix: no tix.toml found — create one with default settings? [Y/n] ");
3391    let mut line = String::new();
3392    std::io::stdin()
3393        .read_line(&mut line)
3394        .map_err(TixError::Io)?;
3395    let answer = line.trim();
3396    if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
3397        commands::init::run_with(cwd, Some("off"))?;
3398        return Ok(true);
3399    }
3400    eprintln!("tix: aborted — run \"tix init\" when you are ready");
3401    Ok(false)
3402}
3403
3404/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
3405/// upward `tix.toml` discovery, failing with the contract's exact `no
3406/// tix.toml found` guidance (exit 1) when no repository is reachable —
3407/// never a guessed default store. This is the "CLI hot path" that
3408/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
3409fn repo_config(cwd: &Path) -> Result<Config> {
3410    let root = Config::project_root(cwd)
3411        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
3412    let mut config = Config::load(&root)?;
3413    // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
3414    // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
3415    config.resolve_repo_paths(&root);
3416    Ok(config)
3417}
3418
3419/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
3420/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
3421/// a repository the process fails with exit 1 BEFORE reading any protocol
3422/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
3423fn run_mcp(cwd: &Path) -> Result<()> {
3424    let config = repo_config(cwd)?;
3425    let store = Store::open(&config)?;
3426    // Absolutize milestones against the same project root as issues_dir so the
3427    // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
3428    // milestone files to the identical directory (parity cross-checker).
3429    let mut config = config;
3430    if config.milestones_dir().is_relative() {
3431        let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
3432        let configured = config.milestones_dir();
3433        config.milestones_dir = Some(root.join(configured));
3434    }
3435    crate::mcp::serve(&config, &store)
3436}
3437
3438/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
3439/// command fails with the same no-config guidance outside a repository.
3440fn run_render(cwd: &Path) -> Result<()> {
3441    let config = repo_config(cwd)?;
3442    let store = Store::open(&config)?;
3443    commands::dispatch(&Command::Render, &config, &store)
3444}
3445
3446/// Config resolution for subcommands: defaults when no `tix.toml` exists
3447/// (relative to the project root via upward walk), with a one-time stderr
3448/// notice. Only explicit per-command requirements fail hard.
3449fn resolve(cwd: &Path) -> Result<(Config, Store)> {
3450    let root = Config::project_root(cwd);
3451    let mut config = match &root {
3452        Some(r) => Config::load(r)?,
3453        None => Config::default(),
3454    };
3455    // Resolve a relative issues_dir against the project root (or cwd when
3456    // unconfigured) so subcommands behave identically from any subdirectory.
3457    if config.issues_dir.is_relative() {
3458        let base = root.as_deref().unwrap_or(cwd);
3459        config.issues_dir = base.join(&config.issues_dir);
3460    }
3461    // Milestones resolve against the same base as issues_dir: the planning
3462    // validators (create/edit) and `lint` must agree on where milestone
3463    // registry files live regardless of the invocation directory.
3464    if config.milestones_dir().is_relative() {
3465        let base = root.as_deref().unwrap_or(cwd);
3466        let configured = config.milestones_dir();
3467        config.milestones_dir = Some(base.join(configured));
3468    }
3469    // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
3470    config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
3471    if root.is_none() {
3472        eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
3473    }
3474    let store = Store::open(&config)?;
3475    Ok((config, store))
3476}
3477
3478fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
3479    let (mut config, store) = resolve(cwd)?;
3480    // `tix view <subcommand>` is the one command tree that needs the config
3481    // **mutably**: an accepted view mutation *is* `config.views` changing, and
3482    // `commands::dispatch` (whose every arm borrows `&Config`) cannot express
3483    // that. Routed here, at the process boundary where the config is owned, for
3484    // the same reason `tix bulk` and `tix triage` are routed in `run`.
3485    if let Command::View {
3486        action: Some(action),
3487        ..
3488    } = cmd
3489    {
3490        return commands::views::run(&mut config, &store, action);
3491    }
3492    // `tix wip set/unset/unit` mutate `tix.toml`, so they are routed here for the
3493    // same reason the view mutations are: `commands::dispatch`'s arms borrow
3494    // `&Config`, and an accepted WIP edit *is* the configuration changing.
3495    if let Command::Wip { action } = cmd {
3496        return commands::wip::run(&mut config, &store, action);
3497    }
3498    if !matches!(cmd, Command::Rollover { .. }) {
3499        let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
3500        let plan = planner.rollover_plan(&store, &crate::model::today());
3501        if let Some(first) = plan.first() {
3502            eprintln!(
3503                "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
3504                first.issues.len(),
3505                first.from_cycle,
3506                first.to_cycle
3507            );
3508        }
3509    }
3510    commands::dispatch(cmd, &config, &store)
3511}