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