//! The MCP tool surface (WP04; WP08 added the triage tools): the static registry,
//! strict argument extraction, the FR-014 structured result shape, and the
//! handlers wired to the WP02/WP03 command cores (plus `Issue::add_comment` for
//! `comment_on_issue`, which has no CLI counterpart — C-003).
//!
//! Two layers stay strictly separate (`contracts/mcp-tools.md` §4, T021):
//! - *protocol* errors (`-32601` unknown tool, `-32602` type-invalid
//! arguments) are returned as JSON-RPC errors by [`call`];
//! - *domain* failures (bad selector, unknown column, missing external CLI…)
//! are tool-level results with `isError: true` and the CLI's exact error
//! string verbatim (`C-006`) — never a JSON-RPC error (`NFR-002`), so a
//! failing tool never kills the server.
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::OnceLock;
use serde_json::{Value, json};
use crate::analytics::chart::{
ChartExport, FORMAT as CHART_FORMAT, SCHEMA_VERSION as CHART_SCHEMA_VERSION, precision_of,
};
use crate::analytics::diagnostics::AnalyticsDiagnosticKind;
use crate::analytics::events::ScopeKind;
use crate::analytics::projection::{PROJECTION_FORMULA_VERSION, ProjectionStatus};
use crate::analytics::series::{Coverage, MAX_SERIES_DAYS, Mode};
// The unit table `estimate` mode converts through, read by the same function the fold
// reads it with, so a surface validating a `--unit` asks the table the fold will ask.
use crate::analytics::units::declared_units;
// The CLI's own scope/period/mode/unit request shape, so the analytics tools parse a
// request the way `tix analytics` does rather than through a second argument reader.
use crate::bulk;
use crate::cli::AnalyticsScopeArgs;
// The **data** module keeps the plain name `bulk`; the CLI surface is aliased, because
// both are called `bulk` and importing both plainly is a shadowing error rather than
// a choice. Same split as `ui::bulk`.
use crate::commands::bulk as cmd_bulk;
// WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity CLI adapter, aliased
// like every other `cmd_*`. MCP calls its scope-identity and formatting helpers rather
// than keeping a second copy — one `parse_scope_kind`, one `scope_label`, one
// `entries_matching`/`one_entry`, one set of `-`-rule formatters, one calculation and
// evidence text — so the two surfaces cannot drift (C-003/C-007/NFR-003).
use crate::commands::capacity as cmd_capacity;
use crate::commands::{
create, delete, edit, epic as cmd_epic, export, import, lint, list, mv, parse_priority,
parse_selector, roadmap as cmd_roadmap, search, status, templates as cmd_templates, view,
};
// WP06: the saved-view CLI adapter. Aliased as `cmd_views` because `view` above is
// the *issue* view command (`tix view <n>`), and the saved-view adapter is a
// different module. The MCP view tools reuse this adapter's argument shaping and
// rendering rather than keeping a second copy: one plan builder, one scope builder,
// one diagnostic rendering, so the two surfaces cannot drift (C-007/NFR-003).
use crate::commands::views as cmd_views;
// WP08: the recurring CLI adapter. Aliased as `cmd_recurring` for the same reason
// `cmd_bulk` and `cmd_triage` are, and *used*: MCP calls its resolver and its date
// context builder rather than owning a second one (C-008), so a series reference
// and a `--date`/`date` argument mean the same thing on both surfaces.
use crate::commands::recurring as cmd_recurring;
// WP08: the triage surface. `cmd_triage` is the CLI's own module, aliased for the
// same reason `cmd_bulk` is — and *used*, not re-implemented: `triage_list` and
// `triage_status` return its rendered text verbatim (NFR-003).
use crate::commands::triage as cmd_triage;
// OpenSpec change `issue-mentions`: the MCP mention tools are thin adapters over the
// CLI core, so both surfaces list and resolve the same records (`NFR-003`).
use crate::commands::mentions as cmd_mentions;
// Mission `per-issue-time-tracking-01M3C9A4` (WP04/WP06): the time CLI adapter,
// aliased like every other `cmd_*`. The MCP time tools answer with this adapter's own
// presentation rather than keeping a second copy — the refusal block, the scope block,
// the `-`-rule formatters, the breakdown vocabulary with its refusal sentence, the
// unconfirmed-delete sentence and the four mutation sentences, and the archived-selector
// refusal `time_total` shares with `tix time total` — so the two surfaces cannot drift
// (C-006).
use crate::commands::time as cmd_time;
// WP07 (mission `burndown-scope-analytics-01M3C9J1`): the burndown/scope-analytics
// surface, aliased like every other `cmd_*` and *used*. The tools below return this
// module's own report builders' text verbatim — the builders the CLI's three `tix
// analytics` verbs call — and the chart document those builders returned as the
// structured answer, so MCP owns no daily fold, no estimate conversion, no projection
// and no chart document of its own (C-008/NFR-003). A second rendering here is the
// defect the six deleted copies were, and there is nothing left to delete.
use crate::commands::analytics as cmd_analytics;
use crate::config::{Config, InitiativeConfiguration};
// WP08 (mission `repeating-cycle-schedules-01M3C7W1`): the cycle-schedule surface.
// The MCP peer of `tix cycle schedule …`, over the same services the CLI adapter
// calls. The types are aliased wherever the recurring surface already claims the
// plain name (`CycleLifecycleAction` vs `LifecycleAction`, `generate_cycles` vs the
// recurring `generate`), so a reader cannot confuse the two families.
use crate::cycle_schedule::config::{CycleScheduleConfiguration, CycleScheduleId};
use crate::cycle_schedule::evaluate::evaluate as evaluate_cycle_schedules;
use crate::cycle_schedule::generate::{
GenerationOutcome as CycleGenerationOutcome, GenerationReport as CycleGenerationReport,
GenerationRequest as CycleGenerationRequest, generate as generate_cycles,
};
use crate::cycle_schedule::identity::{
CycleReference, GeneratedCycle, GeneratedCycleId, GenerationCursor, ScheduleIndex, cadence_rule,
};
use crate::cycle_schedule::lifecycle::{
LifecycleAction as CycleLifecycleAction, LifecycleOutcome as CycleLifecycleOutcome,
LifecycleRequest as CycleLifecycleRequest, apply as apply_cycle_lifecycle,
};
use crate::cycle_schedule::registry::{self, ScheduleDraft, ScheduleEdit};
use crate::cycle_schedule::snapshot::{CycleSnapshot, CycleSnapshotStore};
// WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity surface. Every
// handler answers from the **shared** library — `CapacityScope`'s identity adapter,
// WP05's `calculation_for`/`snapshot_for` entry points, WP04's immutable snapshot
// reader and WP03's warning vocabulary — so MCP owns no formula, no conversion, no
// status comparison and no snapshot writer (C-007/NFR-003). A capacity scope is folded
// by the library, so a generated instance's stable id and its name are one scope here
// exactly as they are in the CLI (C-003).
use crate::capacity::calculate::{CapacityCalculation, CapacityScope};
use crate::capacity::config::CapacityScopeKind;
use crate::capacity::snapshot::CapacitySnapshot;
use crate::capacity::warning::{CapacityWarning, CapacityWarningKind};
use crate::capacity::{CapacityEntry, resolve_cycle, schedule_index};
use crate::cycle_store::CycleStore;
use crate::error::Result as TixResult;
use crate::model::{History, HistoryEventKind, Issue, Status};
use crate::records::RecordStore;
use crate::recurrence::detect::{Detection, detect};
use crate::recurrence::generate::{GenerationOutcome, GenerationRequest, generate};
use crate::recurrence::lifecycle::{
LifecycleAction, LifecycleDiagnostic, LifecycleOutcome, LifecycleRequest,
apply as apply_lifecycle,
};
use crate::recurrence::rule::{DateKey, IntervalUnit, RuleContext, RuleKind};
use crate::recurrence::series::{KeyState, OccurrenceKey, Series, SeriesId, SeriesIndex};
use crate::search::{Corpus, SearchHit};
use crate::storage::Store;
use crate::templates::{
DeleteConfirmed as TemplateDeleteConfirmed, OPTIONS_SOURCES, Template, TemplateDefaults,
TemplateDefinition, TemplateDetail, TemplatePrompt, TemplateReference, TemplateScope,
TemplateService, TemplateWriteError, resolve,
};
// WP06 (mission `per-issue-time-tracking-01M3C9A4`): the time surface. Every read
// answers from the **shared** time DTOs — `TimeSnapshot`'s totals, breakdowns and
// audit, `TimeDiagnostic`'s findings — and every mutation goes through
// `service::apply`, the one seam that validates, stages the projection and the single
// event, and writes once. MCP owns no sum, no filter rule, no entry validation, no
// `## History` grammar and no writer, so the CLI, the TUI, MCP and the portable
// mapping cannot disagree about a minute, a date or a refusal (C-006, NFR-003).
use crate::time_tracking::aggregate::{TimeSnapshot, TimeTotal, TimeTotalState};
use crate::time_tracking::diagnostics::TimeDiagnostic;
use crate::time_tracking::entry::TimeEntry;
use crate::time_tracking::filters::TimeFilter;
use crate::time_tracking::service::{
NewTimeEntry, TimeEntryEdit, TimeMutationRequest, apply as apply_time,
};
use crate::triage::{DecisionKind, TriageDecision, TriagePlan, batch, inbox};
use crate::views::divergence::{ActiveViewMarker, ViewDivergence, ViewSurface};
use crate::views::plan::{ViewApplyResult, ViewPlan};
use crate::views::registry::{
SavedView, ViewDiagnostic, ViewDiagnosticKind, ViewId, ViewRefusal, ViewScope, ViewWriteError,
};
use crate::views::service::{DeleteConfirmed, ViewAuditEvent, ViewDefinition, ViewService};
// WP06 / T027–T031: the initiative and roadmap tools. Aliased so the *CLI adapter*
// (`cmd_roadmap`, imported above) and the *service* (`crate::roadmap`) are not
// confused: the handlers call the service and reuse the adapter's renderers, never
// a second rule or a second rendering (C-007/NFR-003).
use crate::roadmap::registry::{AssociationKind, InitiativeEdit, NewInitiative};
use crate::roadmap::{
InitiativeSummary, ProgressValue, RoadmapDiagnostic, RoadmapFilter, RoadmapProjection,
RoadmapRow, RowReference, SummaryContribution,
};
use super::protocol;
// ---------------------------------------------------------------------------
// T019 – registry + argument helpers
// ---------------------------------------------------------------------------
/// A tool's static metadata. `input_schema` mirrors `contracts/mcp-tools.md`
/// §3 exactly (property names, types, `required`, `additionalProperties`).
pub(crate) struct ToolDescriptor {
pub(crate) name: &'static str,
description: &'static str,
input_schema: Value,
}
/// A handler's argument type errors are protocol failures (`-32602 Invalid params`);
/// handlers use [`ToolOutcome`] so a failed core call becomes an `isError` result
/// rather than propagating as a protocol error.
type ToolOutcome = std::result::Result<Value, ()>;
/// A handler's **inner** step: either a value, or a finished outcome (either a
/// protocol error or a tool result).
///
/// Split from [`ToolOutcome`] so a helper that can produce *either* kind of refusal
/// has a return type that carries both, and the handler's final return type stays the
/// one the dispatch table expects.
type ToolOutcome2<T> = std::result::Result<T, ToolOutcome>;
/// The inner-step form of a **protocol** error: a `-32602`.
///
/// Named because `Err(Err(()))` is unreadable and the two ways an inner step can fail
/// are the exact thing this alias exists to keep apart. A malformed argument is a
/// protocol error; a well-formed request the batch refuses is a tool result.
fn proto() -> ToolOutcome {
Err(())
}
/// The inner-step form of a **refusal**: a finished tool result carrying the reason.
fn refuse(text: String) -> ToolOutcome {
Ok(error_result(text))
}
pub(crate) fn registry() -> &'static [ToolDescriptor] {
static REGISTRY: OnceLock<Vec<ToolDescriptor>> = OnceLock::new();
REGISTRY.get_or_init(|| vec![
ToolDescriptor {
name: "create_issue",
description: "Create a new issue. Returns the created issue (its number, filename, and fields) exactly as `tix create` would write it. With `template`, the stable template's defaults and prompts are merged with the fields by the shared resolver and the resolved request goes through the same create core (`tix create --template`), with the selected ID and scope returned as provenance.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "string", "description": "Non-empty title (required unless `template` is set; no TTY prompt fallback)." },
"status": { "type": "string", "description": "Column; default the first configured column." },
"priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Default medium." },
"assignee": { "type": "string", "description": "Handle (`alice`/`@alice`/`@me`) or full identity; default git author." },
"labels": { "type": "string", "description": "Comma-separated labels." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"related_issues": { "type": "string", "description": "Comma-separated related-issue references: a local number/selector, a relative or absolute path to another issue file, or a repository URL (`host/path#id`). Stored verbatim." },
"type": { "type": "string", "description": "Issue type (e.g. `bug`, `feature`); `tix lint` checks it against `custom_types`." },
"estimate": { "type": "string" },
"cycles": { "type": "string", "description": "Comma-separated cycle names." },
"milestones": { "type": "string", "description": "Comma-separated milestone names." },
"projects": { "type": "string", "description": "Comma-separated project references to associate the new issue with, mirroring `tix create --projects`. A project is selected by its **stable id**; a name is a convenience that resolves to the same project (identity survives a rename). A value **sets the full association set** for the new issue; duplicates collapse to one association. Every reference is resolved through the project service **before** the issue is written, so an unknown reference refuses the whole create with `unknown_project` and nothing is written. Not accepted beside `template`, exactly as the CLI refuses the combination." },
"epics": { "type": "string", "description": "Comma-separated epic references to associate the new issue with, mirroring `tix create --epics`. An epic is selected by its **stable id**; a name is a convenience that resolves to the same epic (identity survives a rename). A value **sets the full reference set** for the new issue, and duplicates collapse to one reference. Every reference is resolved through the epic service **before** the issue is written, so an unknown reference refuses the whole create with `unknown_epic` and nothing is written. Referencing an epic never turns the issue into a task entity: it stays an ordinary issue (C-001). Not accepted beside `template`, exactly as the CLI refuses the combination." },
"reviewer": { "type": "string", "description": "Free-form reviewer name/identity." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD`." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date`." },
"parent": { "type": "string", "description": "Parent issue selector — number, filename, or unique title fragment (as everywhere a selector is taken). An empty string clears an existing parent; omitted leaves it alone." },
"body": { "type": "string", "description": "Markdown body." },
"template": { "type": "string", "description": "Create from a stable template ID (`list_templates`). The template's defaults and prompts are merged with the fields above by the pure resolver, then the resolved request goes through the same shared create core — there is no second writer. Resolution never prompts: a missing required prompt is a structured diagnostic, no issue is written, and the template is left untouched." },
"scope": { "type": "string", "description": "Template scope: `repository` (the default) | `board:<name>` | `team:<label>`. Only meaningful with `template` (a `scope` without one is refused). The selected template ID and scope come back as provenance on the created issue." }
},
"anyOf": [
{ "required": ["title"] },
{ "required": ["template"] }
]
}),
},
ToolDescriptor {
name: "list_issues",
description: "List issues as the board table. Filter by column, or restrict to one project's members.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"status": { "type": "string", "description": "Column to filter by." },
"project": { "type": "string", "description": "Restrict the listing to one project's members, resolved by its **stable id** (a name resolves to the same project). The matching set is the project service's derived membership, the same one `project_membership` returns, so the filter and the membership read can never disagree." },
"epic": { "type": "string", "description": "Restrict the listing to one epic's members, resolved by its **stable id** (a name resolves to the same epic). The matching set is the epic service's derived membership restricted to the **active** set the listing reads; `epic_membership` returns that same service membership over **every** issue file, so the two agree except for archived members (which the listing does not read)." },
"filter": { "type": "string", "description": "Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)." },
"sort": { "type": "string", "description": "Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)." },
"group": { "type": "string", "description": "Group key (`cycle` | `milestone`)." },
"all": { "type": "boolean", "description": "List every issue, ignoring the default scope — your issues (the git author identity), in the board's entry column. The CLI spelling is `tix list --all`, and the two render the same listing." }
}
}),
},
ToolDescriptor {
name: "get_issue",
description: "Return the full issue (frontmatter + body) for a selector.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "board_status",
description: "Column counts plus the total (`tix status`).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"filter": { "type": "string", "description": "Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)." },
"sort": { "type": "string", "description": "Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)." },
"group": { "type": "string", "description": "Group key (`cycle` | `milestone`)." },
"health": { "type": "boolean", "description": "Add the health summary (`tix status --health`): issue counts per state, the policy version, and the diagnostic count." }
}
}),
},
ToolDescriptor {
name: "update_issue",
description: "Merge fields into an issue (CLI `tix edit` flag path only; `$EDITOR` is never opened). At least one optional field must be provided.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"title": { "type": "string" },
"body": { "type": "string" },
"status": { "type": "string", "description": "Set the status. This is a MOVE, not a field write: it is validated by the same transition service as `tix move` and recorded as the same History decision, so it is not a way around the policy. `\"\"` clears the field (matching every other field here) and is a plain field write, not a transition, because there is no target column to judge." },
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
"assignee": { "type": "string" },
"labels": { "type": "string", "description": "Comma-separated." },
"files": { "type": "string", "description": "Comma-separated spec file paths." },
"related_issues": { "type": "string", "description": "Comma-separated related-issue references: a local number/selector, a relative or absolute path to another issue file, or a repository URL (`host/path#id`). Stored verbatim." },
"type": { "type": "string", "description": "Issue type (e.g. `bug`, `feature`); `tix lint` checks it against `custom_types`." },
"estimate": { "type": "string" },
"cycles": { "type": "string", "description": "Comma-separated cycle names (`\"\"` clears)." },
"milestones": { "type": "string", "description": "Comma-separated milestone names (`\"\"` clears)." },
"projects": { "type": "string", "description": "Project associations, with exactly the semantics of `tix edit --projects`: this argument **only clears**. A blank value (`\"\"`) drops every association; a non-blank value is refused, because setting associations has one spelling here (`add_project`, `remove_project`) so a typo cannot silently replace the set." },
"add_project": { "type": "array", "items": { "type": "string" }, "description": "Project references to **associate** the issue with (mirrors `tix edit --add-project`). Each entry is one CLI occurrence, taken **verbatim** — an entry is resolved by **stable id** first, with a name as a convenience that resolves to the same project; entries are neither trimmed nor comma-split (only `projects` is a comma-separated list, matching `--projects`). Idempotent: an association already present is a no-op that appends no History entry. Resolved through the project service before any write, so an unknown reference (including a padded or blank one — exactly as `--add-project \"\"` is refused by the CLI) refuses the whole update with `unknown_project` and leaves every file untouched; no file is written when nothing changed. An empty array contributes no reference." },
"remove_project": { "type": "array", "items": { "type": "string" }, "description": "Project references to **dissociate** from the issue (mirrors `tix edit --remove-project`), each entry taken **verbatim** as one CLI occurrence. Drops only the association — never the issue. An entry that resolves names that project; one that does not resolve must be an id the issue already carries (removing a stale reference is the repair path); an entry that resolves to nothing and is not stored is refused with `unknown_project`. Idempotent when the association is absent. An empty array contributes no reference." },
"epics": { "type": "string", "description": "Epic references, with exactly the semantics of `tix edit --epics`: this argument **only clears**. A blank value (`\"\"`) drops every reference; a non-blank value is refused, because setting references has one spelling here (`add_epic`, `remove_epic`) so a typo cannot silently replace the set." },
"add_epic": { "type": "array", "items": { "type": "string" }, "description": "Epic references to **add** to the issue (mirrors `tix edit --add-epic`). Each entry is one CLI occurrence, taken **verbatim** — an entry is resolved by **stable id** first, with a name as a convenience that resolves to the same epic; entries are neither trimmed nor comma-split (only `epics` is a comma-separated list, matching `--epics`). Idempotent: a reference already present is a no-op that appends no History entry. Resolved through the epic service before any write, so an unknown reference refuses the whole update with `unknown_epic` and nothing is written." },
"remove_epic": { "type": "array", "items": { "type": "string" }, "description": "Epic references to **remove** from the issue (mirrors `tix edit --remove-epic`), each entry taken **verbatim** as one CLI occurrence. Drops only the reference — never the issue. An entry that resolves names that epic; one that does not resolve must be an id the issue already carries (removing a stale reference is the repair path); an entry that resolves to nothing and is not stored is refused with `unknown_epic`. Idempotent when the reference is absent. An empty array contributes no reference." },
"reviewer": { "type": "string", "description": "Free-form reviewer name/identity (`\"\"` clears)." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD` (`\"\"` clears)." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date` (`\"\"` clears)." },
"parent": { "type": "string", "description": "Parent issue selector — number, filename, or unique title fragment (as everywhere a selector is taken). `\"\"` clears the parent, naming no issue; omitted leaves it unchanged." },
"commit": { "type": "string", "description": "Record a commit on the issue; `\"clear\"` removes it." },
"reason": { "type": "string", "description": "Why a transition policy is being overridden by `status`. Required to move an issue OUT of a terminal category (`completed` or `canceled`) on a board that declares a `category_map`; without it the update is refused with `invalid_transition`. A blank value is not a reason and is refused. The reason is recorded in the same History decision the CLI records. Mirrors the CLI's `tix edit --reason`." }
},
"required": ["issue"]
}),
},
// ---- WP07 / T032–T033: the project tools ------------------------------
//
// The project lifecycle and the two derived reads, plus the project-side
// association verbs. Every handler calls `crate::projects` (or the store's
// service-backed association write); no identity, uniqueness, membership
// or progress rule is re-derived here (C-006). A project is always named
// by its stable id; a name is accepted as a convenience that resolves to
// the same project (C-002).
ToolDescriptor {
name: "list_projects",
description: "List the projects in the registry, in declaration order, each with its **id**, its display name and its derived member count. The id is always present because selecting by id is the contract and a rename must never look like a new project (mirrors `tix project list`). Never fails: an empty registry is an empty list.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "get_project",
description: "Show one project's record and its derived, ordered members (mirrors `tix project show`). Resolved by its **stable id**; a name resolves to the same project. Every member carries its issue number and the shared `#<number> <title>` display label, computed on read so it is never stale. Membership is derived from the issues' association lists, never stored.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." }
},
"required": ["project"]
}),
},
ToolDescriptor {
name: "create_project",
description: "Create a project (mirrors `tix project create`). The registry allocates a **stable generated id**, which is returned; the name is a mutable display label and not the identity. Validates the name (non-blank, unpadded, unique case-folded) and the dates (`YYYY-MM-DD`, `due_date >= start_date`) through the service, so a refusal writes nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string", "description": "Display label. Required and unique in the registry (case-folded, trimmed); never the identity." },
"description": { "type": "string", "description": "Optional long-form context." },
"status": { "type": "string", "description": "Optional project status. Free-form project metadata — **not** a board column and not a workflow category." },
"lead": { "type": "string", "description": "Optional lead identity string (a bare handle or `Name <email>`); not a user account." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD`." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date`." }
},
"required": ["name"]
}),
},
ToolDescriptor {
name: "update_project",
description: "Edit a project's fields (mirrors `tix project edit`). Resolved by its **stable id** (or a name). A `name` change is a **label change only**: the id is untouched, so no association and no derived figure is re-keyed (C-002). A blank optional field (`\"\"`) clears it. At least one field must be provided; a name that duplicates another project is refused, and an inverted date pair is refused, either way with nothing written.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." },
"name": { "type": "string", "description": "New display label. The id does not change." },
"description": { "type": "string", "description": "New description; `\"\"` clears it." },
"status": { "type": "string", "description": "New project status; `\"\"` clears it." },
"lead": { "type": "string", "description": "New lead identity string; `\"\"` clears it." },
"start_date": { "type": "string", "description": "New `YYYY-MM-DD` start date; `\"\"` clears it." },
"due_date": { "type": "string", "description": "New `YYYY-MM-DD` due date; `\"\"` clears it." }
},
"required": ["project"]
}),
},
ToolDescriptor {
name: "delete_project",
description: "Delete a project's **registry entry only** (mirrors `tix project delete --yes`). No issue is deleted or modified: the issues that referenced it keep their references, which then read as diagnosable `missing_project_reference` findings until repaired (C-004, C-005). Returns the affected issues (the CLI's impact report) so an agent can warn the user instead of orphaning work. Requires `confirm: true`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." },
"confirm": { "type": "boolean", "description": "Must be true. Acknowledges that only the registry entry is removed and no issue is deleted." }
},
"required": ["project", "confirm"]
}),
},
ToolDescriptor {
name: "project_membership",
description: "The derived, ordered membership of one project (the `membership` operation of the project service). Resolved by its **stable id** (or a name). Returns the raw `member_count` and the ordered member list — each member's issue number and its `#<number> <title>` display label — in the service's order (by issue number), never a formatted summary, so the value can be compared with the CLI's and the TUI's (NFR-003). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." }
},
"required": ["project"]
}),
},
ToolDescriptor {
name: "project_health",
description: "One project's typed progress and health (the `health` operation of the project service; the same value `tix show` and `tix list --show progress` render). Resolved by its **stable id** (or a name). Returns the **raw** figures — `done`, `total`, `overdue`, `blocked`, `unassigned`, and `missing_references` — not a formatted summary, so parity is assertable by value (NFR-003). Every figure is computed once, in the service: `overdue` and `blocked` are consumed from their owning derived reads, never recomputed (C-006). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." }
},
"required": ["project"]
}),
},
ToolDescriptor {
name: "project_associate_issues",
description: "Associate one or more issues with a project, each independently (mirrors `tix project add` / `bulk-add`; the project-side form of the bulk associate operation). The project is resolved by its **stable id** once, up front, so an unknown project refuses before any write. Each selector is processed independently through the store's service-backed association write: one rejection is reported beside the rest rather than aborting them, and each accepted change appends exactly one History entry. An association already present is a no-op that appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a project name that resolves to the same project." },
"issues": { "type": "string", "description": "Comma-separated issue selectors (a number, a filename, or a title fragment — as everywhere a selector is taken). One outcome is reported per selector." }
},
"required": ["project", "issues"]
}),
},
ToolDescriptor {
name: "project_dissociate_issues",
description: "Dissociate one or more issues from a project, each independently (mirrors `tix project remove`). Drops only the association — never an issue (C-004). The project reference resolves to its **stable id** when it names a live project and is otherwise used **verbatim**, which is the repair path for a stale stored id (C-005). Each selector is independent: a rejection is reported beside the rest rather than aborting them, and an absent association is an idempotent no-op that appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"project": { "type": "string", "description": "Stable project id, or a name. A reference that resolves to no live project is used verbatim, so a stale stored id can still be removed." },
"issues": { "type": "string", "description": "Comma-separated issue selectors (a number, a filename, or a title fragment). One outcome is reported per selector." }
},
"required": ["project", "issues"]
}),
},
// ---- WP07 / T034: the epic tools -------------------------------------
//
// The epic lifecycle and its derived reads, mirroring the project tools
// above. Every handler calls `crate::epics` (or the store's
// service-backed reference write); identity, resolution, hierarchy,
// membership, progress and reference diagnostics each have exactly one
// owner, and a rule or a figure appearing in this layer would be the
// second implementation C-008 prohibits. An epic is always named by its
// **stable id**; a name is a convenience that resolves to the same epic
// (C-003). There is no task entity (C-001): a task-labelled issue is an
// ordinary issue that references an epic.
ToolDescriptor {
name: "list_epics",
description: "List the epics in the registry, in declaration order, each with its **id**, its display name, its derived member count and its derived child count (mirrors `tix epic list`). The id is always present because selecting by id is the contract and a rename must never look like a new epic (C-003). Never fails: an empty registry is an empty list.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "get_epic",
description: "Show one epic's record, its raw typed figures, its child epics and its derived, ordered members (mirrors `tix epic show`). Resolved by its **stable id**; a name resolves to the same epic. The figures are the service's typed `EpicRollup` (`done`, `total`, `child_count`, `weighted_progress`, `blocked`, `missing_references`), returned as raw values rather than only as the formatted text, so they compare by value (NFR-003). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or an epic name that resolves to the same epic." }
},
"required": ["epic"]
}),
},
ToolDescriptor {
name: "create_epic",
description: "Create an epic (mirrors `tix epic create`). The registry allocates a **stable generated id**, which is returned; the name is a mutable display label and not the identity. Validates the name (non-blank, unpadded, unique case-folded), the dates (`YYYY-MM-DD`, `due_date >= start_date`) and the weight (a positive integer) through the service, so a refusal writes nothing. `projects` is a comma-separated list of project **ids**, shape-checked only — a well-formed id the registry does not hold is stored and diagnosed by `tix lint` (C-005).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string", "description": "Display label. Required and unique in the registry (case-folded); never the identity." },
"description": { "type": "string", "description": "Optional long-form context." },
"status": { "type": "string", "description": "Optional epic status. Free-form epic metadata — **not** a board column and not a workflow category." },
"owner": { "type": "string", "description": "Optional owner identity string (a bare handle or `Name <email>`); not a user account." },
"start_date": { "type": "string", "description": "`YYYY-MM-DD`." },
"due_date": { "type": "string", "description": "`YYYY-MM-DD`; must be `>= start_date`." },
"weight": { "type": ["integer", "string"], "description": "Optional positive relative size, used by the epic rollup and by a parent epic's weighted figure. The CLI spelling is a string; a JSON integer is equivalent, and a string must be a decimal integer (a malformed one is the service's `invalid_weight` refusal)." },
"projects": { "type": "string", "description": "Comma-separated project **ids** this epic concerns. Each is shape-checked, never resolved: a dangling but well-formed id is stored and diagnosed by `tix lint` (C-005)." },
"milestone": { "type": "string", "description": "Milestone **name** (the existing name-keyed reference), stored verbatim." },
"cycle": { "type": "string", "description": "Cycle **name** (the existing name-keyed reference), stored verbatim." }
},
"required": ["name"]
}),
},
ToolDescriptor {
name: "update_epic",
description: "Edit an epic's fields (mirrors `tix epic edit`). Resolved by its **stable id** (or a name). A `name` change is a **label change only**: the id is untouched, so no reference and no derived figure is re-keyed (C-003, NFR-002). A blank optional field (`\"\"`) clears it. `parent` is validated by the service's iterative epic-chain walk **before** any write, so a self-parent or a cycle-closing change leaves the registry byte-unchanged. `projects` **replaces the whole list** (blank clears it), shape-checked only. At least one field must be provided.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." },
"name": { "type": "string", "description": "New display label. The id does not change. A name is never cleared (an empty name is refused as `duplicate_name`)." },
"description": { "type": "string", "description": "New description; `\"\"` clears it." },
"status": { "type": "string", "description": "New epic status; `\"\"` clears it." },
"owner": { "type": "string", "description": "New owner identity string; `\"\"` clears it." },
"start_date": { "type": "string", "description": "New `YYYY-MM-DD` start date; `\"\"` clears it." },
"due_date": { "type": "string", "description": "New `YYYY-MM-DD` due date; `\"\"` clears it." },
"weight": { "type": ["integer", "string"], "description": "New positive relative size; `\"\"` clears it. A malformed string is the service's `invalid_weight` refusal." },
"parent": { "type": "string", "description": "Set or change the parent, resolved by **stable id** (a name resolves to the same epic); `\"\"` detaches it. Validated by the epic service's cycle walk before any write." },
"projects": { "type": "string", "description": "Replace the whole project-id list, comma-separated; `\"\"` clears it. Shape-checked only, never resolved." },
"milestone": { "type": "string", "description": "New milestone name; `\"\"` clears it. Stored verbatim." },
"cycle": { "type": "string", "description": "New cycle name; `\"\"` clears it. Stored verbatim." }
},
"required": ["epic"]
}),
},
ToolDescriptor {
name: "delete_epic",
description: "Delete an epic's **registry entry only** (mirrors `tix epic delete --yes`). No issue is deleted or modified: the issues that referenced the epic keep their references, which then read as diagnosable missing references (C-005), and the child epics keep their recorded parent, which then reads as an unresolved parent. Returns the affected issues **and** the child epics — the CLI's impact report — so an agent can warn a user instead of orphaning work. The id is never reused. Requires `confirm: true`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." },
"confirm": { "type": "boolean", "description": "Must be true. Acknowledges that only the registry entry is removed and neither an issue nor a child epic is deleted." }
},
"required": ["epic", "confirm"]
}),
},
ToolDescriptor {
name: "epic_hierarchy",
description: "Navigate one epic's hierarchy (mirrors `tix epic navigate`). Resolved by its **stable id** (or a name). Returns the **raw values** in the service's order — the resolved `parent`, the direct `children`, the `ancestors` root-first, the `depth`, and the `unresolved` record when a recorded parent no longer exists — not a formatted summary, so a surface can compare by value (NFR-003). A member is an ordinary issue: direct issue navigation stays available (C-001). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." }
},
"required": ["epic"]
}),
},
ToolDescriptor {
name: "epic_membership",
description: "The derived, ordered membership of one epic (the `membership` operation of the epic service). Resolved by its **stable id** (or a name). Returns the raw `member_count` and the ordered member list — each member's issue number and its `#<number> <title>` display label — in the service's order (by issue number), never a formatted summary, so the value can be compared with the CLI's and the TUI's (NFR-003). Membership is derived from the issues' `epics` lists, never stored. Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." }
},
"required": ["epic"]
}),
},
ToolDescriptor {
name: "epic_health",
description: "One epic's typed progress and health (the `rollup` operation of the epic service; the same value `tix epic show` renders). Resolved by its **stable id** (or a name). Returns the **raw** figures — `done`, `total`, `child_count`, the `weighted_progress` value, `blocked` with its member list, and `missing_references` — not a formatted summary, so parity is assertable by value (NFR-003). Every figure is computed once, in the service: `blocked` is consumed from the relationship service's derived read, never recomputed (C-008), and `missing_references` names every unresolvable reference of all five kinds exactly as lint reports it (C-005). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." }
},
"required": ["epic"]
}),
},
ToolDescriptor {
name: "epic_associate_issues",
description: "Reference one or more issues from an epic, each independently (mirrors `tix epic add` / `tix epic bulk-add`; the epic-side form of the bulk reference operation). The epic is resolved by its **stable id** once, up front, so an unknown epic refuses before any write. Each selector is processed independently through the store's service-backed reference write: one rejection is reported beside the rest rather than aborting them, and each accepted change appends exactly one History entry. A reference already present is an idempotent no-op that appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name that resolves to the same epic." },
"issues": { "type": "string", "description": "Comma-separated issue selectors (a number, a filename, or a title fragment — as everywhere a selector is taken). One outcome is reported per selector." }
},
"required": ["epic", "issues"]
}),
},
ToolDescriptor {
name: "epic_dissociate_issues",
description: "Remove an epic reference from one or more issues, each independently (mirrors `tix epic remove`). Drops only the reference — never an issue (C-006). The epic reference resolves to its **stable id** when it names a live epic and is otherwise used **verbatim**, which is the repair path for a stale stored id (C-005). Each selector is independent: a rejection is reported beside the rest rather than aborting them, and an absent reference is an idempotent no-op that appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"epic": { "type": "string", "description": "Stable epic id, or a name. A reference that resolves to no live epic is used verbatim, so a stale stored id can still be removed." },
"issues": { "type": "string", "description": "Comma-separated issue selectors (a number, a filename, or a title fragment). One outcome is reported per selector." }
},
"required": ["epic", "issues"]
}),
},
ToolDescriptor {
name: "move_issue",
description: "Move an issue to a configured column (CLI `tix move`). A commit is recorded only when passed; never prompts. Moving toward the board's done column with open blockers is refused unless `yes` is set, and records the acknowledgement in the issue History. Validated by the same transition service **and the same work-in-progress policy** as the CLI and the TUI: a move that would exceed a column's `block` limit is refused with the policy's own diagnostics unless `override` authors it with `reason` and `yes`, and a `warn` limit proceeds with the decision reported in `wip`. A refusal returns the same named diagnostic on every surface.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"column": { "type": "string", "description": "A configured column." },
"commit": { "type": "string", "description": "Optional commit to record when the move reaches the board's completion column. `auto` resolves it from git history, as the CLI does." },
"reason": { "type": "string", "description": "Why a transition policy is being overridden. Required to move an issue OUT of a terminal category (`completed` or `canceled`) on a board that declares a `category_map`; without it the move is refused with `invalid_transition`. A blank value is not a reason and is refused. The reason is recorded in the issue's History. Mirrors the CLI's `tix move --reason`. A board with no `category_map` has no transition policy to override." },
"yes": { "type": "boolean", "description": "Acknowledge open blockers and proceed. Without it a move toward the done column with open blockers is refused. Mirrors the CLI's `tix move --yes`." },
"override": { "type": "boolean", "description": "Authorize overriding a **blocking work-in-progress limit**. Not an authorization on its own: the move proceeds only with `override: true` AND a `reason` AND `yes: true`, and the override is recorded as one History entry on the issue (actor, timestamp, reason and the limit it was authorized against). A `warn` limit never needs one; a move within every limit does not need one either." }
},
"required": ["issue", "column"]
}),
},
ToolDescriptor {
name: "delete_issue",
description: "Delete an issue file. Irreversible; requires confirm: true.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"confirm": { "type": "boolean", "description": "Must be true to delete." }
},
"required": ["issue", "confirm"]
}),
},
ToolDescriptor {
name: "search_issues",
description: "Case-insensitive substring search over the full issue markdown (frontmatter + body + comments), same corpus and matcher as `tix search`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"query": { "type": "string" }
},
"required": ["query"]
}),
},
ToolDescriptor {
name: "comment_on_issue",
description: "Append a comment to an issue's `## Comments` section via the model writer. Whitespace-only text is a no-op.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." },
"author": { "type": "string" },
"text": { "type": "string" }
},
"required": ["issue", "author", "text"]
}),
},
ToolDescriptor {
name: "mentions_list",
description: "List the `@mentions` pending for the local user (git identity), across issues. Read-only. Same records the TUI's bottom indicator counts and `tix mentions list` prints.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "mentions_mark_read",
description: "Mark one pending `@mention` read by id, so it is never surfaced as pending again. The only mutating mention answer: ignoring a mention is the absence of a write, and there is deliberately no tool for it.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Mention id, as returned by `mentions_list`." }
},
"required": ["id"]
}),
},
ToolDescriptor {
name: "export_issues",
description: "Mirror local issues to GitLab or Linear via the CLI tool (`glab`/`linear-cli`), or write them into a beads workspace through the `bd` CLI (`--target beads`); idempotent.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear", "beads"] },
"project": { "type": "string", "description": "Optional `--project` override." },
"dry_run": { "type": "boolean", "description": "Do not invoke the external CLI." }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "import_issues",
description: "Pull open remote issues from GitLab or Linear via the CLI tool, mirror a local GitHub Spec Kit project (`--target speckit`), or pull a beads workspace through the `bd` CLI (`--target beads`); idempotent.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"target": { "type": "string", "enum": ["gitlab", "linear", "speckit", "beads"] }
},
"required": ["target"]
}),
},
ToolDescriptor {
name: "link_issue",
description: "Create a TYPED relationship between two issues (`tix link --type`). **Breaking change (C-001): the untype symmetric form is gone and `kind` is REQUIRED** -- there is no default kind, so an untyped relationship is never created by accident. Both issues must exist; linking an issue to itself errors. `blocks` is the stored form of a blocking relationship: `blocked_by` is an accepted input spelling that normalizes into the equivalent `blocks` edge, and a reciprocal pair is rejected as a cycle.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"a": { "type": ["string", "integer"], "description": "Source issue: number, or a filename/title fragment." },
"b": { "type": ["string", "integer"], "description": "Target issue: number, or a filename/title fragment." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "REQUIRED relation kind. The vocabulary is closed -- an unknown kind is rejected, never coerced. `blocks`: the source blocks the target (the stored form of a blocking relationship). `blocked_by`: input-only spelling, normalizes into the equivalent `blocks` edge with the endpoints swapped. `duplicates`: directional, not reordered. `related`: explicit non-directional association, stored once and never mirrored."
}
},
"required": ["a", "b", "kind"]
}),
},
ToolDescriptor {
name: "list_relationships",
description: "List typed relationships for one issue, or for the whole board (`tix link list`). Ordered by (kind, target), so the output is byte-comparable across runs and surfaces. A relationship is stored once in its canonical source's frontmatter, so this reports every relationship *touching* an issue -- including ones stored on the other endpoint.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Restrict to relationships touching this issue. Omit for every issue on the board." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "Only relationships of this kind. `blocked_by` filters on the CANONICAL blocking edge, i.e. edges whose stored form is `blocks` -- no stored edge ever carries `blocked_by`, so filtering on it literally would match nothing."
}
}
}),
},
ToolDescriptor {
name: "edit_relationship",
description: "Retarget and/or re-kind an existing relationship by its stable id (`tix link edit`). The id SURVIVES the edit, so a relationship referenced elsewhere does not change identity. An edit naming its own current target and kind is the identity and writes nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Relationship id, as returned by list_relationships." },
"to": { "type": ["string", "integer"], "description": "New target issue. Omit to keep the current one." },
"kind": {
"type": "string",
"enum": ["blocks", "blocked_by", "duplicates", "related"],
"description": "New relation kind. Omit to keep the current one. At least one of `to` or `kind` is required."
}
},
"required": ["id"]
}),
},
ToolDescriptor {
name: "remove_relationship",
description: "Remove one relationship by its stable id (`tix link remove`). An id that is not stored is an IDEMPOTENT NO-OP: no file is written and no History entry is appended, and the call still succeeds, so it is safe to retry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Relationship id, as returned by list_relationships." }
},
"required": ["id"]
}),
},
ToolDescriptor {
name: "issue_chain",
description: "The transitive dependency chain an issue holds up, walking OUTBOUND `blocks` edges (`tix chain`). Nodes carry their issue number and display name so an agent can address them, and the order is the service's, unmodified. `truncated` is set when a traversal bound cut the result.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Root issue: number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "issue_blocked_state",
description: "Whether an issue has open blockers, which ones, and why (`tix list --show relationships`). Derived on read from the relationship service and never stored. An inbound `blocks` edge counts only if the blocker exists, is a different issue, and its status is not the board's configured done_column. An issue that is merely `related` to a blocker is NOT blocked, and neither is one whose blocker has reached the completion column. `reason` is the exact text the move warning and the detail view print.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue: number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "lint_issues",
description: "Validate frontmatter values in every issue file (same checks as `tix lint`); never crashes on malformed files. Also lints the foreign repositories reachable from `related_issues` unless `foreign` is false.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"foreign": { "type": "boolean", "description": "Also lint related repositories and merge their findings. Default true; `false` mirrors `tix lint --no-foreign`." }
}
}),
},
// ---- WP07 / T029: the hierarchy read tools -------------------------
//
// Three tools for the three derived reads in the parity contract
// (surface-parity.md §1): direct children, the parent of an issue, and
// the rollup. Named to match the existing `list_*` / `get_*` convention
// rather than the CLI's `tix parent <subcommand>`, because the registry
// is flat and one tool per subcommand would have to invent a namespace.
ToolDescriptor {
name: "list_children",
description: "List an issue's direct children, in the same order `tix parent children` prints them. Direct children only — never grandchildren. Returns the count, the ordered `(number, display)` pairs, and the exact text the CLI prints.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "get_parent",
description: "Read an issue's parent. Returns the resolved parent's number and display, or — when the recorded parent no longer exists — the retained reference plus the service's reason, which is the same text the CLI shows. An issue with no parent at all reports `parent: null` and no `unresolved` block.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "get_rollup",
description: "An issue's parent rollup: its direct-children count, how many are in the board's completion column, and the typed progress value. The figures describe direct children only (`completion_basis`), and the counts are returned as numbers rather than only as rendered text, so a caller can compare them without parsing prose.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Number, or a filename/title fragment." }
},
"required": ["issue"]
}),
},
// ---- WP08 / T035: the batch tools ------------------------------------
//
// Three tools, not one. `bulk_preview` and `bulk_apply` are separate because
// C-004 makes a preview read-only and an apply confirmed, and a single tool
// with a `confirm` boolean makes "did the agent remember?" a question about
// the agent rather than about the protocol. A separate `bulk_capabilities`
// exists because `contracts/surface-parity.md` requires the portable
// projection to report representability, and a client that has to infer it
// from two schemas is doing the parity work this mission exists to remove.
//
// The schemas are **generated from `bulk::metadata`**, not hand-written. A
// hand-written `json!` block is a fourth description of the whitelist, and
// the previous ones already drifted: WP06's `--estimate ""` finding was
// exactly a schema and an implementation disagreeing about a blank value.
ToolDescriptor {
name: "bulk_preview",
description: "Plan a batch and return it WITHOUT writing. Read-only: no file, no History entry, no relationship. Returns the selected issues in order, the proposed before/after values and path changes, the validation diagnostics, and the per-item status every surface shares. An apply needs the same arguments plus `confirm: true`.",
input_schema: bulk_schema(false),
},
ToolDescriptor {
name: "bulk_apply",
description: "Plan a batch, confirm it, and apply it. Requires `confirm: true` — without it nothing is written and the refusal names the missing confirmation. Each selected issue is processed independently: a failed item is reported and does not roll back the successful ones. Returns the same per-item statuses as `bulk_preview`.",
input_schema: bulk_schema(true),
},
ToolDescriptor {
name: "bulk_capabilities",
description: "What a batch can do in this build: the operations, the patch whitelist (with which fields are clearable), the shared outcome vocabulary, and the selection grammar. Every operation listed — `archive` included — is performable, so a client reads one availability word per operation. Takes no arguments.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
// ---- WP08 / T038: the triage tools -----------------------------------
//
// Four tools, and the write one is a **separate tool** with a required
// `confirm`, exactly as `bulk_preview`/`bulk_apply` are split. An optional
// `confirm` on a write tool is a boolean a client can forget, and a
// forgotten boolean on a write is the one thing C-004 exists to prevent.
//
// A single decision and a batch decision are **one** pair of tools rather
// than four: `issue` names one issue, `select`/`filter` name many, and
// every one of them is resolved through the same selection grammar and
// planned by the same `triage::batch::build_plan`. The TUI made the same
// choice (one modal, `ModalScope::One` or `Many`), so a client that
// learns the single case has already learned the batch one.
ToolDescriptor {
name: "triage_list",
description: "Read the triage inbox: one row per awaiting or deferred entry, with the queue age derived from each issue's entry stamp. `count: true` returns the counts without the rows; `age: true` sorts oldest-first with an unknown age last. Read-only: nothing is written, no History entry is appended. An entry with no entry stamp is reported as an unknown age, never as zero.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"age": {
"type": "boolean",
"description": "Sort oldest first. An entry with no readable entry stamp sorts last, because `unknown` is not `today`.",
},
"count": {
"type": "boolean",
"description": "Return the counts line without the rows, exactly as `tix triage list --count` does.",
},
}
}),
},
ToolDescriptor {
name: "triage_status",
description: "Read the inbox summary: the entry/deferred counts, the accepted/declined/duplicated figures, and the age histogram, all derived from one issue snapshot. Read-only. A project with no `[triage]` section has no inbox, and the refusal names the missing section.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "triage_preview",
description: "Plan one triage decision over one issue or a selection, and return it WITHOUT writing. Read-only: no file, no History entry, no relationship. Accept, decline, and defer each name the resulting status and one `history` event; a duplicate names the typed `duplicates` relation and never merges files. A missing required reason is a refused item naming `missing_reason`, not an error. An apply needs the same arguments plus `confirm: true`.",
input_schema: triage_schema(false),
},
ToolDescriptor {
name: "triage_apply",
description: "Plan a triage decision and apply it. Requires `confirm: true` — without it nothing is written and the refusal names the missing confirmation. Each selected issue is processed independently: a rejected item is reported and does not roll back the successful ones. Declining never deletes or archives an issue; the declined column is configured and recorded with the reason.",
input_schema: triage_schema(true),
},
// ---- WP06 / T029–T031: the saved-view tools --------------------------
//
// Nine descriptors, nine dispatch arms, in one change: the registry
// sweep catches a descriptor without an arm, and a client only finds
// out otherwise on the first call. Every one is an adapter over the
// shared `views` service — no filter parser, no registry writer, no host
// identity (C-001/C-003/C-004). `sort`/`group` are generated from the
// plan's own key vocabularies, so the schema and the validator cannot
// disagree.
ToolDescriptor {
name: "view_list",
description: "List saved views in declaration order, as `tix view list` prints them (tab-separated id, name, scope, resolution). Read-only. Each entry also carries its normalized plan, preserved extensions, and diagnostics. Scope is metadata: a `user` view stores an owner/shared-with label and grants no permission and creates no account.",
input_schema: view_schema(
&[],
map_of([(
"scope",
json!({
"type": "string",
"enum": ["repository", "project", "user"],
"description": "Only views in this scope family.",
}),
)]),
),
},
ToolDescriptor {
name: "view_show",
description: "Show one saved view: its stable id, name, explicit scope, normalized plan, preserved unknown metadata, and diagnostics. Read-only. Select by stable id, or by display name when that name is unambiguous — a name shared by two scopes of equal precedence is a `scope_conflict` diagnostic, never an implicit pick. A stored plan this build cannot interpret is refused with the field that caused it, before anything could apply it.",
input_schema: view_schema(&["view"], map_of([("view", view_reference_schema())])),
},
ToolDescriptor {
name: "view_create",
description: "Save a new named view from the existing query grammar. The stable id (`v-…`) is generated by the registry, is returned, and is never reused; `name` is only a display label. Writes one `tix.toml` commit and returns an accepted outcome with the audit event, or a rejected outcome with the ordered field diagnostics that refused it. Nothing is written on rejection.",
input_schema: view_schema(
&["name"],
properties([
map_of([("name", view_name_schema())]),
view_scope_properties(),
view_plan_properties(),
]),
),
},
ToolDescriptor {
name: "view_update",
description: "Replace an existing view's definition, keeping its stable id (identity survives a rename). Merge semantics: only the fields named here change, so a rename cannot drop the plan and an unnamed plan field is carried over. Extensions this build does not interpret are preserved. A same-content update is a `no-op`: no commit is issued. Accepted and rejected outcomes are structured the same way as `view_create`.",
input_schema: view_schema(
&["view"],
properties([
map_of([
("view", view_reference_schema()),
("name", view_name_schema()),
]),
view_plan_properties(),
]),
),
},
ToolDescriptor {
name: "view_duplicate",
description: "Copy a view under a **new** stable id. The copy carries the source's scope, plan, and preserved extensions verbatim; `name` overrides the label when given. The source is validated first, so an unloadable plan cannot be duplicated into the registry.",
input_schema: view_schema(
&["view"],
map_of([
("view", view_reference_schema()),
("name", view_name_schema()),
]),
),
},
ToolDescriptor {
name: "view_delete",
description: "Remove a view **definition** only, and only with `confirm: true`. It never deletes an issue, comment, or relationship: the outcome reports whether the in-memory active marker named it. Deleting an unknown reference is rejected. Definition-only deletion is the contract, not a side effect.",
input_schema: view_schema(
&["view", "confirm"],
map_of([
("view", view_reference_schema()),
(
"confirm",
json!({
"type": "boolean",
"description": "Must be true. Acknowledges that only the saved definition is removed.",
}),
),
]),
),
},
ToolDescriptor {
name: "view_share",
description: "Replace a view's explicit scope metadata, keeping its name and plan. Scope is metadata: `repository` (the default), `project` (naming a project id), or `user` (owner/shared-with labels). It is neither authentication nor permission, and no host account is created. An inconsistent scope — share metadata on a non-share scope, a project scope with no project — is rejected with a `scope_conflict` diagnostic.",
input_schema: view_schema(
&["view"],
properties([map_of([("view", view_reference_schema())]), view_scope_properties()]),
),
},
ToolDescriptor {
name: "view_apply",
description: "Apply a saved view **read-only**: resolve it, build the board once through the existing filter path, and return the normalized plan, the applied issue numbers in board order, the board summary, the active marker, and diagnostics. No issue, comment, relationship, or `tix.toml` byte changes; a refused or unsupported plan produces no board. The active marker is informational and never overwrites live filter state.",
input_schema: view_schema(&["view"], map_of([("view", view_reference_schema())])),
},
ToolDescriptor {
name: "view_status",
description: "Report a saved plan against a live plan assembled from the plan arguments here, field by field, using the existing grammar. Read-only. With `view` the comparison is against that definition; without it the report is the explicit \"no active saved view\" diagnostic — never a silent `in_sync: true`. Divergence is informational: nothing is reconciled and no live filter is changed.",
input_schema: view_schema(
&[],
properties([
map_of([("view", view_reference_schema())]),
view_plan_properties(),
]),
),
},
// ---- WP06 / T028–T029: the template registry -------------------------
//
// Six tools over the shared `TemplateService`, the sibling of the saved
// views service. MCP parses **no** template grammar of its own: the JSON
// arguments are deserialized into the service's own typed definition,
// which is the only thing that reaches the registry. `list`/`show` and
// the four mutations are declared *and* dispatched in the same change,
// because a descriptor without a dispatch arm is advertised and then
// fails every call — the failure the registration sweep catches.
ToolDescriptor {
name: "list_templates",
description: "List the stable-ID issue/form templates stored in `tix.toml` (`tix template list`), one entry per template with its immutable `id`, mutable display `name`, explicit `scope`, `defaults`, ordered `prompts`, typed `references`, and any preserved `extensions`. Read-only. Template IDs are stable across renames; names are labels, not identity.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "get_template",
description: "Return one template's full metadata (`tix template show`): its immutable `id`, display `name`, explicit `scope`, `defaults`, ordered `prompts`, typed `references` and preserved unknown `extensions`. `template` is the stable ID, or a display name that names exactly one template — an ambiguous name is refused rather than guessed. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"template": { "type": "string", "description": "Stable template ID, or a display name that names exactly one template." }
},
"required": ["template"]
}),
},
ToolDescriptor {
name: "create_template",
description: "Create a template under a new stable `id` (`tix template create`). The complete definition is validated against the shared registry before the atomic `tix.toml` save, so a refused create writes nothing. Templates are data: defaults, prompts and references can only name normal create fields and existing registries, never commands or arbitrary behavior.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": template_definition_schema("id"),
"required": ["id"]
}),
},
ToolDescriptor {
name: "update_template",
description: "Replace the supplied parts of an existing template (`tix template update`). The stable `id` never changes, so a rename keeps identity, scope, defaults, prompts, references and preserved unknown metadata; a field the request omits is left exactly as it was. A same-content update is a no-op that writes nothing and returns `status: \"no-op\"`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": template_definition_schema("template"),
"required": ["template"]
}),
},
ToolDescriptor {
name: "duplicate_template",
description: "Copy a template to a new stable ID (`tix template duplicate`), leaving the source untouched. The copy carries the source's scope, defaults, prompts, references and preserved extensions verbatim; only the ID is new. `new_id` overrides the deterministic `<source>-copy` allocation and `name` overrides the copied label.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"template": { "type": "string", "description": "Stable ID of the template to copy, or a display name that names exactly one." },
"new_id": { "type": "string", "description": "The new stable ID. Omit for the deterministic `<source>-copy` (then `-copy-2`, …)." },
"name": { "type": "string", "description": "Display label for the copy. Omit to copy the source's label." }
},
"required": ["template"]
}),
},
ToolDescriptor {
name: "delete_template",
description: "Delete a template **definition** (`tix template delete`), never the issues created from it — issues are files in the issue tree that this service never names. Requires `confirm: true`; without it nothing is removed. Deleting an unknown reference is refused because nothing was stored to remove.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"template": { "type": "string", "description": "Stable ID of the template to delete, or a display name that names exactly one." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is removed and the refusal names the missing confirmation." }
},
"required": ["template", "confirm"]
}),
},
// ---- WP08 / T038-T039: the recurring surface ----------------------------
//
// Ten tools over the shared services, the MCP peer of `tix recurring`. MCP
// owns no scheduler, no rule parser, no series writer and no second clock:
// the arguments are read, `SeriesIndex::build` indexes what the store already
// holds, and `detect`, `generate` and `apply` decide everything. A mutation
// requires `confirm: true`, exactly as the CLI requires `--yes`, and each one
// returns the shared outcome (`created`/`no-op`/`rejected`,
// `applied`/`no-op`/`rejected`) rather than a surface-specific word.
ToolDescriptor {
name: "list_recurring_series",
description: "List the recurring series stored in the issue tree (`tix recurring list`), one row per series in stable-ID order: the immutable `id`, the display `name`, the lifecycle `state`, the `next_cursor`, how many dates it skipped, and the rule in words. Read-only. This is the index the CLI, the board's due warning and the Schedule page all build, so a series missing here is missing everywhere.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "get_recurring_series",
description: "Return one series' full state (`tix recurring show`): identity and metadata, the rule and its anchor, the cursor, the skipped dates, the dates already generated, and every inconsistency the index found. `series` is the stable ID (from `list_recurring_series`), or a display name that names exactly one series — an ambiguous name is refused rather than guessed. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." }
},
"required": ["series"]
}),
},
ToolDescriptor {
name: "detect_recurring_due",
description: "Report which occurrences are due (`tix recurring detect`): every due warning with its stable `occurrence_key`, due date, series and rule in words, plus a note naming why a series that was examined is *not* due (paused, ended, cursor already taken). Read-only, and non-mutating by contract: detection creates no issue, appends no History and changes no series — the board's start/refresh warning and this call are the same detector's answer. `date` is the explicit date context; omitted, the boundary resolves today.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"date": { "type": "string", "description": "The date context, `YYYY-MM-DD`. Omitted: today, resolved at the boundary. Pass it for a reproducible answer." },
"timezone": { "type": "string", "description": "The timezone the date context carries, e.g. `+01:00` or `UTC`. It is carried on the context, exactly as `tix recurring detect --timezone` carries it; a *series rule* that stores a named IANA zone is a different case and is refused by the shared validator, because a daylight-saving transition makes a local date ambiguous." }
}
}),
},
ToolDescriptor {
name: "generate_recurring_occurrence",
description: "Create the occurrence a series is due for (`tix recurring generate`). Requires `confirm: true`; without it nothing is written and the refusal names the missing confirmation. `date` is the explicit occurrence date — the key is `<series-id>@<date>`. Idempotent: a key that already has an occurrence, or that the series recorded as skipped, returns `status: \"no-op\"` and writes nothing. On success the occurrence, its link to the series and the series' cursor are one transaction, and one History entry records the generation.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"date": { "type": "string", "description": "The occurrence date, `YYYY-MM-DD`." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is generated and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." }
},
"required": ["series", "date", "confirm"]
}),
},
ToolDescriptor {
name: "pause_recurring_series",
description: "Suspend generation for a series (`tix recurring pause`): its state becomes `paused` and one History entry records the decision. Requires `confirm: true`. Idempotent: an already-paused series returns `status: \"no-op\"` and appends nothing, and no refresh, timer or detection ever applies this — only a confirmed call does.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "confirm"]
}),
},
ToolDescriptor {
name: "resume_recurring_series",
description: "Resume a paused series (`tix recurring resume`): its state becomes `active` and one History entry records the decision. Never inferred from a refresh, and never automatic. Requires `confirm: true`. Idempotent: an already-active series returns `status: \"no-op\"` and appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "confirm"]
}),
},
ToolDescriptor {
name: "end_recurring_series",
description: "End a series (`tix recurring end`): `ended` is terminal for generation, and every action but `end` itself is refused on an ended series. Requires `confirm: true`. Idempotent: an already-ended series returns `status: \"no-op\"` and appends nothing. The issue that is the series is not deleted — ending is a lifecycle decision, not a removal.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "confirm"]
}),
},
ToolDescriptor {
name: "skip_recurring_occurrence",
description: "Record one occurrence date as skipped (`tix recurring skip`), so it is never generated: the date joins the series' `skipped` set and the cursor moves past it. `date` is the occurrence date, required — skip is about one date, and an occurrence that already exists cannot be skipped. Requires `confirm: true`. Idempotent: an already-skipped date returns `status: \"no-op\"` and appends nothing. `ignore` has the same schedule effect; the two differ in the decision the History entry records.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"date": { "type": "string", "description": "The occurrence date to skip, `YYYY-MM-DD`." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "date", "confirm"]
}),
},
ToolDescriptor {
name: "defer_recurring_occurrence",
description: "Move the series' cursor past one occurrence date without creating it (`tix recurring defer`): the occurrence is not generated and the date is **not** recorded as skipped, so the rule may produce it again on a later cursor. `date` is required. Requires `confirm: true`. Idempotent: a cursor already past that date returns `status: \"no-op\"` and appends nothing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"date": { "type": "string", "description": "The occurrence date to defer, `YYYY-MM-DD`." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "date", "confirm"]
}),
},
ToolDescriptor {
name: "ignore_recurring_occurrence",
description: "Stop offering one occurrence date (`tix recurring ignore`): recorded like `skip`, as a decision, so the date joins the series' `skipped` set. `date` is required and an occurrence that already exists cannot be ignored. Requires `confirm: true`. Idempotent: an already-recorded date returns `status: \"no-op\"` and appends nothing. `skip` has the same schedule effect; the two differ in the decision the History entry records.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"series": { "type": "string", "description": "Stable series ID, or a display name that names exactly one series." },
"date": { "type": "string", "description": "The occurrence date to ignore, `YYYY-MM-DD`." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is acting, for the History entry. Default: the boundary's actor (the git identity)." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History entry." }
},
"required": ["series", "date", "confirm"]
}),
},
// Mission `repeating-cycle-schedules-01M3C7W1` (WP08): the cycle-schedule
// tools. One descriptor per `tix cycle schedule <verb>`, named
// `cycle_schedule_<verb>` so the CLI subcommand enum can be diffed against
// this block by eye, plus `cycle_snapshot_list` for the snapshot view the
// parity contract's "Read snapshots" row names. Every marker for a mutating
// verb carries `confirm`; detection and the reads carry none.
ToolDescriptor {
name: "cycle_schedule_list",
description: "List the repeating cycle schedules defined in `[cycle_schedules.<id>]` (`tix cycle schedule list`), one row per schedule in declaration order: the stable `id`, the mutable display `name`, the lifecycle `state`, the cadence in words, the duration, the anchor `start_date`, the `look_ahead`, the derived `next_sequence` and how many instances exist. Read-only. The identity, the cursor and the diagnostics come from the one `ScheduleIndex` the CLI, the TUI and the refresh warning build (C-008).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "cycle_schedule_show",
description: "Return one schedule's full state (`tix cycle schedule show`): identity, cadence and its rule in words, duration, anchor, timezone, look-ahead, the derived cursor (`next_sequence`, `next_start`, `last_generated_key`), every generated instance with its state and effective dates, and every identity finding about it. `schedule` is the stable id, or a display name that names exactly one schedule — an ambiguous name is refused rather than guessed. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." }
},
"required": ["schedule"]
}),
},
ToolDescriptor {
name: "cycle_schedule_create",
description: "Define a repeating cycle schedule (`tix cycle schedule create`). The whole record is validated before the atomic save, so an invalid definition leaves `tix.toml` exactly as it was. Requires `confirm: true`; without it nothing is written and the refusal names the missing confirmation. A schedule id is never reused: an id already in the registry is refused as `exists`, and an id a stored cycle record **or** a closed-cycle snapshot still claims is refused as `id_spent`, naming whichever holder remains, because re-creating the id would otherwise silently rebind that history to a new definition (C-003/C-007).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"id": { "type": "string", "description": "Stable schedule id (letters, digits, `-`, `_`, `:`); the registry key, and it is never reused." },
"name": { "type": "string", "description": "Display label, unique case-insensitively within the registry." },
"cadence": { "type": "string", "description": "Cadence family: `daily`, `weekly`, `monthly` or `interval`. `interval` is the family that carries `unit`; the others refuse one." },
"interval": { "type": "integer", "minimum": 1, "description": "How many cadence units between cycle starts (1-1000; absent means 1)." },
"unit": { "type": "string", "enum": ["days", "weeks", "months"], "description": "The unit an `interval` cadence counts in." },
"duration": { "type": "integer", "minimum": 1, "description": "How long one cycle lasts, as a positive count." },
"duration_unit": { "type": "string", "enum": ["days", "weeks", "months"], "description": "The duration's unit." },
"start_date": { "type": "string", "description": "The schedule's stable anchor, a `YYYY-MM-DD` calendar date." },
"timezone": { "type": "string", "description": "Fixed UTC offset the schedule is expressed in: `UTC` or `±HH:MM` (`-05:00` is accepted). A named IANA zone is refused by the shared validator, which names each field." },
"look_ahead": { "type": "integer", "minimum": 0, "description": "How many future cycles to consider beyond the current one (0 when absent)." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["id", "name", "cadence", "duration", "duration_unit", "start_date", "timezone", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_edit",
description: "Change a schedule's fields, keeping its stable id (`tix cycle schedule edit`). Only the fields you name change; the rest of the record — including the generation cursor and preserved unknown metadata — is carried over, so identity survives a rename (C-002). An `interval`-family cadence edits its `interval`/`unit` in place. Requires `confirm: true`; the whole record is validated before the atomic save, so a refused edit leaves `tix.toml` unchanged.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"name": { "type": "string", "description": "New display label." },
"cadence": { "type": "string", "description": "New cadence family: `daily`, `weekly`, `monthly` or `interval`." },
"interval": { "type": "integer", "minimum": 1, "description": "New cadence interval (1-1000)." },
"unit": { "type": "string", "enum": ["days", "weeks", "months"], "description": "New cadence unit (only meaningful for the `interval` family)." },
"duration": { "type": "integer", "minimum": 1, "description": "New duration count." },
"duration_unit": { "type": "string", "enum": ["days", "weeks", "months"], "description": "New duration unit." },
"start_date": { "type": "string", "description": "New anchor date, `YYYY-MM-DD`." },
"timezone": { "type": "string", "description": "New timezone: `UTC` or `±HH:MM` (a west-of-UTC offset is accepted)." },
"look_ahead": { "type": "integer", "minimum": 0, "description": "New look-ahead count." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_delete",
description: "Delete a schedule's registry entry (`tix cycle schedule delete`). Requires `confirm: true`. The generated instances and their snapshots are **not** deleted: they keep their schedule reference, which then reads as a diagnosable missing schedule, so nothing is destroyed by deleting a definition (C-007). The id stays spent — it is never reused.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_detect",
description: "Report the schedule warnings (`tix cycle schedule detect`): every `ended`, `due` and `look-ahead` warning — with the instance or candidate's stable id, its effective dates and the explicit decisions it accepts — plus a `failure` warning for a schedule that cannot be evaluated, field-attributed. Read-only, and non-mutating by contract: detection creates no cycle, moves no cursor, writes no file and appends no History. `date` is the explicit date context; omitted, the boundary resolves today, which is why passing `date` is what makes an answer reproducible. `timezone` rides on the same shared evaluation context; the evaluator's answer is offset-independent (a schedule's own validated offset is the calendar its cadence is expressed in), which is why the CLI's namespace exposes `--date` alone.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"date": { "type": "string", "description": "Evaluate against this `YYYY-MM-DD` date instead of today." },
"timezone": { "type": "string", "description": "The timezone the date context carries, e.g. `+01:00` or `UTC`." }
}
}),
},
ToolDescriptor {
name: "cycle_schedule_generate",
description: "Create the schedule's future cycle instances (`tix cycle schedule generate`). Requires `confirm: true`; without it nothing is written and the refusal names the missing confirmation. Idempotent: a candidate whose stable key is already taken returns `status: \"no-op\"` and writes nothing, never a duplicate. `through` is a horizon selector (`--through`): every candidate starting on or before that date. Omitting it uses the schedule's own configured `look_ahead`, exactly as a confirmation of what detection showed. Every outcome carries the service's own vocabulary (`created`/`no-op`/`rejected`).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"through": { "type": "string", "description": "Generate every candidate whose start date is on or before this `YYYY-MM-DD` date." },
"date": { "type": "string", "description": "The `YYYY-MM-DD` as-of date the request records; omitted, the boundary resolves today. Generation is anchored at the schedule's own dates, so the as-of date never derives an instance." },
"timezone": { "type": "string", "description": "The timezone the as-of context carries, e.g. `+01:00`. A zone the crate cannot resolve is reported rather than normalized." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who confirmed the pass, for the audit context. Default: the boundary's actor (the git identity)." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_close",
description: "Close one instance (`tix cycle schedule close`): capture its immutable snapshot under `docs/cycle_snapshots/`, mark the record closed and append one History event to the record's own body. Requires `confirm: true` and a `reason` — the service refuses a missing one by name. Idempotent: an already-closed instance returns `status: \"no-op\"` and never appends a second snapshot or event. Name the instance with `cycle` (`<schedule-id>:<sequence>`) or with `from` (its start date).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"reason": { "type": "string", "description": "Why; recorded in the instance's History. Required by this action." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor (the git identity)." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 close instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_reopen",
description: "Reopen one closed instance (`tix cycle schedule reopen`): the record's state returns to `active` and exactly one History event is appended, naming the snapshot the close captured. Requires `confirm: true` and a `reason` — the service refuses a missing one by name. It writes **no** snapshot: a closed cycle's capacity evidence is immutable (C-008/NFR-002), so the prior snapshot's bytes are untouched and its `snapshot_id` stays readable after the reopen. Idempotent: an instance that is not closed returns `status: \"no-op\"` and appends no second event. Name the instance with `cycle` (`<schedule-id>:<sequence>`) or with `from` (its start date).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"reason": { "type": "string", "description": "Why; recorded in the instance's History. Required by this action." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor (the git identity)." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 reopen instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_extend",
description: "Push one instance's due date out by one cadence period (`tix cycle schedule extend`): the window moves, one History event records the reason and the new dates. Requires `confirm: true` and a `reason`. `by` exists to refuse anything but 1 explicitly — the shared lifecycle extends by exactly one period per action and this surface will not re-derive a multi-period step of its own.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"reason": { "type": "string", "description": "Why; recorded in the instance's History. Required by this action." },
"by": { "type": "integer", "enum": [1], "description": "How many cadence periods to extend by; the shared lifecycle supports 1, and any other value is refused explicitly." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_skip",
description: "Record one sequence as skipped (`tix cycle schedule skip`), so it is never regenerated: the instance's state becomes `skipped` and one History event records the reason. Requires `confirm: true` and a `reason`. Idempotent: an already-skipped instance returns `status: \"no-op\"`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"reason": { "type": "string", "description": "Why; recorded in the instance's History. Required by this action." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_pause",
description: "Pause a schedule and its non-terminal instances (`tix cycle schedule pause`): the schedule's state becomes `paused`, generation is refused until resumed, and one History event records it. Requires `confirm: true`. Idempotent: an already-paused schedule returns `status: \"no-op\"`. Never inferred from detection or a refresh — only a confirmed call applies it (C-004).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_resume",
description: "Resume a paused schedule and its instances (`tix cycle schedule resume`): the schedule's state returns to `active` and one History event records it. Requires `confirm: true`. Idempotent: an already-active schedule returns `status: \"no-op\"`. Never automatic, and an ended schedule stays terminal.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_adjust_future",
description: "Re-derive every future instance's dates from the schedule's own definition and re-derive the cursor (`tix cycle schedule adjust-future`). Requires `confirm: true` and a `reason`: it changes what future cycles are. Closed and skipped instances are historical and are never touched.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"reason": { "type": "string", "description": "Why; recorded in the History. Required by this action." },
"actor": { "type": "string", "description": "Who decided, for the History entry. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is written and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_schedule_ignore",
description: "Close a detection warning without deciding anything (`tix cycle schedule ignore`): the explicit \"no action\" decision, so nothing is written — no snapshot, no cursor move, no History entry. Requires `confirm: true` because it is still a recorded decision, exactly as the CLI requires `--confirm`. The outcome says `applied` only when the warning was there to ignore; an idempotent repeat reports `no-op`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"schedule": { "type": "string", "description": "Stable schedule id, or a display name that names exactly one schedule." },
"cycle": { "type": "string", "description": "Target instance, `<schedule-id>:<sequence>`." },
"from": { "type": "string", "description": "Select the instance by its start date, `YYYY-MM-DD`." },
"actor": { "type": "string", "description": "Who decided. Default: the boundary's actor." },
"timestamp": { "type": "string", "description": "The explicit RFC 3339 instant. Default: the boundary clock, exactly as the CLI resolves it." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing changes and the refusal names the missing confirmation." }
},
"required": ["schedule", "confirm"]
}),
},
ToolDescriptor {
name: "cycle_snapshot_list",
description: "List the immutable closed-cycle snapshots under `docs/cycle_snapshots/`, one entry per file: the stable `cycle_id`, its `schedule_id`, the historical `sequence`, the effective `start_date`/`due_date` at close, `closed_at`/`closed_by`, the state summary, and the preserved `measurements`/`extensions`. Read-only. A snapshot is evidence: it is written once at close and never recomputed from the live schedule, so a later schedule edit cannot change what this returns (C-007).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
// Mission `project-issue-health-status-01M3C8HX` (WP07 T035-T037): the health
// tools. Every one answers from the same portable document the CLI renders, and
// the read tools return the CLI's text verbatim.
ToolDescriptor {
name: "health_scope",
description: "One scope's health: the state, its layer, the policy version, the derived rollup with its contributors, the audit entries and every diagnostic with its remedy. Name exactly one of `issue` (a selector, as `tix health show`) or `scope` (a canonical spelling: `issue:42`, `cycle:2026-10`, `milestone:m1`).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector (number or slug), as `tix health show`." },
"scope": { "type": "string", "description": "Canonical scope spelling, e.g. `issue:42`, `cycle:2026-10`." }
}
}),
},
ToolDescriptor {
name: "health_rollup",
description: "A scope's derived health, with its contributors, its policy version and its diagnostics (`tix health rollup`). Rollups are policy-derived and read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["scope", "id"],
"properties": {
"scope": { "type": "string", "enum": ["issue", "project", "epic", "cycle", "milestone"], "description": "The scope kind." },
"id": { "type": "string", "description": "The scope's stable id (an issue number when `scope` is `issue`)." }
}
}),
},
ToolDescriptor {
name: "health_history",
description: "The append-only health History entries about a scope (`tix health history`), oldest first. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["scope"],
"properties": {
"scope": { "type": "string", "description": "Canonical scope spelling, e.g. `issue:42`." }
}
}),
},
ToolDescriptor {
name: "health_record",
description: "Record an issue's health state (`tix health set`), or layer an override over its derived value (`tix health override`). One append-only History entry carries the actor, the date and the rationale, written in the same step as the frontmatter value, or neither is written. **`override: true` requires `confirm: true`.**",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["issue", "state", "rationale"],
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector (number or slug)." },
"state": { "type": "string", "enum": ["unknown", "on_track", "needs_attention", "at_risk"], "description": "One of the four states." },
"rationale": { "type": "string", "description": "Why — required: a health change nobody can explain is not recorded." },
"actor": { "type": "string", "description": "Who is making the change (defaults to the configured git identity)." },
"date": { "type": "string", "description": "When, as `YYYY-MM-DD` (defaults to today)." },
"override": { "type": "boolean", "description": "Layer an override over the derived value instead of recording the issue's own state." },
"confirm": { "type": "boolean", "description": "Required with `override: true`: an override is an explicit act about a whole scope." }
}
}),
},
// ---- WP06 / T027–T028: the initiative tools ---------------------------
//
// The initiative lifecycle and its two association verbs. Every handler
// calls `crate::roadmap::registry`: identity, resolution, name
// uniqueness, date validation, reference shape and idempotence each have
// exactly one owner, and a rule appearing in this layer would be the
// second implementation C-008 prohibits. An initiative is always named by
// its **stable id**; a name is a convenience that resolves to the same
// initiative (C-002). The registry is re-read per call (as the project and
// epic tools do), so an initiative this session created is visible to the
// next read.
ToolDescriptor {
name: "list_initiatives",
description: "List the initiatives in the registry, in declaration order, each with its **id**, its display name, its target date and its status (mirrors `tix initiative list`). The id is a column of its own because selecting by id is the contract and a rename must never look like a new initiative (C-002). Never fails: an empty registry is an empty list. Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "get_initiative",
description: "Show one initiative's record, its metadata and its references, with the references resolved and diagnosed (mirrors `tix initiative show`; the resolution and diagnostics come from the shared roadmap projection). Resolved by its **stable id**; a name resolves to the same initiative. Returns the raw record fields, each reference's derived label and `resolution` (so a missing reference is visible, C-005), the source-attributed `summary`, and the ordered `diagnostics` — never a blended figure (C-004). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." }
},
"required": ["initiative"]
}),
},
ToolDescriptor {
name: "create_initiative",
description: "Create an initiative (mirrors `tix initiative create`). The registry allocates a **stable generated id**, which is returned; the name is a mutable display label and not the identity (C-002). Validates the name (non-blank, unpadded, unique case-folded), the priority spelling and the reference shapes through the service, and writes through the registry's atomic boundary, so a refusal persists nothing (C-008). An unknown-but-well-formed reference is stored and stays diagnosable (C-005).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string", "description": "Display label. Required and unique in the registry (case-folded, trimmed); never the identity." },
"outcome": { "type": "string", "description": "Optional strategic outcome." },
"owner": { "type": "string", "description": "Optional owner identity string (a bare handle or `Name <email>`); not a user account." },
"status": { "type": "string", "description": "Optional free-form initiative status — **not** a board column and not a workflow category." },
"priority": { "type": "string", "description": "Optional priority. A known spelling (`low`/`medium`/`high`) is normalized; an unknown one is stored verbatim, as the record keeps it." },
"labels": { "type": "string", "description": "Comma-separated filterable labels." },
"target_date": { "type": "string", "description": "Target date as `YYYY-MM-DD`." },
"projects": { "type": "string", "description": "Comma-separated project **ids** this initiative concerns. Each is shape-checked, never resolved: a dangling but well-formed id is stored and diagnosed by `tix roadmap`/`tix lint` (C-005)." },
"epics": { "type": "string", "description": "Comma-separated epic **ids** this initiative concerns. Shape-checked only, never resolved." },
"milestones": { "type": "string", "description": "Comma-separated milestone **names** (the existing name-keyed spelling). Stored verbatim." }
},
"required": ["name"]
}),
},
ToolDescriptor {
name: "update_initiative",
description: "Edit an initiative's fields (mirrors `tix initiative edit`). Resolved by its **stable id** (or a name). A `name` change is a **label change only**: the id is untouched, so no reference and no derived value is re-keyed (C-002). A blank optional field (`\"\"`) clears it; a blank reference list (`\"\"`) clears it. At least one field must be provided; a refusal writes nothing (C-008).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." },
"name": { "type": "string", "description": "New display label. The id does not change." },
"outcome": { "type": "string", "description": "New strategic outcome; `\"\"` clears it." },
"owner": { "type": "string", "description": "New owner identity string; `\"\"` clears it." },
"status": { "type": "string", "description": "New free-form status; `\"\"` clears it." },
"priority": { "type": "string", "description": "New priority; `\"\"` clears it. An unknown spelling is stored verbatim." },
"labels": { "type": "string", "description": "Replace the whole label list, comma-separated; `\"\"` clears it." },
"target_date": { "type": "string", "description": "New target date (`YYYY-MM-DD`); `\"\"` clears it." },
"projects": { "type": "string", "description": "Replace the whole project-id list, comma-separated; `\"\"` clears it. Shape-checked only, never resolved." },
"epics": { "type": "string", "description": "Replace the whole epic-id list, comma-separated; `\"\"` clears it. Shape-checked only." },
"milestones": { "type": "string", "description": "Replace the whole milestone-name list, comma-separated; `\"\"` clears it." }
},
"required": ["initiative"]
}),
},
ToolDescriptor {
name: "initiative_associate",
description: "Associate an initiative with exactly one project, epic or milestone (mirrors `tix initiative associate`; the association is part of the record, so the kind is explicit rather than guessed). The target is shape-checked and **never** resolved: a well-formed id that names nothing is stored and stays diagnosable (C-005). A repeated association is an idempotent no-op that writes no bytes; `changed` says which happened. Writes through the atomic boundary, so a refusal persists nothing (C-008).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." },
"kind": { "type": "string", "enum": ["project", "epic", "milestone"], "description": "The reference kind; part of the record's shape." },
"target": { "type": "string", "description": "The project id, epic id, or milestone name. Shape-checked, never resolved." }
},
"required": ["initiative", "kind", "target"]
}),
},
ToolDescriptor {
name: "initiative_dereference",
description: "Drop an explicit association from an initiative (mirrors `tix initiative dereference`). Drops only the association — never a source entity. Dropping an association that is not recorded is an idempotent no-op that writes no bytes; `changed` says which happened. Writes through the atomic boundary, so a refusal persists nothing (C-008).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." },
"kind": { "type": "string", "enum": ["project", "epic", "milestone"], "description": "The reference kind; part of the record's shape." },
"target": { "type": "string", "description": "The project id, epic id, or milestone name to drop." }
},
"required": ["initiative", "kind", "target"]
}),
},
ToolDescriptor {
name: "delete_initiative",
description: "Delete an initiative's **registry entry only** (mirrors `tix initiative delete --yes`). Nothing cascades: no project, epic, milestone or issue is deleted or modified, and the record's own references are returned (and printed) so an agent can see what the entry held — a missing reference is never silently repaired (C-005). The id is never reused. Requires `confirm: true`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." },
"confirm": { "type": "boolean", "description": "Must be true. Acknowledges that only the registry entry is removed and nothing else is deleted." }
},
"required": ["initiative", "confirm"]
}),
},
// ---- WP06 / T029–T031: the roadmap tools ------------------------------
//
// Read-only, over the shared projection. The filter is the **same**
// `RoadmapFilter` DTO the CLI builds, with the same field names, so the two
// surfaces cannot drift into two query languages (C-007), and a malformed
// value is refused with the filter's own named diagnostic before any work
// is done. The rows are the projection's `RoadmapRow`s: ordered by target
// date then stable id, one source-attributed contribution per referenced
// entity, with each owner's own value and policy version — a join, never a
// blended figure (C-004).
ToolDescriptor {
name: "roadmap_list",
description: "List the roadmap: initiatives in roadmap order (target date ascending, then stable id), filtered by the normalized `RoadmapFilter` (mirrors `tix roadmap list`). An undated initiative is the explicit `undated` state and sorts last — never a blank or an invented period (C-003), and a bounded period excludes it. Each row carries its metadata, its references with their resolution, the source-attributed `summary` (one contribution per referenced entity, with the owning service's own value and policy version), and its diagnostics — never a blended percentage or score (C-004). An empty result is an empty list, not an error. Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"period_from": { "type": "string", "description": "Inclusive lower bound on the target date (`YYYY-MM-DD`). A malformed date is refused with the filter's own diagnostic." },
"period_to": { "type": "string", "description": "Inclusive upper bound on the target date (`YYYY-MM-DD`)." },
"owner": { "type": "string", "description": "Owner to match; compared trimmed and case-insensitively. Must not be blank." },
"status": { "type": "string", "description": "Initiative status to match; compared trimmed and case-insensitively. Must not be blank." },
"labels": { "type": "array", "items": { "type": "string" }, "description": "Labels the initiative must all carry (an AND). Each compared trimmed and case-insensitively; a blank entry is refused." },
"query": { "type": "string", "description": "Free text matched case-insensitively against name/outcome/owner/status/labels." }
}
}),
},
ToolDescriptor {
name: "roadmap_show",
description: "Show one initiative's roadmap row — its metadata, references with resolution, source-attributed summary and diagnostics (mirrors `tix roadmap show`). Resolved by its **stable id**; a name resolves to the same initiative. The rendering is the CLI's own `roadmap_row_lines`, so the two surfaces cannot describe one row two ways. Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." }
},
"required": ["initiative"]
}),
},
ToolDescriptor {
name: "roadmap_summary",
description: "One initiative's progress/risk summary: the raw typed contributions, one per referenced source entity (its `<kind>:<id>` provenance, the owning service's own value, its risk value and the shared policy version), plus the explicit summary state, the ordered source entities and policy versions, and the diagnostics (the `InitiativeSummary` of `tix roadmap show`). No formatted summary and no blended figure: a missing reference is a contribution with `resolution: missing` and no value, and an initiative that references nothing has the explicit `empty` state (C-004, C-005, C-006). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"initiative": { "type": "string", "description": "Stable initiative id, or a name that resolves to the same initiative." }
},
"required": ["initiative"]
}),
},
// ---- WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity tools --
//
// The MCP spelling of `tix capacity …`, over the same shared library the CLI
// adapter calls: `CapacityScope` folds a scope reference, WP05's
// `calculation_for` runs the one calculation, and WP04's
// `CapacitySnapshot::read` reads the immutable close evidence. MCP owns no
// formula, no rounding policy, no conversion and no snapshot writer — every
// number below is a field of the shared DTO (C-007/NFR-003). The configuration
// tools are **read-only**: capacity configuration is a validated `tix.toml`
// write and it already has exactly one writer (the CLI's
// `tix capacity set|delete`), so a second MCP spelling of it would be the drift
// C-008 prohibits — an agent configures through the same CLI the human does and
// reads the result here. A missing value is `-` in the text and `null` in the
// structure, never `0`: an unconfigured scope is not a scope with zero capacity,
// and a missing conversion is not a zero total (C-004).
ToolDescriptor {
name: "capacity_list",
description: "List every configured capacity entry (`tix capacity list`), in declaration order: its `id` (the `[capacity.<id>]` handle), its `scope` kind, the `reference` it names, the `display` label that reference resolves to, the `capacity` ceiling, the `unit` and whether it is `enabled`. The `display` is the resolved identity, so a generated instance's stable id and its name are visibly one scope (C-003). Read-only: no write, no History entry. An absent ceiling or unit is `null`, never `0`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "capacity_show",
description: "Show one configured capacity entry (`tix capacity show <REFERENCE>`): its handle, scope, the reference it names, the resolved `display`, the ceiling, the unit and whether it is enabled. `reference` is a cycle name **or** its stable id, a team label, or an assignee identity — the library folds the two spellings into one scope, so a ceiling configured under a generated instance's stable id is found by its name too (C-003). `scope` (a `cycle`/`team`/`assignee` kind) narrows an ambiguous reference to one entry. A reference that names nothing, or several entries, is refused with the CLI's own sentence and nothing is written. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["reference"],
"properties": {
"reference": { "type": "string", "description": "A cycle name or stable id, a team label, or an assignee identity." },
"scope": { "type": "string", "enum": ["cycle", "team", "assignee"], "description": "Restrict the reference to one scope kind when it is ambiguous." }
}
}),
},
ToolDescriptor {
name: "capacity_calculate",
description: "The shared capacity calculation for one scope (`tix capacity calculate --scope <KIND> --id <REFERENCE>`). Returns `state` (`configured`/`uncountable`/`unconfigured`), the ceiling and unit, `committed`/`completed`/`remaining`/`overflow`, the floored `utilization` percentage, the counted/uncounted/unestimated issue counts, the source reading, and every `diagnostic` with its `kind`, scope, numbers, `reason` and `remedy`. Absent values are `null` (text `-`), never `0`: an unconfigured scope is not zero capacity, and a missing conversion is not a zero total (C-004). The overflow is a report, never a mutation (C-006). The text mirrors the CLI's own output for the same call — its stdout joined with the findings it writes to stderr, where a finding about an estimate or a reference always renders and `warnings: true` adds the overflow report exactly as `--warnings` does; the structure always carries every diagnostic. Read-only: nothing is written.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["scope", "id"],
"properties": {
"scope": { "type": "string", "enum": ["cycle", "team", "assignee"], "description": "The scope dimension to calculate." },
"id": { "type": "string", "description": "The scope reference: a cycle name or stable id, a team label, or an assignee identity." },
"warnings": { "type": "boolean", "description": "Also render the findings in the text, as `--warnings` does. The structured `diagnostics` always carry them." }
}
}),
},
ToolDescriptor {
name: "capacity_snapshot",
description: "Read a closed cycle's immutable capacity evidence (`tix capacity snapshot <CYCLE>`): the stable `snapshot_id`, `closed_at`/`closed_by`, the scope, the state and the values the close captured, the source reading, and the findings — plus the cycle record's `history` summaries, the `cycle_closed` and `cycle_reopened` entries the lifecycle appends, oldest first, so a reopen stays auditable (C-008). (The History vocabulary also names a `cycle_capacity_edited` kind, but no surface in this build appends one, so a reader will not find it here.) A snapshot with no capacity payload (written before this mission) is `evidence: \"none\"` — a value, never an error. Read-only, and immutable evidence: the payload is never recomputed from the live schedule (NFR-002). `cycle` names a **generated** cycle (its stable id or a name that resolves to it).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"required": ["cycle"],
"properties": {
"cycle": { "type": "string", "description": "A generated cycle: its stable id (`<schedule-id>:<sequence>`) or a name that resolves to it." },
"warnings": { "type": "boolean", "description": "Also render the stored overflow report in the text, as `--warnings` does; a finding about an estimate or a reference always renders in the text, as the CLI writes it to stderr. The structured `diagnostics` always carry them all." }
}
}),
},
// ---- WP06 (mission `per-issue-time-tracking-01M3C9A4`): the time tools ------
//
// The MCP spelling of `tix time …`, over the shared DTOs: `TimeSnapshot`
// answers every read, `service::apply` is the one mutation path, and
// `TimeFilter`/`TimeDiagnostic` are the one filter and the one finding
// vocabulary. MCP owns no sum, no filter rule, no entry validation and no
// writer: a scope the shared calculation refuses is refused here in the CLI's
// own words, and an absent value is `-` in the text and `null` in the
// structure — never `0` (C-002, C-005, C-006, NFR-003). The model is closed by
// C-009 and so are these schemas: one duration in whole minutes, an optional
// `YYYY-MM-DD` calendar date, an author and an optional note. There is no
// timer, no start/stop pair, no timezone, no overlap rule and no billing,
// payroll or invoicing field, and an argument that tries to be one is refused
// by name rather than ignored.
ToolDescriptor {
name: "time_show",
description: "The issue's current time entries and its total (`tix time show <ISSUE>`). The issue is resolved by the crate's own selector grammar, so an archived or otherwise off-view issue is still readable, and every entry is listed — malformed ones included, each explained by a finding in `diagnostics`, because a malformed entry is reported rather than hidden and blocks no read (C-005). Each entry carries the shared DTO's own fields: its stable `id`, `duration_minutes`, `date`, `author`, `note` and its preserved `extensions` as a **nested object** (never flattened beside the typed fields, which is how a preserved key would come to shadow one), and the total carries its explicit `state` — `recorded`, `nothing` (no entry) or `unknown` (entries, none usable). An absent value is `null` in the structure and `-` in the text, never `0`, so a malformed entry does not read as a zero-minute one (C-002, C-006). Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector — a number, a filename fragment or a unique title fragment, the same selector `tix view` and `tix time show` take." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "time_history",
description: "What History retains for one issue (`tix time history <ISSUE>`): the current-entry count beside every prior value a deletion left behind — the entry it named, when the deletion was recorded and who recorded it — plus how many corrections the issue's History keeps (FR-005, C-004). Corrections are counted rather than re-rendered: the prior values live in the `## History` line the mutation wrote, and re-parsing that grammar here would make this a second owner of a grammar the shared time service already owns. An event whose `entry_id` is `null` is a hand-edited History line the grammar cannot read; it is still counted and still listed, so the audit never silently shrinks. Read-only: no write, no History entry.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector — a number, a filename fragment or a unique title fragment." }
},
"required": ["issue"]
}),
},
ToolDescriptor {
name: "time_total",
description: "Total recorded effort for one scope (`tix time total`), and optionally its breakdown. The scope is the shared `TimeFilter`: `issue` (the same selector `time_show` takes), `assignee` — the identity that **did the work**, an entry's author, never the issue's own `assignee` (ownership) — an inclusive calendar period (`from`/`to`, the caller's dates: this surface resolves no `today`), `audit` (report what deletions left in History; never added to the total) and `by` (`issue` | `assignee` | `date`, with `all` the scope's one ungrouped total). Every number is a field of the shared `TimeTotal` DTO, and `malformed_count`/`undated_count` say whether the sum is the whole story (`is_partial`). An absent value is `null` in the structure and `-` in the text, never `0`: a scope that selected no entry is `state: \"nothing\"`, a scope whose entries carry no usable duration is `state: \"unknown\"`, and only a genuinely recorded zero is `0`. A refused filter, or an issue the snapshot does not hold, is refused in the CLI's own words and writes nothing (C-005, C-006, FR-003, NFR-003). Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Scope the total to one issue, by the same selector `time_show` takes (`tix time total <ISSUE>`). Omit for every active issue." },
"assignee": { "type": "string", "description": "The identity that did the work — an entry's `author`. `ada`, `@ada` and `Ada Lovelace <ada@example.com>` are one identity. Never the issue's own `assignee`." },
"from": { "type": "string", "description": "Inclusive period start, `YYYY-MM-DD`." },
"to": { "type": "string", "description": "Inclusive period end, `YYYY-MM-DD`." },
"audit": { "type": "boolean", "description": "Also report how many prior values deletions left in History. Never added to the total." },
"by": { "type": "string", "enum": ["all", "issue", "assignee", "date"], "description": "Break the total down. `all` (the default) is the scope's one ungrouped total; an entry that cannot be attributed or placed is shown as `(unattributed)`/`(undated)`, never dropped." }
}
}),
},
ToolDescriptor {
name: "time_add",
description: "Record effort against an issue (`tix time add <ISSUE> --minutes <N> --author <IDENTITY>`), through the shared time service — there is no second writer. `minutes` is whole minutes at or above zero (a negative value is refused by the shared entry validator with its field name), `date` is an optional `YYYY-MM-DD` **calendar date** (never an instant; this surface resolves no `today`), `author` is required because an entry without attribution is not auditable, and `note` is optional free text. The stable entry id is minted by the service and returned. `actor`/`timestamp`/`reason` are the service's own attribution seams — the CLI takes the crate's git identity and clock, and has no `--reason` on `add`, so a reason is optional here too. Appends exactly one attributable History event and touches no other issue field: never ownership, status, estimate or another entry (C-003, C-008, NFR-002).",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector — a number, a filename fragment or a unique title fragment." },
"minutes": { "type": "integer", "description": "The effort, in whole minutes at or above zero. Deliberately not pre-validated here: the shared entry validator refuses a negative value with its field name and its remedy." },
"date": { "type": "string", "description": "The calendar date the effort belongs to, `YYYY-MM-DD`. Optional, and an entry without one is real — a period-bounded read reports it as undated rather than as zero." },
"author": { "type": "string", "description": "Who did the work: a handle (`ada`, `@ada`) or a `Name <email>` identity." },
"note": { "type": "string", "description": "Free-text context, preserved verbatim on one line." },
"actor": { "type": "string", "description": "Who is recording the entry, for the History event. Default: the boundary's actor (the git identity)." },
"timestamp": { "type": "string", "description": "The instant to record, `YYYY-MM-DDTHH:MM:SSZ`. Default: now. An off-shape value is refused as `invalid_timestamp`." },
"reason": { "type": "string", "description": "Why, recorded verbatim in the History event. Optional for a create, as the CLI's `tix time add` has no `--reason`." }
},
"required": ["issue", "minutes", "author"]
}),
},
ToolDescriptor {
name: "time_edit",
description: "Correct one time entry (`tix time edit <ISSUE> --entry <ID>`), through the shared service's patch: a field the call does not name is left exactly as it was, including the entry's date, note and any metadata another build attached. `date`/`note` replace the value and `clear_date`/`clear_note` remove it — naming a replacement beside its clear is refused, exactly as `clap` refuses `--date` beside `--clear-date`, so no call is accepted that the CLI would reject. `reason` is required by the shared service, which refuses a blank one with `missing_reason`: the prior value stays in History and the reason is what makes the change attributable (C-003, C-004, FR-005). An edit that names no field is refused with `empty_edit`, and a request that asks for exactly what is already there writes nothing and appends no event.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector — a number, a filename fragment or a unique title fragment." },
"entry": { "type": "string", "description": "The stable id of the entry to correct; `time_show` lists them. Ids are never reused." },
"minutes": { "type": "integer", "description": "Replacement duration, in whole minutes at or above zero." },
"date": { "type": "string", "description": "Replacement calendar date, `YYYY-MM-DD`." },
"clear_date": { "type": "boolean", "description": "Remove the entry's date. Refused beside `date`, as the CLI refuses the flag pair." },
"note": { "type": "string", "description": "Replacement note." },
"clear_note": { "type": "boolean", "description": "Remove the entry's note. Refused beside `note`." },
"author": { "type": "string", "description": "Replacement author identity. There is no way to clear it: an entry without attribution is not auditable." },
"reason": { "type": "string", "description": "Why the entry is being corrected; recorded in History. Required by the shared service, which refuses a missing or blank one with `missing_reason`." },
"actor": { "type": "string", "description": "Who is correcting it, for the History event. Default: the git identity." },
"timestamp": { "type": "string", "description": "The instant to record, `YYYY-MM-DDTHH:MM:SSZ`. Default: now." }
},
"required": ["issue", "entry"]
}),
},
ToolDescriptor {
name: "time_delete",
description: "Remove one entry from the issue's **current** entries (`tix time delete <ISSUE> --entry <ID> --reason <TEXT> --confirm`). The entry leaves the frontmatter; its value and its event stay in History, where `time_history` reads them back, so a deletion is auditable rather than a rewrite (C-003, C-004, FR-005). Requires `confirm: true` — an unconfirmed deletion is refused before the store is even read, in the CLI's own words, and writes nothing. `reason` is required by the shared service (`missing_reason`). The stable id is never reused, in the current entries or in History.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"issue": { "type": ["string", "integer"], "description": "Issue selector — a number, a filename fragment or a unique title fragment." },
"entry": { "type": "string", "description": "The stable id of the entry to remove." },
"reason": { "type": "string", "description": "Why the entry is being removed; recorded in History. Required by the shared service." },
"confirm": { "type": "boolean", "description": "Must be true. Without it nothing is removed and the refusal names the missing confirmation." },
"actor": { "type": "string", "description": "Who is removing it, for the History event. Default: the git identity." },
"timestamp": { "type": "string", "description": "The instant to record, `YYYY-MM-DDTHH:MM:SSZ`. Default: now." }
},
"required": ["issue", "entry", "confirm"]
}),
},
// ---- WP07 (mission `burndown-scope-analytics-01M3C9J1`): the analytics tools --
//
// Four descriptors, one per facet of the shared burndown/scope-analytics
// surface, and each dispatch arm lands in the same change (see `dispatch`):
// a descriptor without an arm is advertised and then fails every call — the
// failure `every_declared_tool_name_dispatches` exists to catch, and it can
// only catch it because the two sides move together.
ToolDescriptor {
name: "analytics_metadata",
description: "The closed vocabularies, formulas and binding rules this build's analytics surface answers with: the scope kinds (`cycle`/`milestone`), every mode with its formula version, precision and whether it needs a unit, the units the project's **default board** declares a conversion for, the coverage markers, the projection statuses, the export format marker and schema version, and every diagnostic kind with its remedy. No CLI counterpart — `tix analytics` has three verbs and no capability read — so this exists to tell a caller what is possible **before** it folds anything, and every entry is the shared vocabulary's own (`ScopeKind::ALL`, `Mode::ALL`, …), never a list kept here. Read-only; a value the vocabulary does not have is `null`, never a `0`.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {}
}),
},
ToolDescriptor {
name: "analytics_burndown",
description: "The daily burndown series for one cycle or milestone, in count or estimate mode (`tix analytics burndown`). The `content` text is that command's stdout byte for byte, and `structuredContent` is the shared chart document it was rendered from: the scope, period, snapshot binding, mode, unit and formula version, the ordered daily points (`scope_total`, `remaining`, `completed`, `scope_added`, `completions`, each `null` where the record cannot answer, with the day's `coverage`), the coverage tally, and every finding. **`null` is not `0`** (C-005): an unobserved day has no additions and an unanswerable level has no value; the coverage marker says which. Refused, in the CLI's own words, when the fold answers no day at all — an unenumerable period, or estimate mode with a unit the project declares no conversion for — and for an unknown scope/mode or a malformed date, before any fold runs. Read-only: no issue, cycle, milestone or snapshot is opened for writing.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"scope": { "type": "string", "enum": ["cycle", "milestone"], "description": "The scope kind the `id` names, from the closed vocabulary. Anything else is refused by name." },
"id": { "type": "string", "description": "The scope's planning identity: a cycle name or its stable id, or a milestone name. A generated cycle's name and its stable id select the same scope." },
"from": { "type": "string", "description": "The series' inclusive start, `YYYY-MM-DD`. Replaces the scope's own start; a value after `to` is refused with the shared period reason." },
"to": { "type": "string", "description": "The series' inclusive end, `YYYY-MM-DD`. Replaces the scope's own planning end." },
"mode": { "type": "string", "enum": ["count", "estimate"], "description": "How the series counts. `count` (the default) is issue counts; `estimate` converts each member's estimate through the project's declared unit conversion. Anything else is refused by name." },
"unit": { "type": "string", "description": "The unit estimate mode counts in — one the project's default board declares a conversion for. Required in estimate mode (a missing one is `missing_conversion`); count mode is unitless and ignores it, exactly as the CLI does." }
},
"required": ["scope", "id"]
}),
},
ToolDescriptor {
name: "analytics_projection",
description: "The read-only end-date forecast over one cycle or milestone at `as_of` (`tix analytics projection`). The `content` text is that command's stdout byte for byte, and `structuredContent` is the shared chart document carrying the projection: its status (`projected`/`unknown`/`insufficient_data`), the planning `end_date` it was drawn against, the `remaining` work and `observed_rate`, and the `projected_completion` and `meets_end_date` — each `null` when the record cannot derive it, never a fabricated date or a `0` (C-004). An unanswerable forecast is an **answer**, not a refusal: its own `status` says so. The request-level refusals (a malformed `as_of`, a scope with no planning end date, estimate mode with no unit) happen before the fold and read exactly as the CLI prints them. Read-only.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"scope": { "type": "string", "enum": ["cycle", "milestone"], "description": "The scope kind the `id` names." },
"id": { "type": "string", "description": "The scope's planning identity: a cycle name or its stable id, or a milestone name." },
"as_of": { "type": "string", "description": "The forecast origin, a `YYYY-MM-DD` calendar date — the caller's day, never a clock. Required: a forecast without an origin has nothing to be drawn from." },
"from": { "type": "string", "description": "The series' inclusive start, `YYYY-MM-DD`." },
"to": { "type": "string", "description": "The planning end date the forecast is drawn against, `YYYY-MM-DD`. Replaces the scope's own; a scope that declares none requires it." },
"mode": { "type": "string", "enum": ["count", "estimate"], "description": "How the series the forecast is drawn from counts. `count` (the default) or `estimate`." },
"unit": { "type": "string", "description": "The unit estimate mode counts in; required in estimate mode, ignored in count mode." }
},
"required": ["scope", "id", "as_of"]
}),
},
ToolDescriptor {
name: "analytics_export",
description: "The deterministic local chart document for one cycle or milestone (`tix analytics export`), as the pretty JSON document on stdout. The `content` text is that command's stdout bytes exactly, and `structuredContent` is the same document as an object: the format marker and `schema_version`, the scope with its period and snapshot provenance, the mode, unit and precision, the series' ordered daily points with their coverage markers and `null`s, the optional projection, the coverage tally (the unknown/coverage-limited day counts), and the findings — including each configured `gitlab`/`linear` target that cannot carry a chart document. `mode` is **required** here, as the CLI requires `--mode`: the document states the mode it was folded in and a default would be a guess about which arithmetic produced the numbers. Deterministic and read-only: the same repository state produces byte-identical output, and nothing is written (the CLI's own `--output` path is the one writer, and this tool returns the document instead). The remote export namespaces are never consulted.",
input_schema: json!({
"type": "object",
"additionalProperties": false,
"properties": {
"scope": { "type": "string", "enum": ["cycle", "milestone"], "description": "The scope kind the `id` names." },
"id": { "type": "string", "description": "The scope's planning identity: a cycle name or its stable id, or a milestone name." },
"mode": { "type": "string", "enum": ["count", "estimate"], "description": "How the document's series was folded: `count` or `estimate`. Required — the document records it as the arithmetic it was built with." },
"unit": { "type": "string", "description": "The unit estimate mode counts in; required in estimate mode, ignored in count mode." },
"from": { "type": "string", "description": "The series' inclusive start, `YYYY-MM-DD`." },
"to": { "type": "string", "description": "The series' inclusive end (and, with `as_of`, the planning end the forecast is drawn against), `YYYY-MM-DD`." },
"as_of": { "type": "string", "description": "Ask the same document for a forecast at this origin, a `YYYY-MM-DD` calendar date. Omit for a document with no projection." }
},
"required": ["scope", "id", "mode"]
}),
},
])
}
/// One property map from `(name, schema)` pairs.
fn map_of(
pairs: impl IntoIterator<Item = (&'static str, Value)>,
) -> serde_json::Map<String, Value> {
pairs
.into_iter()
.map(|(name, schema)| (name.to_string(), schema))
.collect()
}
/// Several property maps merged, later entries winning.
fn properties(
parts: impl IntoIterator<Item = serde_json::Map<String, Value>>,
) -> serde_json::Map<String, Value> {
let mut out = serde_json::Map::new();
for part in parts {
out.extend(part);
}
out
}
/// An object schema for a view tool: closed, with the same `required` spelling
/// every other descriptor uses.
fn view_schema(required: &[&str], properties: serde_json::Map<String, Value>) -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required,
})
}
/// The stable-id-or-name argument every single-view tool takes.
fn view_reference_schema() -> Value {
json!({
"type": "string",
"description": "Stable view id (`v-…`), or a display name when it is unambiguous. The id is the identity; the name is a label and may be changed or shared.",
})
}
/// The display-label argument.
fn view_name_schema() -> Value {
json!({
"type": "string",
"description": "Display label. The stable id is generated by the registry and never changes.",
})
}
/// The plan-shaping arguments, mirroring the CLI's `ViewPlanArgs` spelling.
///
/// `filter` is the **existing** token query — the same grammar `tix list
/// --filter` accepts — and `sort`/`group` are `enum`s generated from
/// [`crate::views::plan::SORT_KEYS`]/`GROUP_KEYS`, the very vocabularies
/// `ViewPlan::validate` checks against (C-004). A key the schema would not offer
/// is still refused by the plan's own diagnostic if a client sends it anyway.
fn view_plan_properties() -> serde_json::Map<String, Value> {
map_of([
(
"filter",
json!({
"type": "string",
"description": "The existing token query (`status:todo`, `label:urgent`, `category:done`, …), resolved by the same grammar `tix list --filter` uses.",
}),
),
(
"sort",
json!({
"type": "string",
"enum": crate::views::plan::SORT_KEYS,
"description": "The existing sort-key vocabulary.",
}),
),
(
"group",
json!({
"type": "string",
"enum": crate::views::plan::GROUP_KEYS,
"description": "The existing group-key vocabulary.",
}),
),
(
"hide_columns",
json!({
"type": "string",
"description": "Comma-separated board columns to hide when the view is applied. Giving this or `show_mismatch` makes visibility an absolute snapshot; omitting both means the view inherits the live board's visibility.",
}),
),
(
"show_mismatch",
json!({
"type": "boolean",
"description": "Show the computed `Mismatched` column when the view is applied.",
}),
),
])
}
/// The explicit scope-metadata arguments, shared by `view_create`,
/// `view_update`, and `view_share`, mirroring the CLI's `ViewScopeArgs`.
fn view_scope_properties() -> serde_json::Map<String, Value> {
map_of([
(
"scope",
json!({
"type": "string",
"enum": ["repository", "project", "user"],
"description": "Scope family. Defaults to `repository` when omitted.",
}),
),
(
"project",
json!({
"type": "string",
"description": "Project id, for `scope: \"project\"`. Unknown ids are refused by the registry, not by this schema.",
}),
),
(
"owner",
json!({
"type": "string",
"description": "Owner label, for `scope: \"user\"`. Metadata only — not an account and not a permission.",
}),
),
(
"shared_with",
json!({
"type": "string",
"description": "Comma-separated shared-with labels, for `scope: \"user\"`. Metadata only.",
}),
),
])
}
/// The batch schema, generated from the capability projection (T035).
///
/// `confirm` is `required` on the apply and **absent** on the preview, rather than
/// optional on both. An optional `confirm` on the apply is a boolean a client can
/// forget, and a forgotten boolean on a write tool is the one thing C-004 exists to
/// prevent.
fn bulk_schema(require_confirmation: bool) -> Value {
let caps = bulk::metadata::capabilities();
let mut properties = serde_json::Map::new();
for (key, description) in [
(
"select",
"Comma-separated issue selectors. Mutually exclusive with `filter` and `search` — name exactly one grammar.",
),
(
"filter",
"The existing structured filter grammar, e.g. `status:todo label:bug`. Mutually exclusive with `select` and `search`.",
),
(
"search",
"An existing search token, e.g. `fix crash`. Mutually exclusive with `select` and `filter`.",
),
(
"scope",
"Selection scope. Defaults to the active scope, exactly as the CLI's --scope does.",
),
(
"operation",
"One of the operation names `bulk_capabilities` reports.",
),
(
"target",
"The other end of a pair. Required for `link` and `unlink`; ignored for `edit` and `archive`.",
),
(
"type",
"The relationship kind. Required for `link` and `unlink`, and validated by the relationship service's own vocabulary — not this schema's.",
),
] {
properties.insert(
key.to_string(),
json!({ "type": "string", "description": description }),
);
}
// The patch fields, from the whitelist. `status` is `not clearable` and the rest
// are, and the schema says so rather than leaving a client to try.
for field in &caps.fields {
properties.insert(
field.name.clone(),
json!({
"type": "string",
"description": format!(
"{}{}",
field.summary,
if field.clearable {
" Clearable with `clear`."
} else {
" Not clearable."
}
)
}),
);
}
properties.insert(
"clear".to_string(),
json!({
"type": "string",
"description": format!(
"Name exactly one field to empty. One per call. Clearable: {}.",
caps.fields.iter().filter(|f| f.clearable).map(|f| f.name.as_str()).collect::<Vec<_>>().join(", ")
)
}),
);
// Only `operation` is structurally required. Exactly one of
// `select`/`filter`/`search` is required too, but it is **not** expressible in
// JSON Schema as "exactly one of", and naming `select` in `required` would make a
// filter-only request a `-32602` while the shared request builder already has the
// right refusal — "name exactly one of --select, --filter or --search" — and a
// client deserves to read it rather than a schema violation.
let mut required = vec![Value::from("operation")];
if require_confirmation {
properties.insert(
"confirm".to_string(),
json!({
"type": "boolean",
"description": "Must be true. Without it nothing is written and the refusal names the missing confirmation (C-004)."
}),
);
required.push(Value::from("confirm"));
}
json!({
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required
})
}
/// The triage schema (T038): the CLI's own argument names and spellings, so a
/// client that has read `tix triage --help` can send the same request without a
/// translation table.
///
/// `confirm` is `required` on the apply and **absent** on the preview, for the
/// reason [`bulk_schema`] gives: a write tool whose confirmation is optional is a
/// write tool a client can call by accident.
///
/// The selection rule (`exactly one of issue | select | filter`) is stated in the
/// descriptions but **enforced by the shared selection builder**, not here — a
/// client that names two gets the CLI's own message rather than a schema
/// complaint, which is the parity `surface-parity.md` §Equivalence asks for.
fn triage_schema(require_confirmation: bool) -> Value {
let mut properties = json!({
"issue": {
"type": ["string", "integer"],
"description": "One issue: a number, a filename fragment, or a unique title fragment. Mutually exclusive with `select` and `filter`.",
},
"select": {
"type": "string",
"description": "Comma-separated issue selectors: numbers, filename fragments, or unique title fragments. Mutually exclusive with `issue` and `filter`.",
},
"filter": {
"type": "string",
"description": "A `tix list --filter` query selecting the issues, e.g. `status:triage`. Mutually exclusive with `issue` and `select`.",
},
"decision": {
"type": "string",
"enum": ["accept", "decline", "duplicate", "defer"],
"description": "Which decision to make. `accept` may name a target column; `decline`, `duplicate` and `defer` require a reason.",
},
"reason": {
"type": "string",
"description": "Recorded on the History event. Required for decline, duplicate and defer — a missing one is a refused item naming `missing_reason`, exactly as the CLI reports it.",
},
"actor": {
"type": "string",
"description": "Who decided. Defaults to the git identity, exactly as the CLI's `--actor` does.",
},
"to": {
"type": "string",
"description": "The accept target column, validated by the board's own transition policy. With no `to`, the configured `[triage].accepted_status` is used, and with neither the item is refused rather than guessed.",
},
"of": {
"type": "string",
"description": "The duplicate target selector, for `decision: \"duplicate\"`. Creates a typed `duplicates` relation; the two files are never merged.",
},
});
let required = if require_confirmation {
properties["confirm"] = json!({
"type": "boolean",
"description": "Required, and must be true. A triage apply writes files and appends History; without it nothing is written.",
});
json!(["decision", "confirm"])
} else {
json!(["decision"])
};
json!({
"type": "object",
"additionalProperties": false,
"properties": properties,
"required": required,
})
}
/// The `create_template`/`update_template` properties: the selector, plus the
/// definition's own fields.
///
/// `selector_key` is the only difference between the two schemas — `id` on
/// create (the new stable ID) and `template` on update (an existing stable ID or
/// unique display name) — so the definition's shape is declared once.
fn template_definition_schema(selector_key: &str) -> Value {
let selector = if selector_key == "id" {
json!({
"type": "string",
"description": "The new immutable stable ID: ASCII letters, digits, `-` or `_`, and not already in use.",
})
} else {
json!({
"type": "string",
"description": "Stable ID of the template to change, or a display name that names exactly one template.",
})
};
let mut properties = serde_json::Map::new();
properties.insert(selector_key.to_string(), selector);
properties.insert(
"name".to_string(),
json!({
"type": "string",
"description": "Mutable display label. Not identity: a rename keeps the stable ID and every stored part.",
}),
);
properties.insert(
"scope".to_string(),
json!({
"type": "string",
"description": "Explicit selection: `repository` (the default), `board:<name>` or `team:<label>`. There is no implicit inheritance, and a scope naming an unknown board or team is refused.",
}),
);
properties.insert("defaults".to_string(), template_defaults_schema());
properties.insert(
"prompts".to_string(),
json!({
"type": "array",
"description": "Ordered structured prompts. Supplying any prompt replaces the whole list.",
"items": template_prompt_schema(),
}),
);
properties.insert(
"references".to_string(),
json!({
"type": "array",
"description": "Typed references, validated against the existing registries before any issue is written. Supplying any reference replaces the whole list.",
"items": template_reference_schema(),
}),
);
Value::Object(properties)
}
/// The `defaults` object: normal create fields only, so a template can never
/// introduce a field the shared create core does not understand.
///
/// Closed like the registry's own `TemplateDefaults` (`deny_unknown_fields`): an
/// unknown key is refused with a field diagnostic, never silently stored as
/// metadata.
fn template_defaults_schema() -> Value {
let mut properties = serde_json::Map::new();
for (field, description) in [
("title", "Pre-filled title."),
("body", "Pre-filled Markdown body."),
(
"status",
"Pre-filled column; validated against the board's own columns.",
),
(
"priority",
"Pre-filled priority (`low` | `medium` | `high`).",
),
("assignee", "Pre-filled assignee identity."),
("estimate", "Pre-filled estimate."),
("reviewer", "Pre-filled reviewer."),
("start_date", "`YYYY-MM-DD`."),
("due_date", "`YYYY-MM-DD`; must be `>= start_date`."),
("parent", "Pre-filled parent selector."),
("type", "Pre-filled issue type (e.g. `bug`, `feature`)."),
] {
properties.insert(
field.to_string(),
json!({ "type": "string", "description": description }),
);
}
for (field, description) in [
("labels", "Pre-filled labels."),
("files", "Pre-filled attached file paths."),
("cycles", "Pre-filled cycle names."),
("milestones", "Pre-filled milestone names."),
("related_issues", "Pre-filled cross-repository references."),
] {
properties.insert(
field.to_string(),
json!({ "type": "array", "items": { "type": "string" }, "description": description }),
);
}
json!({
"type": "object",
"additionalProperties": false,
"description": "Normal create-request defaults, applied before user input.",
"properties": Value::Object(properties),
})
}
/// The `prompts` item: a structured input field — data with a declared type,
/// never a command or any other executable payload (C-004).
fn template_prompt_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"key": { "type": "string", "description": "The normal create field this prompt answers. A key the create model does not know is refused." },
"label": { "type": "string", "description": "Display label; falls back to `key`." },
"kind": { "type": "string", "enum": ["text", "select", "multi-select", "date"], "description": "Input type — a type, never a behavior." },
"required": { "type": "boolean", "description": "Whether a value must be present before any issue is written." },
"default": { "type": "string", "description": "Merged before user input, and before the template's own default." },
"options_ref": { "type": "string", "enum": OPTIONS_SOURCES, "description": "An existing registry source for `select`/`multi-select` — never a new one." },
},
"required": ["key", "kind"],
})
}
/// The `references` item: a typed reference into the existing registries and
/// model vocabulary, never a second relationship schema.
fn template_reference_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"kind": { "type": "string", "enum": ["label", "assignee", "file", "cycle", "milestone", "relationship"], "description": "The reference kind." },
"value": { "type": "string", "description": "The referenced value: a label, identity, path, name, or relation kind." },
"required": { "type": "boolean", "description": "Whether absence rejects resolution." },
},
"required": ["kind", "value"],
})
}
/// The descriptors serialized for the `tools/list` result (`FR-002`).
pub fn list() -> Vec<Value> {
registry()
.iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
.collect()
}
/// Required string argument. Absent + `null` → protocol error.
fn req_string(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
value.as_str().map(str::to_string).ok_or(())
}
/// Optional string argument. `null` counts as absent (lenient), any other
/// non-string type is a strict `-32602` protocol error.
fn opt_string(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_str().map(|s| Some(s.to_string())).ok_or(())
}
/// Optional issue selector, accepting a number or a string.
///
/// **Not** [`opt_string`], which is for free text. The schemas for the
/// selector-taking relationship tools declare `{"type": ["string", "integer"]}`
/// and `docs/mcp.md` says so in prose, so an agent following the documented
/// schema and sending `{"issue": 0}` got a JSON-RPC `-32602` while the CLI
/// accepted the same thing — and the other selector-taking tools
/// (`issue_chain`, `issue_blocked_state`) went through [`selector`] and were
/// fine. Two tools, two rules for the same argument, on the same surface.
fn opt_selector(args: &Value, key: &str) -> std::result::Result<Option<String>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
match value {
Value::Null => Ok(None),
Value::String(s) => Ok(Some(s.clone())),
Value::Number(n) => n.as_i64().map(|i| Some(i.to_string())).ok_or(()),
_ => Err(()),
}
}
/// Reject the retired `links` argument loudly, naming the typed replacements.
///
/// WP05 removed this argument because the typed model replaced it, and that
/// removal is the user-visible half of C-001. But `tools::call` only checks
/// registry membership — nothing enforces the `additionalProperties: false` every
/// schema declares — so an integration that used to pass `links` got a
/// **success** response and silently lost the relationships, while
/// `tix create --links 0` failed loudly as an unknown flag.
///
/// A silent drop is the worst of the three available behaviours: the caller
/// believes the relationships were stored, `tix link list` disagrees, and nothing
/// in between says why. Rejecting turns a data-loss bug into a message.
///
/// A targeted check, not general unknown-key rejection. General rejection is the
/// better design and is a separate change — it would alter the contract of every
/// tool at once, which is a scope call and not one to make inside a fix for a
/// silent data loss.
fn reject_retired_links(args: &Value) -> Option<Value> {
if !args.as_object().is_some_and(|o| o.contains_key("links")) {
return None;
}
Some(error_result(
"invalid input: `links` was removed \u{2014} the typed model replaces it. \
create each relationship with `link_issue` (`a`, `b`, `kind`) and remove \
an existing one with `remove_relationship` (`id`). The old argument \
*replaced* the whole set, so replacing it means one `remove_relationship` \
per existing edge plus one `link_issue` per new one"
.to_string(),
))
}
/// Optional array-of-strings argument.
///
/// For an argument that stands for a **repeatable CLI flag**: one array entry is
/// one occurrence, taken verbatim. That is why it is not [`opt_string`] plus
/// [`crate::commands::parse_csv_list`]: the CLI's `--add-project` / `--remove-project`
/// hand `plan_edit_associations` each occurrence exactly as typed, so `" p-a "`
/// and `""` are *references that do not resolve* on the CLI. Trimming or
/// comma-splitting them here would accept a call the CLI refuses, which is a
/// parity defect, not a convenience.
///
/// `null` counts as absent (lenient, like [`opt_string`]); a present non-array or
/// an entry that is not a string is a strict `-32602` protocol error.
fn opt_string_array(args: &Value, key: &str) -> std::result::Result<Option<Vec<String>>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let items = value.as_array().ok_or(())?;
let mut out: Vec<String> = Vec::with_capacity(items.len());
for item in items {
out.push(item.as_str().ok_or(())?.to_string());
}
Ok(Some(out))
}
/// Optional boolean argument with the same strictness as [`opt_string`].
fn opt_bool(args: &Value, key: &str) -> std::result::Result<Option<bool>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_bool().map(Some).ok_or(())
}
/// Issue selector: accepts an integer (`#123`) or a string fragment, matching
/// `parse_selector` precedence (`contracts/mcp-tools.md` §2).
fn selector(args: &Value, key: &str) -> std::result::Result<String, ()> {
let value = args.get(key).ok_or(())?;
match value {
Value::Null => Err(()),
Value::String(s) => Ok(s.clone()),
Value::Number(n) => n.as_i64().map(|i| i.to_string()).ok_or(()),
_ => Err(()),
}
}
// ---------------------------------------------------------------------------
// T021 – structured results + domain-error mapping
// ---------------------------------------------------------------------------
/// FR-014 success shape: `content` mirror + `structuredContent`, `isError: false`.
fn success_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": false,
})
}
/// FR-014 failure shape: `isError: true`, the CLI's exact error string, and
/// **no** `structuredContent` (`contracts/mcp-tools.md` §4).
fn error_result(text: String) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"isError": true,
})
}
/// The issue object used by `create_issue`/`get_issue`/`update_issue`/
/// `move_issue`/`comment_on_issue`: the frontmatter fields plus `filename` and
/// `body` (which the derived serializer skips).
/// WP07: `issue_value` now takes the board's [`CategoryResolution`](crate::workflow::CategoryResolution).
///
/// Every call site already has a `Config` in scope, so threading it is mechanical, and
/// doing it this way means a response can **never** carry a category derived from a
/// different board than the one it was resolved against — the failure mode of a
/// helper that fetched the resolution itself.
fn issue_value(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> Value {
let mut obj = serde_json::to_value(issue).expect("Issue serialization is infallible");
obj["filename"] = Value::String(issue.filename.clone());
obj["body"] = Value::String(issue.body.clone());
// Always present, even when empty (`skip_serializing_if` omits it from the
// serialized issue; a client reading the payload should not have to tell
// "absent" from "none").
obj["related_issues"] = json!(issue.related_issues);
// The issue's resolved category, from the service (T030 step 4, FR-004). The
// **key** as well as the label: a client filtering or grouping needs the stable
// name, and the label is a display string that may be reworded.
//
// `null` for a column the board does not configure — a configuration error the
// client should be able to see, not a silent default. `tix list --show category`
// spells the same case `(unmapped)`, and a JSON field has no room for a marker
// string, so `null` plus the CLI's spelling is the honest pairing.
match crate::workflow::category_of(issue, resolution) {
Some(c) => {
obj["category"] = json!(c.key());
obj["category_label"] = json!(c.label());
}
None => {
obj["category"] = Value::Null;
obj["category_label"] = json!("(unmapped)");
}
}
obj
}
// ---------------------------------------------------------------------------
// T022/T023 – handlers
// ---------------------------------------------------------------------------
fn create_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
if let Some(value) = reject_retired_links(args) {
return Ok(value);
}
// T030. Every create value is read **raw** first, because the template path
// hands the strings to the shared resolver exactly as the CLI does — an
// explicit `""` is an override there, not the MCP-only "must not be empty"
// refusal the no-template path keeps below.
let template = opt_string(args, "template")?;
let template_scope = opt_string(args, "scope")?;
let title = opt_string(args, "title")?;
let body = opt_string(args, "body")?;
let status_raw = opt_string(args, "status")?;
let priority_raw = opt_string(args, "priority")?;
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let files = opt_string(args, "files")?;
let related_issues = opt_string(args, "related_issues")?;
let type_ = opt_string(args, "type")?;
let estimate = opt_string(args, "estimate")?;
let cycles = opt_string(args, "cycles")?;
let milestones = opt_string(args, "milestones")?;
let reviewer = opt_string(args, "reviewer")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
// T028. `""` clears, exactly as `--parent ""` does on the CLI, because both go
// through `commands::create`/`edit::apply` and those own the semantics. Omitted
// is `None`, which is a different thing: "leave it alone", not "set none".
let parent = opt_string(args, "parent")?;
// T031. The project association list, raw: it is resolved through the project
// service *after* the field validation below and *before* the issue exists, so
// an unknown reference refuses the whole create (mirroring `create::run`).
let projects = opt_string(args, "projects")?;
// T033. The epic reference list, raw, exactly as `projects`: resolved through
// the epic service *before* the issue exists, so an unknown reference refuses
// the whole create.
let epics = opt_string(args, "epics")?;
// A call with neither a title nor a template is the same `-32602` it has
// always been. The schema states title-or-template with `anyOf`, but the
// schema is documentation a client may ignore, so the handler holds the line.
if title.is_none() && template.is_none() {
return Err(());
}
// `scope` selects which template to resolve; without one there is nothing to
// scope, exactly as clap's `requires = "template"` refuses `--scope` alone.
if template.is_none() && template_scope.is_some() {
return Ok(template_error_result(
"invalid input: `scope` requires `template` — a scope selects which \
template to resolve and is meaningless without one"
.to_string(),
template_outcome_value(
"create",
"rejected",
None,
vec![json!({
"kind": "invalid-scope",
"field": "scope",
"message": "`scope` requires `template`",
})],
),
));
}
if let Some(template_id) = template {
// T031. `--projects` beside `--template` is refused rather than dropped,
// with the CLI's own words: silently ignoring it would create an issue the
// caller believes is associated (see `commands::dispatch`'s `Create` arm,
// which draws the same line for `tix create`). The predicate and wording
// come from the one shared helper so the two surfaces cannot diverge.
if let Some(error) = crate::commands::projects_beside_template_refused(projects.as_deref())
{
return Ok(error_result(crate::error::render_error(&error)));
}
// T033. `--epics` beside `--template` is refused the same way, with the
// CLI's own words from the one shared helper.
if let Some(error) = crate::commands::epics_beside_template_refused(epics.as_deref()) {
return Ok(error_result(crate::error::render_error(&error)));
}
return create_issue_from_template(
config,
store,
&template_id,
template_scope.as_deref(),
&cmd_templates::CreateFields {
title: title.as_deref(),
body: body.as_deref(),
status: status_raw.as_deref(),
priority: priority_raw.as_deref(),
assignee: assignee.as_deref(),
labels: labels.as_deref(),
files: files.as_deref(),
estimate: estimate.as_deref(),
cycles: cycles.as_deref(),
milestones: milestones.as_deref(),
reviewer: reviewer.as_deref(),
start_date: start_date.as_deref(),
due_date: due_date.as_deref(),
parent: parent.as_deref(),
related_issues: related_issues.as_deref(),
type_: type_.as_deref(),
},
&resolution,
);
}
let status = match status_raw {
None => None,
Some(s) if s.trim().is_empty() => {
return Ok(error_result(
"invalid input: --status must not be empty".into(),
));
}
Some(s) => Some(Status::new(s)),
};
let priority = match priority_raw {
None => None,
Some(p) => match parse_priority(&p) {
Ok(priority) => Some(priority),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
};
// T031. Resolved through the project service **before** the issue is written,
// exactly as `create::run` does, so an unknown reference refuses the whole
// create rather than leaving a half-associated issue. The registry is re-read
// from disk, because a project this session created must be resolvable here.
let project_registry = match projects.as_deref() {
None => None,
Some(raw) => {
let (registry, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::commands::project::resolve_projects(®istry, raw) {
Ok(ids) => Some((registry, ids)),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
};
// T033. The same shape for epics: resolved through the epic service **before**
// the issue is written, against a freshly loaded registry (a name resolves to
// the same epic; duplicates collapse to one reference).
let epic_registry = match epics.as_deref() {
None => None,
Some(raw) => {
let (registry, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match cmd_epic::resolve_epics(®istry, raw) {
Ok(ids) => Some((registry, ids)),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
};
match create::create(
config,
store,
// Guaranteed: the guard above refuses the only path that could arrive
// here without a title.
title.as_deref().expect("a title or a template is required"),
body.as_deref(),
status,
priority,
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
parent.as_deref(),
related_issues.as_deref(),
type_.as_deref(),
) {
Ok(issue) => {
let mut issue = issue;
if let Some((registry, ids)) = &project_registry {
if !ids.is_empty() {
// Each accepted association appends exactly one `## History`
// entry through the store's service-backed write; the create
// core stays free of project rules (T022 step 4).
let association = store_with_config(store, registry.clone());
if let Err(e) =
crate::commands::project::associate_created(&association, &issue, ids)
{
return Ok(error_result(crate::error::render_error(&e)));
}
// Re-read so the response carries the associations just written.
issue = match association.resolve_issue(&issue.number.to_string()) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
}
}
// T033. Each accepted reference appends exactly one `## History` entry
// through the store's service-backed `reference_epic`; the create core
// stays free of epic rules (T033 step 3).
if let Some((registry, ids)) = &epic_registry {
if !ids.is_empty() {
let association = store_with_config(store, registry.clone());
if let Err(e) = cmd_epic::reference_created(&association, &issue, ids) {
return Ok(error_result(crate::error::render_error(&e)));
}
issue = match association.resolve_issue(&issue.number.to_string()) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
}
}
Ok(success_result(
format!("Created {}", issue.filename),
issue_value(&issue, &resolution),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
/// `create_issue` with a template selected (T030).
///
/// The resolver is the CLI's own (`commands::templates::selection_for` +
/// `crate::templates::resolve`): it merges the template's defaults and prompts
/// with these fields, validates required prompts and typed references, and
/// returns one normal create request — or **every** diagnostic and no request.
/// A failed resolution writes nothing: no issue, no template change. The request
/// then goes through `commands::templates::apply_request`, the exact call
/// `tix create --template` makes, so both the created issue and the failure text
/// are the CLI's (C-007/NFR-002).
fn create_issue_from_template(
config: &Config,
store: &Store,
template_id: &str,
scope: Option<&str>,
fields: &cmd_templates::CreateFields<'_>,
resolution: &crate::workflow::CategoryResolution,
) -> ToolOutcome {
// Resolve against a freshly loaded config, not the one captured at server
// start: a template written by `create_template` earlier in the same MCP
// session must be visible here. The management handlers already re-load per
// call; without this, `create_issue` would contradict `list_templates`.
// Falls back to the session config if the reload fails.
let fresh = template_management_config().ok().map(|(config, _)| config);
let config: &Config = fresh.as_ref().unwrap_or(config);
let selection = match cmd_templates::selection_for(template_id, scope) {
Ok(selection) => selection,
Err(error) => {
return Ok(template_error_result(
crate::error::render_error(&error),
template_outcome_value(
"create",
"rejected",
None,
vec![json!({
"kind": "invalid-scope",
"field": "scope",
"message": error.to_string(),
})],
),
));
}
};
let resolved = resolve(config, &selection, &fields.to_input());
if !resolved.is_ok() {
let diagnostics: Vec<Value> = resolved
.diagnostics()
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind().as_str(),
"field": diagnostic.field(),
"message": diagnostic.message(),
})
})
.collect();
return Ok(template_error_result(
crate::error::render_error(&cmd_templates::resolution_error(&resolved)),
template_outcome_value("create", "rejected", None, diagnostics),
));
}
let template_id = resolved.template_id().to_string();
let scope = resolved.scope().to_string();
let request = resolved
.into_request()
.expect("a successful resolution always carries a request");
match cmd_templates::apply_request(config, store, &request) {
Ok(issue) => {
let mut value = issue_value(&issue, resolution);
// Provenance, not a second issue schema: the stable ID and the
// explicit scope the caller's creation used (data-model §7).
value["template"] = json!({ "id": template_id, "scope": scope });
Ok(success_result(format!("Created {}", issue.filename), value))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn list_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let status = opt_string(args, "status")?;
let project = opt_string(args, "project")?;
let epic = opt_string(args, "epic")?;
let filter = opt_string(args, "filter")?;
let sort = opt_string(args, "sort")?;
let group = opt_string(args, "group")?;
let all = opt_bool(args, "all")?.unwrap_or(false);
// MCP carries no archive flag (out of scope for issue #1492), so it lists the
// active set: archived issues are skipped without being read.
//
// The default scope comes from the one seam the CLI reads (#1711), so
// `list_issues {}` and `tix list` are the same listing — scoped to your issues
// in the board's entry column — and `all` is the escape hatch on both. A
// `status`/`filter`/`project`/`epic` argument names a selection of its own and
// replaces the scope rather than composing with it (#1711).
match list::scoped_query(
store,
&list::ListSelection {
status: status.as_deref(),
filter: filter.as_deref(),
project: project.as_deref(),
epic: epic.as_deref(),
all,
},
false,
) {
Ok(issues) => {
let mut issues = issues;
// WP07 / T034: the project filter is a **bucket lookup** on the
// service's derived membership index, not a second membership rule
// (NFR-001, C-006). `tix list --project` reaches the same set once the
// list surface grows the flag; until then this is the MCP spelling of
// contract `project-service.md` §5's "filter by project" row, and
// `project_membership` returns the identical set — both read the
// **active** set and both resolve against the registry on disk.
//
// Resolved against a freshly loaded registry, not `store.config`: the
// server's snapshot was taken when `mcp::serve` bootstrapped, so a
// project this session created would read as `unknown_project` (and one
// it deleted would still resolve) while every other project handler
// re-read the file. Two snapshots for one registry is exactly the
// disagreement this filter is documented not to have.
if let Some(reference) = project.as_deref() {
let registry = match registry_config(store) {
Ok((registry, _path)) => registry,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::projects::membership(®istry, &issues, reference) {
Ok(membership) => {
let members: BTreeSet<u32> = membership
.members
.iter()
.map(|m| m.issue.number())
.collect();
issues.retain(|issue| members.contains(&issue.number));
}
Err(error) => {
return Ok(error_result(crate::error::render_error(
&crate::error::TixError::from(error),
)));
}
}
}
// T033/T036: the epic filter is the same shape over the epic service's
// derived membership — a bucket lookup, not a second rule — resolved
// against a freshly loaded registry for the same reason the project
// filter is, and returning the identical set `epic_membership` reads.
if let Some(reference) = epic.as_deref() {
let registry = match registry_config(store) {
Ok((registry, _path)) => registry,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::epics::membership(®istry, &issues, reference) {
Ok(membership) => {
let members: BTreeSet<u32> = membership
.members
.iter()
.map(|m| m.issue.number())
.collect();
issues.retain(|issue| members.contains(&issue.number));
}
Err(error) => {
return Ok(error_result(crate::error::render_error(
&crate::error::TixError::from(error),
)));
}
}
}
let sort_key = sort.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "sort"))
});
let group_key = group.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "group"))
});
sort_issues(&mut issues, sort_key.as_deref());
let values: Vec<Value> = issues.iter().map(|i| issue_value(i, &resolution)).collect();
// WP04 T019: the service's resolution, so MCP's text mirror renders
// `Overdue` and the group rollup through the same categories the CLI
// does. This call passed a completion column *name* while the CLI
// passed categories — the surfaces agreed only because both happened
// to resolve the same board, which is a coincidence, not a contract.
let (_, resolution) = crate::workflow::active_resolution(&store.config);
Ok(success_result(
list_text(&issues, group_key.as_deref(), &resolution),
json!({ "issues": values }),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
// WP07: takes a `Config` for the category resolution. It had none, because until
// now it returned only frontmatter — which is a reason the category belongs on the
// *issue object* rather than bolted onto one handler, so that `list_issues`,
// `search_issues` and every write result carry it too.
fn get_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
match view::get(store, &raw) {
Ok(issue) => {
// The **same** hierarchy block `tix view` appends, from the same
// `HierarchyFacts`. WP04 made `tix show` carry the block
// (`contracts/cli-surface.md` §2), and the parity test
// `parity_get_matches_cli_view` immediately reported the two surfaces
// disagreeing — which is what it exists for.
//
// WP07 owns the rest of the hierarchy read tools (`parent show`,
// `children`, `list`) and the `parent` field on `update_issue`. Appending
// the block here is not that work: it is the smallest change that keeps
// an existing parity guarantee true after a documented CLI change, and
// it reuses the one renderer so the two cannot drift again.
let mut contents = issue.to_file_contents();
match crate::commands::parent::source(store) {
Ok((index, issues, done_column)) => {
let facts = crate::commands::parent::facts_for(
&index,
&issues,
issue.number,
&done_column,
);
contents.push_str(&crate::commands::parent::detail_block(&facts));
}
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
// The `## Workflow` block, for the same reason and by the same
// mechanism as the hierarchy block above: `tix view` appends it
// unconditionally (WP08 T036), so a client reading this text would
// otherwise see a *shorter* document than the CLI prints. The parity
// test `parity_get_matches_cli_view` reported the divergence the moment
// the block was added on the CLI side — which is what it is for.
//
// The category is *also* on the structured object (`issue_value`, WP07),
// but that is a different channel: the contract requires the text to
// mirror the CLI's stdout, and a field that only one surface carries is
// exactly the drift this keeps closing.
contents.push_str(&crate::commands::view::category_block(&issue, &resolution));
// The `## Projects` block, for the same reason and by the same
// mechanism as the two above: `tix view` appends it **unconditionally**
// (`contracts/cli-surface.md` §4, `src/commands/view.rs`), so without it
// an associated issue's document would be *shorter* than the CLI's and
// `parity_get_matches_cli_view` would be asserting a difference. The
// renderer is the CLI's own `projects_block`, and the registry is
// re-read per call so a project this session created renders its health
// line rather than `(missing reference)`.
{
let registry = match registry_config(store) {
Ok((registry, _path)) => registry,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::commands::view::projects_block(
&store_with_config(store, registry),
&issue,
) {
Ok(block) => contents.push_str(&block),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
// The `## Epics` block, for the same reason and by the same mechanism as
// the three above: `tix view` appends it **unconditionally**
// (`contracts/cli-surface.md` §3, `src/commands/view.rs`), so without it
// an issue that references an epic would render a *shorter* document
// than the CLI's and `parity_get_matches_cli_view` would be asserting a
// difference. The renderer is the CLI's own `epics_block`, and the
// registry is re-read per call so an epic this session created renders
// its figures rather than `(missing reference)`.
{
let registry = match registry_config(store) {
Ok((registry, _path)) => registry,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::commands::view::epics_block(
&store_with_config(store, registry),
&issue,
) {
Ok(block) => contents.push_str(&block),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
// #1301 (F9): expose the relationships through the **service**, not the
// issue's own stored `relationships` field. A typed edge is stored once,
// in its canonical source, so the other endpoint's own field is empty and
// a client reading it would see no relationship at all. The derived set is
// a superset of the stored one (it includes this file's own edges), so
// nothing is hidden by replacing the key — and it is what
// `tix view --show relationships` and `list_relationships` already report.
let mut value = issue_value(&issue, &resolution);
match with_graph(store, |graph| {
Ok(graph
.relationships_for(issue.number)
.into_iter()
.map(relationship_value)
.collect::<Vec<Value>>())
}) {
Ok(relationships) => value["relationships"] = json!(relationships),
Err(result) => return Ok(result),
}
Ok(success_result(contents, value))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn board_status(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let filter = opt_string(args, "filter")?;
let health = opt_bool(args, "health")?.unwrap_or(false);
let sort = opt_string(args, "sort")?;
let group = opt_string(args, "group")?;
match store.list() {
Ok(mut issues) => {
if let Some(filter) = filter.as_deref() {
// Same two steps as `tix status --filter` (WP08 T035), because this
// tool is required to mirror the CLI's counts. Going through
// `query_matches` would report **unfiltered** counts for a filter
// the client believes is applied — a summary that looks like an
// answer. A resolution failure is returned as a tool error result
// rather than with `?`, so a rejected filter is a refusal the client
// asked for and not a malfunction of the server (the same
// distinction `move_issue` draws).
let (filter, criteria) = match crate::search::resolve_filter_criteria(store, filter)
{
Ok(pair) => pair,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let (_, resolution) = crate::workflow::active_resolution(config);
issues.retain(|i| {
crate::search::matches_issue(
&filter,
&criteria,
i,
&i.to_file_contents(),
&resolution,
)
});
}
let sort_key = sort.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "sort"))
});
let group_key = group.clone().or_else(|| {
filter
.as_deref()
.and_then(|q| crate::search::control_token(q, "group"))
});
sort_issues(&mut issues, sort_key.as_deref());
// WP04: the service's own counting, not a second implementation of it.
let summary = status::board_for_issues(config, &issues);
let columns: Vec<Value> = summary
.columns
.iter()
.map(|(column, count)| json!({ "column": column, "count": count }))
.collect();
// WP04: the same `show_controls` / `group_key` decisions the CLI makes,
// resolved from the same control tokens, so the two renderers cannot be
// handed different inputs.
let show_controls = filter.is_some() || sort_key.is_some() || group_key.is_some();
// Mission `project-issue-health-status-01M3C8HX` (WP07): the opt-in health
// summary. Opt-in so the default response stays byte-identical to the CLI's
// board (NFR-005), and answered from the same document `tix health board`
// renders when it is asked for.
let health_summary = if health {
match crate::commands::export::run_health_core(config) {
Ok(document) => Some(crate::commands::health::HealthSummary::of(&document)),
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
}
} else {
None
};
let mut text = status_text(
config,
store,
&issues,
&summary,
show_controls,
group_key.as_deref(),
);
let mut structured = json!({ "columns": columns, "total": summary.total });
if let Some(health) = &health_summary {
text.push_str(&health.render());
structured["health"] = json!({
"policy_version": health.policy_version,
"issues": health.issues,
"derived": health.derived,
"diagnostics": health.diagnostics,
"states": health.by_state,
});
}
Ok(success_result(text, structured))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn update_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
if let Some(value) = reject_retired_links(args) {
return Ok(value);
}
let raw = selector(args, "issue")?;
let title = opt_string(args, "title")?;
let body = opt_string(args, "body")?;
let status = opt_string(args, "status")?;
// WP07: read here so the refusal surfaces as an MCP error result rather than a
// `?`. `?` would return a tool *error*, which the protocol treats as a
// malfunction of the server rather than a refusal the client asked for — and
// `move_issue` returns a refusal as `isError: true` with the diagnostic as its
// text, so the two tools would disagree about what a refusal looks like.
let reason = opt_string(args, "reason")?;
let priority = opt_string(args, "priority")?;
let assignee = opt_string(args, "assignee")?;
let labels = opt_string(args, "labels")?;
let files = opt_string(args, "files")?;
let related_issues = opt_string(args, "related_issues")?;
let type_ = opt_string(args, "type")?;
let estimate = opt_string(args, "estimate")?;
let cycles = opt_string(args, "cycles")?;
let milestones = opt_string(args, "milestones")?;
let reviewer = opt_string(args, "reviewer")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
// T028. `""` clears, exactly as `--parent ""` does on the CLI, because both go
// through `commands::create`/`edit::apply` and those own the semantics. Omitted
// is `None`, which is a different thing: "leave it alone", not "set none".
let parent = opt_string(args, "parent")?;
let commit = opt_string(args, "commit")?;
// T031. The project association inputs, with `tix edit`'s exact semantics.
// `projects` **only clears** — a blank value drops every association, and a
// non-blank value is refused rather than guessed at, so setting the set has
// exactly one spelling (`add_project`). `projects` is comma-separated (the
// `--projects` shape, parsed by the CLI's own `resolve_projects`), while
// `add_project` / `remove_project` are **arrays whose entries stand for
// occurrences of the CLI's repeatable flags**: each entry reaches
// `plan_edit_associations` verbatim, so a padded or blank entry is the
// reference the CLI would also refuse (`--add-project " p-a "`, `--add-project ""`)
// rather than one this surface silently cleans up. A project is named by its
// **stable id**; a name resolves to the same project.
let projects = opt_string(args, "projects")?;
let add_project = opt_string_array(args, "add_project")?;
let remove_project = opt_string_array(args, "remove_project")?;
// T033. The epic-reference inputs, with `tix edit`'s exact semantics and the
// same shapes as the project ones above: `epics` **only clears**, `add_epic` /
// `remove_epic` are **arrays whose entries stand for occurrences** of the CLI's
// repeatable flags, each entry taken verbatim, and an epic is named by its
// **stable id** (a name resolves to the same epic).
let epics = opt_string(args, "epics")?;
let add_epic = opt_string_array(args, "add_epic")?;
let remove_epic = opt_string_array(args, "remove_epic")?;
let clear_projects = match projects.as_deref() {
None => false,
Some(value) if crate::commands::edit_projects_clears(value) => true,
Some(value) => {
// The CLI's own words, so the refusal reads identically on both
// surfaces (`commands::edit::run` raises this exact message).
return Ok(error_result(crate::error::render_error(
&crate::commands::edit_projects_must_be_clear(value),
)));
}
};
let add_refs: Vec<String> = add_project.unwrap_or_default();
let remove_refs: Vec<String> = remove_project.unwrap_or_default();
let has_projects = clear_projects || !add_refs.is_empty() || !remove_refs.is_empty();
// `--epics` mirrors `--projects`: it exists to **clear**, and a non-blank value
// is refused with the CLI's own words.
let clear_epics = match epics.as_deref() {
None => false,
Some(value) if crate::commands::edit_epics_clears(value) => true,
Some(value) => {
return Ok(error_result(crate::error::render_error(
&crate::commands::edit_epics_must_be_clear(value),
)));
}
};
let add_epic_refs: Vec<String> = add_epic.unwrap_or_default();
let remove_epic_refs: Vec<String> = remove_epic.unwrap_or_default();
let has_epics = clear_epics || !add_epic_refs.is_empty() || !remove_epic_refs.is_empty();
// One list, two uses: the emptiness test and the sentence that names what the
// caller may pass. They were separate hand-maintained enumerations, and a
// field added to one and not the other is invisible — a caller told
// "pass at least one of ..." would not learn that a field they just added
// works, and the empty-update guard would reject a valid single-field edit.
// That is not hypothetical: `parent` was reachable in the schema and silently
// absent from both, so `update_issue {parent: "3"}` returned "no fields to
// update" for a field the tool advertised.
// `reason` is deliberately **absent**. It qualifies a `status` transition; it is
// not an editable field, so `update_issue {issue: "1", reason: "because"}` must
// still be "no fields to update" rather than silently succeeding. Adding it
// would also mean a reason alone could satisfy the guard and then be discarded.
const UPDATABLE: [&str; 17] = [
"title",
"body",
"status",
"priority",
"assignee",
"labels",
"type",
"files",
"related_issues",
"estimate",
"cycles",
"milestones",
"reviewer",
"start_date",
"due_date",
"parent",
"commit",
];
// `null` counts as absent, matching `opt_string`, which reads an explicit
// `null` as `None`. Testing only for `is_none()` would let `{"parent": null}`
// past the guard while every field still read as `None` — the call would fall
// through to `edit::apply` with nothing to do instead of being refused here.
let is_absent = |field: &str| match args.get(field) {
None | Some(Value::Null) => true,
Some(_) => false,
};
if UPDATABLE.iter().all(|field| is_absent(field)) && !has_projects && !has_epics {
return Ok(error_result(format!(
"invalid input: no fields to update — pass at least one of {}, or a project \
association argument (projects, add_project, remove_project), or an epic \
reference argument (epics, add_epic, remove_epic)",
UPDATABLE.join(", ")
)));
}
// T031. An association edit is validated **before** the field write, exactly as
// `edit::run` does, so `add_project: "bogus"` refuses the whole update and
// leaves every file byte-identical rather than applying the other fields and
// then failing. The registry and the issue file are re-read from disk, because
// a project this session created must be resolvable here, and the issue is
// addressed by **number** so the mutations keep working after an `--assignee`
// reassign moves the file.
let has_fields = UPDATABLE.iter().any(|field| !is_absent(field));
// One freshly-loaded registry for **both** association kinds, so a project or
// epic this session created is resolvable here and the response can be read
// back from the same store the writes went through. Addressed by **number**, so
// the mutations keep working after an `--assignee` reassign moves the file.
let association_registry = if has_projects || has_epics {
let (registry, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
Some(store_with_config(store, registry))
} else {
None
};
let association = if has_projects {
let registry = association_registry
.as_ref()
.expect("built when a project argument is present");
let selector = match registry.resolve_issue(&raw) {
Ok(issue) => issue.number.to_string(),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::commands::project::plan_edit_associations(
®istry.config,
registry,
&selector,
&add_refs,
&remove_refs,
clear_projects,
) {
Ok(plan) => Some((selector, plan)),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
} else {
None
};
// The epic inputs are planned **before** any write too, so `add_epic: ["bogus"]`
// refuses the whole update and leaves every file byte-identical (T033 step 5).
// The plan is the service's: resolution, uniqueness and the stale-id repair
// path all live in `crate::commands::epic` / `crate::epics`.
let epic_association = if has_epics {
let registry = association_registry
.as_ref()
.expect("built when an epic argument is present");
let selector = match registry.resolve_issue(&raw) {
Ok(issue) => issue.number.to_string(),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match cmd_epic::plan_edit_epics(
®istry.config,
registry,
&selector,
&add_epic_refs,
&remove_epic_refs,
clear_epics,
) {
Ok(plan) => Some((selector, plan)),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
} else {
None
};
// The field write, through the same core the CLI calls. An association-only
// update has no fields to write, so it reads the issue instead — which is what
// `edit::run` does, and what keeps a no-op association edit from bumping
// `updated`.
let issue = if has_fields {
match edit::apply(
config,
store,
&raw,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
// `commit` **then** `parent` — `edit::apply`'s order. Passing them the
// other way round compiles, and the compiler cannot help: both are
// `Option<&str>`. It put the parent selector into the `commit` slot and
// persisted it as a commit hash (`commit: '99'` in the issue file), so
// the parent was silently never set and no validation ran. Found by
// driving the real binary over JSON-RPC and reading the file it wrote,
// not by the type checker and not by any existing test.
commit.as_deref(),
parent.as_deref(),
// WP07: the `reason` parameter, closing the gap WP04 recorded. A client can
// now leave a terminal column through `update_issue` exactly as it can
// through `move_issue` and the CLI — the same escape on every surface, and
// the reason recorded in the same `TransitionDecision`.
//
// Passed straight through, like `move_issue`'s: no MCP-side validation and
// no default. A permissive default here would hand MCP a silent override
// nobody recorded, which the contract calls an escalation trigger.
reason.as_deref(),
related_issues.as_deref(),
type_.as_deref(),
) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
} else {
match store.resolve_issue(&raw) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
};
// Every association mutation goes through the store's service-backed write, so
// `updated` and the one History entry are handled in the single place that owns
// both. Order is clear → remove → add, exactly as `edit::run` applies it, and
// the project write precedes the epic one as it does there.
let project_changed = match (&association, association_registry.as_ref()) {
(Some((selector, plan)), Some(registry)) => {
match crate::commands::project::apply_edit_associations(registry, selector, plan) {
Ok(changed) => changed,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
_ => false,
};
let epics_changed = match (&epic_association, association_registry.as_ref()) {
(Some((selector, plan)), Some(registry)) => {
match cmd_epic::apply_edit_epics(registry, selector, plan) {
Ok(changed) => changed,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
}
}
_ => false,
};
let associations_changed = project_changed || epics_changed;
// The response carries the final state, associations included.
let issue = match (&association, association_registry.as_ref()) {
(Some((selector, _)), Some(registry)) => match registry.resolve_issue(selector) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
_ => match (&epic_association, association_registry.as_ref()) {
(Some((selector, _)), Some(registry)) => match registry.resolve_issue(selector) {
Ok(issue) => issue,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
_ => issue,
},
};
// The success line must not claim a write that never happened: an
// association-only edit that changed nothing writes nothing and bumps no date,
// so it says so (the CLI's own wording for the same case).
let text = if has_fields || associations_changed {
format!("Updated {}", issue.filename)
} else {
format!("Updated {} (no association change)", issue.filename)
};
Ok(success_result(text, issue_value(&issue, &resolution)))
}
// ---------------------------------------------------------------------------
// WP07 / T031–T034: the project tools, and WP07 / T033–T036: the epic tools
// ---------------------------------------------------------------------------
//
// Every handler below reaches behaviour through `crate::projects` or
// `crate::epics` (or the store's service-backed association/reference write):
// identity, resolution, uniqueness, membership, hierarchy, progress and health
// all have exactly one owner, and a rule or a figure appearing in this layer
// would be the second implementation C-008 prohibits. The registry is re-read
// from disk per call, exactly as `template_management_config` does it, so a
// project or epic this session created is visible to the next read.
/// The registry and the `tix.toml` path a project or epic mutation writes.
///
/// Re-read per call: the server holds the `Config` it bootstrapped with, so a
/// read that used that snapshot would not see a project or epic this session
/// just created — and the CLI, a fresh process each time, always does. The path
/// is found through the store's issue directory (the same upward walk every
/// other command uses) rather than the process cwd, so the file is the one the
/// rest of the session reads.
fn registry_config(store: &Store) -> TixResult<(Config, std::path::PathBuf)> {
let path = crate::commands::views::config_path(store);
let root = path.parent().map_or_else(
|| std::path::PathBuf::from("."),
std::path::Path::to_path_buf,
);
let config = Config::load(&root)?;
Ok((config, path))
}
/// A [`Store`] over the same issue directory but a freshly loaded [`Config`].
///
/// The store's service-backed association and reference writes resolve their
/// project/epic reference through `store.config`, so a mutation has to be applied
/// through a store that already holds the registry on disk — otherwise a project
/// or epic created earlier in the same MCP session would read as unknown to the
/// write.
fn store_with_config(store: &Store, config: Config) -> Store {
Store {
issues_dir: store.issues_dir.clone(),
config,
}
}
/// Persist a registry change through the one atomic-save path.
///
/// `validate` runs first, so a change that would make `tix.toml` unloadable is
/// refused before it is written, and the wording is the CLI's
/// (`commands::project::save` / `commands::epic::save` perform the same two
/// steps).
fn save_registry_config(config: &mut Config, path: &Path) -> TixResult<()> {
config.validate()?;
config.save(path).map_err(|error| {
crate::error::TixError::Config(format!("could not save `{}`: {error}", path.display()))
})
}
/// A trimmed, non-blank optional string, or `None` — the CLI's `trim_optional`.
fn trimmed_optional(value: Option<String>) -> Option<String> {
value
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
}
/// A blank value clears an optional text field; a written one sets it.
fn text_patch(value: Option<String>) -> crate::projects::Patch<String> {
use crate::projects::Patch;
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text),
}
}
/// A blank value clears a date field; a written one is passed through trimmed.
fn date_patch(value: Option<String>) -> crate::projects::Patch<String> {
use crate::projects::Patch;
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.trim().to_string()),
}
}
/// One derived member as JSON: the issue number and the shared display label.
fn member_value(member: &crate::projects::MemberRef) -> Value {
json!({ "issue": member.issue.number(), "display": member.display })
}
/// One dangling association as JSON, exactly as the service reports it (C-005).
fn missing_reference_value(missing: &crate::projects::MissingProjectReference) -> Value {
json!({
"issue": missing.issue.number(),
"issue_display": missing.issue_display,
"project_id": missing.project_id,
"reason": missing.reason,
})
}
/// A project's registry record as JSON — raw values, no derived figure.
fn project_record_value(project: &crate::projects::Project) -> Value {
json!({
"id": project.id.as_str(),
"name": project.name,
"description": project.description,
"status": project.status,
"lead": project.lead,
"start_date": project.start_date,
"due_date": project.due_date,
"member_count": project.member_count,
})
}
/// A project's record plus its ordered members, as JSON.
fn project_value(project: &crate::projects::Project) -> Value {
let mut value = project_record_value(project);
value["members"] = json!(
project
.members
.iter()
.map(member_value)
.collect::<Vec<Value>>()
);
value
}
/// The typed progress and health **values**, flattened to the raw figures
/// (`done`, `total`, `overdue`, `blocked`, `unassigned`, `missing_references`).
///
/// No formatted summary and no computed percentage: the response carries exactly
/// what the service computed, so a client — and the parity matrix — can compare
/// it by value (NFR-003).
fn project_health_value(report: &crate::projects::ProjectHealthReport) -> Value {
json!({
"project_id": report.project_id.as_str(),
"name": report.name,
"done": report.progress.done,
"total": report.progress.total,
"overdue": report.health.overdue,
"blocked": report.health.blocked,
"unassigned": report.health.unassigned,
"missing_references": report
.health
.missing_references
.iter()
.map(missing_reference_value)
.collect::<Vec<Value>>(),
})
}
/// The `tix project list` rows: `id<TAB>name<TAB>member count`, one per project.
fn project_rows_text(projects: &[crate::projects::Project]) -> String {
projects
.iter()
.map(|project| format!("{}\t{}\t{}", project.id, project.name, project.member_count))
.collect::<Vec<String>>()
.join("\n")
}
/// The `tix project show` record, figure and member lines, in the CLI's shape.
///
/// Produced by the CLI's own [`crate::commands::project::record_lines`], so the two
/// surfaces cannot describe one record two ways — the same reason every other
/// shared renderer exists. `report` is the service's typed value for the project,
/// read once by the caller (C-006).
fn project_record_text(
project: &crate::projects::Project,
report: Option<&crate::projects::ProjectHealthReport>,
) -> String {
crate::commands::project::record_lines(project, report).join("\n")
}
/// One project's figures, on one line, for the text mirror.
///
/// `{display} {done}/{total}{suffix}` — the **same** string the CLI's detail block
/// and its `--show progress` column render, through the same
/// [`crate::commands::view::health_suffix`] renderer. One renderer means the two
/// surfaces cannot word the same value differently, and the parity matrix can
/// compare the two channels as text.
fn project_health_text(report: &crate::projects::ProjectHealthReport) -> String {
format!(
"{} {}/{}{}",
crate::projects::project_display(&report.project_id, &report.name),
report.progress.done,
report.progress.total,
crate::commands::view::health_suffix(report),
)
}
/// The delete-time impact notice, in the CLI's own words.
///
/// One line per affected issue; empty when nothing references the project. The
/// notice is what lets a caller warn a user instead of silently orphaning work,
/// and the affected list is returned structurally beside it.
fn deletion_notice(deletion: &crate::projects::ProjectDeletion) -> String {
if deletion.affected.is_empty() {
return String::new();
}
let plural = if deletion.affected.len() == 1 {
"issue"
} else {
"issues"
};
let mut out = format!(
"Note: {} {plural} still reference this project and keep the reference as a \
missing project reference.\nThey are not deleted or modified; `tix lint` reports \
the dangling reference until it is repaired.\n",
deletion.affected.len()
);
for member in &deletion.affected {
out.push_str(&format!(" {}\n", member.display));
}
out
}
/// One bulk association outcome as JSON.
fn association_outcome_value(outcome: &crate::storage::BulkAssociationOutcome) -> Value {
match &outcome.outcome {
Ok(applied) => json!({
"selector": outcome.selector,
"changed": applied.changed,
"project_ids": applied.project_ids,
"written": applied.written.as_ref().map(|issue| issue.filename.clone()),
}),
Err(error) => json!({
"selector": outcome.selector,
"error": crate::error::render_error(error),
}),
}
}
/// A `TixError` from a service refusal, rendered through the one boundary
/// renderer so the text is byte-identical to the CLI's stderr.
fn project_refusal(error: crate::projects::ProjectError) -> Value {
error_result(crate::error::render_error(&crate::error::TixError::from(
error,
)))
}
fn list_projects(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The service's own listing: ids, names and derived member counts, in
// declaration order. No count is computed here.
let projects = crate::projects::list_projects(&config, &issues);
let structured: Vec<Value> = projects.iter().map(project_record_value).collect();
Ok(success_result(
project_rows_text(&projects),
json!({ "projects": structured }),
))
}
fn get_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The figures are the service's typed value, over the **same snapshot** this
// read enumerates (`list_all`, as `tix project show` and `tix project list`
// do), so `get_project` mirrors `tix project show` including its progress and
// health lines rather than dropping them (C-006, `contracts/cli-surface.md` §1).
let registry = store_with_config(store, config);
let reports = match crate::commands::view::project_reports(®istry, &issues) {
Ok((_, reports)) => reports,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::projects::resolve(®istry.config, &issues, &reference) {
Ok(project) => Ok(success_result(
project_record_text(&project, reports.get(&project.id)),
project_value(&project),
)),
Err(error) => Ok(project_refusal(error)),
}
}
fn create_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let name = req_string(args, "name")?;
let description = opt_string(args, "description")?;
let status = opt_string(args, "status")?;
let lead = opt_string(args, "lead")?;
// Dates are trimmed and a blank one is absent, exactly as `tix project create`
// normalizes them; the other optional fields are kept verbatim.
let start_date = trimmed_optional(opt_string(args, "start_date")?);
let due_date = trimmed_optional(opt_string(args, "due_date")?);
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let new = crate::projects::NewProject {
name,
description,
status,
lead,
start_date,
due_date,
};
// The service allocates the stable id and validates name and dates, so an
// invalid request refuses here with the registry untouched.
match crate::projects::create_project(&mut config, &new) {
Ok(project) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
Ok(success_result(
format!(
"Created {}",
crate::projects::project_display(&project.id, &project.name)
),
project_value(&project),
))
}
Err(error) => Ok(project_refusal(error)),
}
}
fn update_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let name = opt_string(args, "name")?;
let description = opt_string(args, "description")?;
let status = opt_string(args, "status")?;
let lead = opt_string(args, "lead")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
// The patch shape `tix project edit` builds: `--name` always sets (a name is
// never cleared, which the service refuses as a duplicate-name diagnostic),
// while the other fields follow the blank-clears convention.
let edit = crate::projects::ProjectEdit {
name: name.map_or(
crate::projects::Patch::Unchanged,
crate::projects::Patch::Set,
),
description: text_patch(description),
status: text_patch(status),
lead: text_patch(lead),
start_date: date_patch(start_date),
due_date: date_patch(due_date),
};
if edit.name.is_unchanged()
&& edit.description.is_unchanged()
&& edit.status.is_unchanged()
&& edit.lead.is_unchanged()
&& edit.start_date.is_unchanged()
&& edit.due_date.is_unchanged()
{
// The CLI's wording for the same refusal, from the one shared helper.
return Ok(error_result(crate::error::render_error(
&crate::commands::project_edit_requires_a_field(),
)));
}
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::projects::edit_project(&mut config, &reference, &edit) {
Ok(project) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
// Re-resolve through an index so the response carries the members: a
// registry-only edit has no issue set to derive from and would report
// `member_count: 0` for a project that has members.
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::projects::resolve(&config, &issues, project.id.as_str()) {
Ok(project) => Ok(success_result(
format!(
"Updated {}",
crate::projects::project_display(&project.id, &project.name)
),
project_value(&project),
)),
Err(error) => Ok(project_refusal(error)),
}
}
Err(error) => Ok(project_refusal(error)),
}
}
fn delete_project(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
// The schema declares `confirm` required, but a schema is documentation a
// client may ignore; checked before anything is resolved, so a forgotten
// confirmation writes nothing (the convention `delete_template` follows).
if opt_bool(args, "confirm")? != Some(true) {
return Ok(error_result(
"invalid input: `confirm` must be true to delete a project — only the registry \
entry is removed and no issue is deleted"
.to_string(),
));
}
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// Removes the registry entry **only**; the service reports every issue that
// still references it, so this response carries the CLI's impact report and
// no issue is ever deleted (C-004, C-005).
match crate::projects::delete_project(&mut config, &reference, &issues) {
Ok(deletion) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
let affected: Vec<Value> = deletion.affected.iter().map(member_value).collect();
let text = format!(
"Deleted project {}\n{}",
crate::projects::project_display(&deletion.id, &deletion.name),
deletion_notice(&deletion),
);
Ok(success_result(
text,
json!({
"id": deletion.id.as_str(),
"name": deletion.name,
"affected": affected,
}),
))
}
Err(error) => Ok(project_refusal(error)),
}
}
fn project_membership(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The **active** set (`list`), which is the snapshot every figure-bearing read
// uses: `tix show`'s project block, `tix list --show progress`, and the
// `list_issues {project}` filter all read the active set, so membership must
// too or the filter and this read would disagree on an archived member. The
// registry verbs (`tix project list` / `show`) are the ones that see every
// issue file, and `list_projects` / `get_project` mirror them.
let issues = match store.list() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The service's derived membership — the same value the CLI reads and the same
// set `list_issues {project}` filters to. Read-only: nothing is written.
match crate::projects::membership(&config, &issues, &reference) {
Ok(membership) => {
let members: Vec<Value> = membership.members.iter().map(member_value).collect();
let text = format!(
"{}: {} member(s)",
membership.project_id, membership.member_count
);
Ok(success_result(
text,
json!({
"project_id": membership.project_id.as_str(),
"member_count": membership.member_count,
"members": members,
}),
))
}
Err(error) => Ok(project_refusal(error)),
}
}
fn project_health(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The **same snapshot the CLI feeds `project_reports`**: `tix show`'s project
// block and `tix list --show progress` both pass `store.list()` — the active
// set. Feeding `list_all()` here would make MCP count an archived member that
// neither CLI channel counts, so `total`/`done`/`unassigned` and the
// `missing_references` set would differ for the same snapshot.
let issues = match store.list() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The CLI's own read path (`commands::view::project_reports`): one index build
// and one health read per project, over the consumed overdue/blocked facts, so
// MCP, the CLI and the TUI read the *same value* for the same snapshot rather
// than three computations that happen to agree (C-006, NFR-003). Read-only:
// nothing is written and no History entry is appended.
let registry = store_with_config(store, config);
match crate::commands::view::project_reports(®istry, &issues) {
Ok((index, reports)) => match index.resolve(&reference) {
Ok(project) => match reports.get(&project.id) {
Some(report) => Ok(success_result(
project_health_text(report),
project_health_value(report),
)),
None => Ok(error_result(format!(
"internal error: the project service returned no health value for `{}`",
project.id
))),
},
Err(error) => Ok(project_refusal(error)),
},
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn project_associate_issues(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
if selectors.is_empty() {
return Ok(error_result(
"invalid input: `issues` must name at least one issue selector".to_string(),
));
}
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let registry = store_with_config(store, config);
// Resolved **once**, up front, so an unknown project refuses before any write
// (mirroring `tix project add`). The id, not the reference, is what the
// service-backed bulk write receives.
let id = match crate::projects::resolve_project_id(®istry.config, &reference) {
Ok(id) => id,
Err(error) => return Ok(project_refusal(error)),
};
match registry.bulk_associate(id.as_str(), &selectors) {
Ok(outcomes) => {
let changed = outcomes
.iter()
.filter(|outcome| outcome.outcome.as_ref().is_ok_and(|a| a.changed))
.count();
let failed = outcomes
.iter()
.filter(|outcome| outcome.outcome.is_err())
.count();
let rows: Vec<Value> = outcomes.iter().map(association_outcome_value).collect();
let changes: Vec<String> = outcomes
.iter()
.map(|outcome| match &outcome.outcome {
Ok(applied) if applied.changed => {
format!("{}: associated", outcome.selector)
}
Ok(_) => format!("{}: already associated", outcome.selector),
Err(error) => {
format!(
"{}: {}",
outcome.selector,
crate::error::render_error(error)
)
}
})
.collect();
let text = format!(
"associated {changed} of {} issue(s) with {id}\n{}",
outcomes.len(),
changes.join("\n")
);
Ok(success_result(
text,
json!({
"project_id": id.as_str(),
"changed": changed,
"failed": failed,
"total": outcomes.len(),
"outcomes": rows,
}),
))
}
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn project_dissociate_issues(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "project")?;
let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
if selectors.is_empty() {
return Ok(error_result(
"invalid input: `issues` must name at least one issue selector".to_string(),
));
}
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let registry = store_with_config(store, config);
// Resolve when the reference names a live project; otherwise use it
// **verbatim**, which is the repair path C-005 promises and exactly what
// `tix project remove` does. The rule that decides is the service's resolver,
// not a second classification here.
let needle = crate::projects::resolve_project_id(®istry.config, &reference)
.map_or_else(|_| reference.clone(), |id| id.to_string());
let mut rows: Vec<Value> = Vec::with_capacity(selectors.len());
let mut changes: Vec<String> = Vec::with_capacity(selectors.len());
let mut changed = 0usize;
let mut failed = 0usize;
for selector in &selectors {
match registry.dissociate(selector, &needle) {
Ok(applied) => {
if applied.changed {
changed += 1;
changes.push(format!("{selector}: dissociated"));
} else {
changes.push(format!("{selector}: was not associated"));
}
rows.push(json!({
"selector": selector,
"changed": applied.changed,
"project_ids": applied.project_ids,
}));
}
Err(error) => {
failed += 1;
changes.push(format!(
"{selector}: {}",
crate::error::render_error(&error)
));
rows.push(
json!({ "selector": selector, "error": crate::error::render_error(&error) }),
);
}
}
}
let text = format!(
"dissociated {changed} of {} issue(s) from {needle}\n{}",
selectors.len(),
changes.join("\n")
);
Ok(success_result(
text,
json!({
"project_id": needle,
"changed": changed,
"failed": failed,
"total": selectors.len(),
"outcomes": rows,
}),
))
}
// ---------------------------------------------------------------------------
// WP07 / T034: the epic handlers
// ---------------------------------------------------------------------------
//
// Every handler reaches behaviour through `crate::epics` (and, for the reference
// writes, the store's service-backed `reference_epic`/`dereference_epic`/
// `bulk_reference`): identity, resolution, hierarchy, membership, progress,
// blocked state and reference diagnostics each have exactly one owner, and a
// rule or a figure appearing here would be the second implementation C-008
// prohibited. `tix epic …` reads every issue file (`list_all`), so these reads do
// too, and the blocked state comes from the relationship service's derived read
// (`graph_source`, the active set) exactly as `commands::epic::show` does.
/// The epic service's error, rendered through the one boundary renderer so the
/// text is byte-identical to the CLI's stderr.
fn epic_refusal(error: crate::epics::EpicError) -> Value {
error_result(crate::error::render_error(&crate::error::TixError::from(
error,
)))
}
/// The refusal `tix epic edit` prints when no field flag was given, in the CLI's
/// own words.
///
/// Duplicated from `commands::epic` because it is private there; a parity row
/// (`t036_an_epic_edit_with_no_fields_is_refused_identically`) pins the two
/// wordings together, which is what makes the duplication safe.
fn epic_edit_requires_a_field() -> String {
crate::error::render_error(&crate::error::TixError::InvalidInput(
"no fields to change; pass at least one of --name, --description, --status, \
--owner, --start-date, --due-date, --weight, --parent, --projects, \
--milestone or --cycle"
.to_string(),
))
}
/// The one wording for a non-integer epic weight, naming the service's kind.
///
/// The **positivity** rule stays the service's; this is only the argument-level
/// parse refusal, worded with the same `invalid_weight` kind so one problem has
/// one name and MCP's text equals the CLI's.
fn epic_weight_parse_error(text: &str) -> String {
crate::error::render_error(&crate::error::TixError::InvalidInput(format!(
"{}: epic weight must be an integer; got `{text}`",
crate::epics::EpicDiagnosticKind::InvalidWeight
)))
}
/// A create-time epic weight: an integer, a decimal string, or nothing.
///
/// `""` is unset, matching `tix epic create --weight ""`. A malformed string is
/// the CLI's own refusal; no value is coerced.
fn epic_create_weight(args: &Value, key: &str) -> ToolOutcome2<Option<i64>> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => match number.as_i64() {
Some(value) => Ok(Some(value)),
None => Err(refuse(epic_weight_parse_error(&number.to_string()))),
},
Some(Value::String(text)) if text.trim().is_empty() => Ok(None),
Some(Value::String(text)) => match text.trim().parse::<i64>() {
Ok(value) => Ok(Some(value)),
Err(_) => Err(refuse(epic_weight_parse_error(text))),
},
Some(_) => Err(proto()),
}
}
/// An epic-weight patch: absent keeps it, a blank value clears it, a written
/// value is parsed. Mirrors `commands::epic::weight_patch`.
fn epic_weight_patch(args: &Value, key: &str) -> ToolOutcome2<crate::epics::Patch<i64>> {
use crate::epics::Patch;
match args.get(key) {
None | Some(Value::Null) => Ok(Patch::Unchanged),
Some(Value::Number(number)) => number
.as_i64()
.map(Patch::Set)
.ok_or_else(|| refuse(epic_weight_parse_error(&number.to_string()))),
Some(Value::String(text)) if text.trim().is_empty() => Ok(Patch::Clear),
Some(Value::String(text)) => text
.trim()
.parse::<i64>()
.map(Patch::Set)
.map_err(|_| refuse(epic_weight_parse_error(text))),
Some(_) => Err(proto()),
}
}
/// One derived member as JSON: the issue number and the shared display label.
fn epic_member_value(member: &crate::epics::MemberRef) -> Value {
json!({ "issue": member.issue.number(), "display": member.display })
}
/// One derived epic reference as JSON: the stable id, the name and the display.
fn epic_ref_value(reference: &crate::epics::EpicRef) -> Value {
json!({
"id": reference.id.as_str(),
"name": reference.name,
"display": reference.display,
})
}
/// One unresolvable reference as JSON, exactly as the service reports it (C-005).
///
/// `kind` is the reference kind and `diagnostic_kind` is the `tix lint` name for
/// it, so MCP, lint and the CLI name one condition with one spelling.
fn epic_missing_value(missing: &crate::epics::EpicReferenceResolution) -> Value {
json!({
"holder": missing.holder.to_string(),
"kind": missing.target_kind.as_str(),
"diagnostic_kind": missing.target_kind.diagnostic_kind(),
"target": missing.target,
"reason": missing.reason,
})
}
/// An epic's registry record plus its derived children and members, as JSON.
///
/// Raw values only: no formatted figure and no computed percentage beyond what
/// the service already carries.
fn epic_record_value(epic: &crate::epics::Epic) -> Value {
json!({
"id": epic.id.as_str(),
"name": epic.name,
"description": epic.description,
"status": epic.status,
"owner": epic.owner,
"start_date": epic.start_date,
"due_date": epic.due_date,
"weight": epic.weight,
"parent": epic.parent,
"projects": epic.projects,
"milestone": epic.milestone,
"cycle": epic.cycle,
"member_count": epic.member_count,
"child_count": epic.child_count,
"children": epic.children.iter().map(epic_ref_value).collect::<Vec<Value>>(),
"members": epic.members.iter().map(epic_member_value).collect::<Vec<Value>>(),
})
}
/// The typed weight-aware figure, flattened to the service's raw fields.
fn weighted_progress_value(weighted: &crate::epics::WeightedProgress) -> Value {
json!({
"own_weight": weighted.own_weight,
"weighted_children": weighted.weighted_children,
"unweighted_children": weighted.unweighted_children,
"done_weight": weighted.done_weight,
"total_weight": weighted.total_weight,
"percent": weighted.percent,
})
}
/// The typed rollup **values**, flattened to the raw figures the contract names.
///
/// No formatted summary: the response carries exactly what the service computed,
/// so a client — and the parity matrix — can compare it by value (NFR-003).
fn epic_rollup_value(rollup: &crate::epics::EpicRollup) -> Value {
json!({
"epic_id": rollup.epic_id.as_str(),
"name": rollup.name,
"done": rollup.progress.done,
"total": rollup.progress.total,
"progress": { "done": rollup.progress.done, "total": rollup.progress.total },
"child_count": rollup.child_count,
"weighted_progress": weighted_progress_value(&rollup.weighted_progress),
"blocked": rollup.blocked,
"blocked_members": rollup
.blocked_members
.iter()
.map(|member| json!({
"issue": member.issue.number(),
"display": member.display,
"reason": member.reason,
}))
.collect::<Vec<Value>>(),
"missing_references": rollup
.missing_references
.iter()
.map(epic_missing_value)
.collect::<Vec<Value>>(),
})
}
/// The derived hierarchy as JSON: the raw parent/children/ancestors/depth and
/// the explicit unresolved record, in the service's order.
fn epic_hierarchy_value(hierarchy: &crate::epics::EpicHierarchy) -> Value {
json!({
"epic": hierarchy.epic.as_str(),
"recorded_parent": hierarchy.recorded_parent,
"parent": hierarchy.parent.as_ref().map(epic_ref_value),
"children": hierarchy.children.iter().map(epic_ref_value).collect::<Vec<Value>>(),
"child_count": hierarchy.child_count,
"ancestors": hierarchy.ancestors.iter().map(epic_ref_value).collect::<Vec<Value>>(),
"depth": hierarchy.depth,
"unresolved": hierarchy.unresolved.as_ref().map(|unresolved| json!({
"epic": unresolved.epic.as_str(),
"recorded_parent": unresolved.recorded_parent,
"reason": unresolved.reason,
})),
})
}
/// One epic's figures on one line, for the text mirror of `epic_health`.
fn epic_health_text(rollup: &crate::epics::EpicRollup) -> String {
format!(
"{} {}/{} done, blocked {}, missing-references {}",
crate::epics::epic_display(&rollup.epic_id, &rollup.name),
rollup.progress.done,
rollup.progress.total,
rollup.blocked,
rollup.missing_references.len(),
)
}
/// The `ReferenceTargets` for one snapshot, consumed from their owners.
///
/// Mirrors `commands::epic::reference_targets` (private there): the milestone and
/// cycle names come from the existing planner lookups and the project ids from
/// the project registry — no second milestone, cycle, or project store.
fn epic_reference_targets(
config: &Config,
store: &Store,
) -> TixResult<crate::epics::ReferenceTargets> {
let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
let planner = crate::planning::Planner::from_config(config.clone(), root);
crate::epics::ReferenceTargets::from_planner(&planner)
}
/// One bulk reference/dereference outcome as JSON.
fn epic_reference_outcome_value(outcome: &crate::storage::BulkReferenceOutcome) -> Value {
match &outcome.outcome {
Ok(applied) => json!({
"selector": outcome.selector,
"changed": applied.changed,
"epic_ids": applied.epic_ids,
"written": applied.written.as_ref().map(|issue| issue.filename.clone()),
}),
Err(error) => json!({
"selector": outcome.selector,
"error": crate::error::render_error(error),
}),
}
}
fn list_epics(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The service's own listing: ids, names and the derived member and child
// counts, in declaration order. No count is computed here.
let index = crate::epics::EpicIndex::build(&config, &issues);
let structured: Vec<Value> = index.epics().into_iter().map(epic_record_value).collect();
// The snapshot's **issue-held** unresolvable references (a reference to an
// epic that no longer exists, or an entry that is not a well-formed epic id),
// exactly as `tix lint` reports them (C-005). `epic_health` carries the
// epic-held half for one epic; the two together cover all five reference kinds
// without re-deriving any of them.
let missing: Vec<Value> = index.missing().iter().map(epic_missing_value).collect();
Ok(success_result(
cmd_epic::listing_lines(&config, &issues).join("\n"),
json!({ "epics": structured, "missing_references": missing }),
))
}
fn get_epic(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let index = crate::epics::EpicIndex::build(&store.config, &issues);
let epic = match index.resolve(&reference) {
Ok(epic) => epic.clone(),
Err(error) => return Ok(epic_refusal(error)),
};
let targets = match epic_reference_targets(&store.config, &store) {
Ok(targets) => targets,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let (graph_issues, done_column) = match store.graph_source() {
Ok(source) => source,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let graph = crate::commands::graph_over(&graph_issues, &done_column);
let rollup = match index.rollup(&store.config, &reference, &targets, &graph) {
Ok(rollup) => rollup,
Err(error) => return Ok(epic_refusal(error)),
};
let mut value = epic_record_value(&epic);
value["rollup"] = epic_rollup_value(&rollup);
Ok(success_result(
cmd_epic::record_lines(&epic, Some(&rollup)).join("\n"),
value,
))
}
fn create_epic(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let name = req_string(args, "name")?;
let description = opt_string(args, "description")?;
let status = opt_string(args, "status")?;
let owner = opt_string(args, "owner")?;
// Dates and the name-keyed references are trimmed and a blank one is absent,
// exactly as `tix epic create` normalizes them; the other optional fields are
// kept verbatim and the service normalizes a written-but-blank value.
let start_date = trimmed_optional(opt_string(args, "start_date")?);
let due_date = trimmed_optional(opt_string(args, "due_date")?);
let milestone = trimmed_optional(opt_string(args, "milestone")?);
let cycle = trimmed_optional(opt_string(args, "cycle")?);
let weight = match epic_create_weight(args, "weight") {
Ok(weight) => weight,
Err(outcome) => return outcome,
};
let projects = opt_string(args, "projects")?
.map(|raw| crate::commands::parse_csv_list(&raw))
.unwrap_or_default();
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let new = crate::epics::NewEpic {
name,
description,
status,
owner,
start_date,
due_date,
weight,
projects,
milestone,
cycle,
};
// The service allocates the stable id and validates name, dates, weight and
// the project-id shapes, so an invalid request refuses here with the registry
// untouched.
match crate::epics::create_epic(&mut config, &new) {
Ok(epic) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
Ok(success_result(
format!(
"Created {}",
crate::epics::epic_display(&epic.id, &epic.name)
),
epic_record_value(&epic),
))
}
Err(error) => Ok(epic_refusal(error)),
}
}
fn update_epic(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let name = opt_string(args, "name")?;
let description = opt_string(args, "description")?;
let status = opt_string(args, "status")?;
let owner = opt_string(args, "owner")?;
let start_date = opt_string(args, "start_date")?;
let due_date = opt_string(args, "due_date")?;
let parent = opt_string(args, "parent")?;
let projects = opt_string(args, "projects")?;
let milestone = opt_string(args, "milestone")?;
let cycle = opt_string(args, "cycle")?;
let weight = match epic_weight_patch(args, "weight") {
Ok(weight) => weight,
Err(outcome) => return outcome,
};
let has_weight = !matches!(args.get("weight"), None | Some(Value::Null));
if name.is_none()
&& description.is_none()
&& status.is_none()
&& owner.is_none()
&& start_date.is_none()
&& due_date.is_none()
&& parent.is_none()
&& projects.is_none()
&& milestone.is_none()
&& cycle.is_none()
&& !has_weight
{
return Ok(error_result(epic_edit_requires_a_field()));
}
// The patch shape `tix epic edit` builds: `name` always sets (a name is never
// cleared, which the service refuses as a duplicate-name diagnostic), the
// other text fields follow the blank-clears convention, and `projects`
// **replaces the whole list** (blank clears it).
let projects_patch = match projects {
None => crate::epics::Patch::Unchanged,
Some(value) if value.trim().is_empty() => crate::epics::Patch::Clear,
Some(value) => crate::epics::Patch::Set(crate::commands::parse_csv_list(&value)),
};
let edit = crate::epics::EpicEdit {
name: name.map_or(crate::epics::Patch::Unchanged, crate::epics::Patch::Set),
description: text_patch(description),
status: text_patch(status),
owner: text_patch(owner),
start_date: date_patch(start_date),
due_date: date_patch(due_date),
weight,
parent: text_patch(parent),
projects: projects_patch,
milestone: text_patch(milestone),
cycle: text_patch(cycle),
};
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match crate::epics::edit_epic(&mut config, &reference, &edit) {
Ok(epic) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
// Re-resolve through an index so the response carries the derived
// members and children: a registry-only edit has no issue set to derive
// from and would report `member_count: 0` for an epic that has members.
let store = store_with_config(store, config);
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let index = crate::epics::EpicIndex::build(&store.config, &issues);
match index.resolve(epic.id.as_str()) {
Ok(epic) => Ok(success_result(
format!(
"Updated {}",
crate::epics::epic_display(&epic.id, &epic.name)
),
epic_record_value(epic),
)),
Err(error) => Ok(epic_refusal(error)),
}
}
Err(error) => Ok(epic_refusal(error)),
}
}
fn delete_epic(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
// The schema declares `confirm` required, but a schema is documentation a
// client may ignore; checked before anything is resolved, so a forgotten
// confirmation writes nothing (the convention `delete_project` follows).
if opt_bool(args, "confirm")? != Some(true) {
return Ok(error_result(
"invalid input: `confirm` must be true to delete an epic — only the registry \
entry is removed and neither an issue nor a child epic is deleted"
.to_string(),
));
}
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// Resolved before the deletion so the impact report describes the epic that
// still exists — the order `tix epic delete` uses.
let epic = match crate::epics::resolve(&config, &issues, &reference) {
Ok(epic) => epic,
Err(error) => return Ok(epic_refusal(error)),
};
// Removes the registry entry **only**; the service reports every affected issue
// and every child epic, so this response carries the CLI's impact report and
// neither is ever deleted (C-005, C-006).
match crate::epics::delete_epic(&mut config, &reference, &issues) {
Ok(deletion) => {
if let Err(e) = save_registry_config(&mut config, &path) {
return Ok(error_result(crate::error::render_error(&e)));
}
let affected: Vec<Value> = deletion.affected.iter().map(epic_member_value).collect();
let children: Vec<Value> = deletion.child_epics.iter().map(epic_ref_value).collect();
let text = format!(
"Deleted epic {}\n{}",
crate::epics::epic_display(&deletion.id, &deletion.name),
cmd_epic::impact_notice(&epic),
);
Ok(success_result(
text,
json!({
"id": deletion.id.as_str(),
"name": deletion.name,
"affected": affected,
"child_epics": children,
}),
))
}
Err(error) => Ok(epic_refusal(error)),
}
}
fn epic_hierarchy(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let index = crate::epics::EpicIndex::build(&store.config, &issues);
let hierarchy = match index.hierarchy(&reference) {
Ok(hierarchy) => hierarchy,
Err(error) => return Ok(epic_refusal(error)),
};
let epic = match index.resolve(&reference) {
Ok(epic) => epic.clone(),
Err(error) => return Ok(epic_refusal(error)),
};
let mut value = epic_hierarchy_value(&hierarchy);
// The members, so a surface can open an ordinary issue directly (FR-004).
value["member_count"] = json!(epic.member_count);
value["members"] = json!(
epic.members
.iter()
.map(epic_member_value)
.collect::<Vec<Value>>()
);
Ok(success_result(
cmd_epic::navigation_lines(&hierarchy, &epic).join("\n"),
value,
))
}
fn epic_membership(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The service's derived membership — the same value `tix epic show` and the
// `list_issues {epic}` filter read. Read-only: nothing is written.
match crate::epics::membership(&store.config, &issues, &reference) {
Ok(membership) => {
let members: Vec<Value> = membership.members.iter().map(epic_member_value).collect();
let text = format!(
"{}: {} member(s)",
membership.epic_id, membership.member_count
);
Ok(success_result(
text,
json!({
"epic_id": membership.epic_id.as_str(),
"member_count": membership.member_count,
"members": members,
}),
))
}
Err(error) => Ok(epic_refusal(error)),
}
}
fn epic_health(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
let issues = match store.list_all() {
Ok(issues) => issues,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let index = crate::epics::EpicIndex::build(&store.config, &issues);
let targets = match epic_reference_targets(&store.config, &store) {
Ok(targets) => targets,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The same snapshot `commands::epic::show` feeds the identical read: the index
// over every issue file, the blocked state over the relationship service's
// active set. Read-only: nothing is written and no History entry is appended.
let (graph_issues, done_column) = match store.graph_source() {
Ok(source) => source,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let graph = crate::commands::graph_over(&graph_issues, &done_column);
match index.rollup(&store.config, &reference, &targets, &graph) {
Ok(rollup) => Ok(success_result(
epic_health_text(&rollup),
epic_rollup_value(&rollup),
)),
Err(error) => Ok(epic_refusal(error)),
}
}
fn epic_associate_issues(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
if selectors.is_empty() {
return Ok(error_result(
"invalid input: `issues` must name at least one issue selector".to_string(),
));
}
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
// Resolved **once**, up front, so an unknown epic refuses before any write
// (mirroring `tix epic add`). The id, not the reference, is what the
// service-backed bulk write receives.
let id = match crate::epics::resolve_epic_id(&store.config.epics, &reference) {
Ok(id) => id,
Err(error) => return Ok(epic_refusal(error)),
};
match store.bulk_reference(id.as_str(), &selectors) {
Ok(outcomes) => {
let changed = outcomes
.iter()
.filter(|outcome| outcome.outcome.as_ref().is_ok_and(|a| a.changed))
.count();
let failed = outcomes
.iter()
.filter(|outcome| outcome.outcome.is_err())
.count();
let rows: Vec<Value> = outcomes.iter().map(epic_reference_outcome_value).collect();
let changes: Vec<String> = outcomes
.iter()
.map(|outcome| match &outcome.outcome {
Ok(applied) if applied.changed => {
format!("{}: referenced", outcome.selector)
}
Ok(_) => format!("{}: already referenced", outcome.selector),
Err(error) => {
format!(
"{}: {}",
outcome.selector,
crate::error::render_error(error)
)
}
})
.collect();
let text = format!(
"referenced {changed} of {} issue(s) by {id}\n{}",
outcomes.len(),
changes.join("\n")
);
Ok(success_result(
text,
json!({
"epic_id": id.as_str(),
"changed": changed,
"failed": failed,
"total": outcomes.len(),
"outcomes": rows,
}),
))
}
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn epic_dissociate_issues(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "epic")?;
let selectors = crate::commands::parse_csv_list(&req_string(args, "issues")?);
if selectors.is_empty() {
return Ok(error_result(
"invalid input: `issues` must name at least one issue selector".to_string(),
));
}
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let store = store_with_config(store, config);
// Resolve when the reference names a live epic; otherwise use it **verbatim**,
// which is the repair path C-005 promises and exactly what `tix epic remove`
// does. The one owner of that classification is the service's
// `epics::dereference_needle` (WP09 item B).
let needle = crate::epics::dereference_needle(&store.config.epics, &reference);
let mut rows: Vec<Value> = Vec::with_capacity(selectors.len());
let mut changes: Vec<String> = Vec::with_capacity(selectors.len());
let mut changed = 0usize;
let mut failed = 0usize;
for selector in &selectors {
match store.dereference_epic(selector, &needle) {
Ok(applied) => {
if applied.changed {
changed += 1;
changes.push(format!("{selector}: dereferenced"));
} else {
changes.push(format!("{selector}: was not referenced"));
}
rows.push(json!({
"selector": selector,
"changed": applied.changed,
"epic_ids": applied.epic_ids,
}));
}
Err(error) => {
failed += 1;
changes.push(format!(
"{selector}: {}",
crate::error::render_error(&error)
));
rows.push(
json!({ "selector": selector, "error": crate::error::render_error(&error) }),
);
}
}
}
let text = format!(
"dereferenced {changed} of {} issue(s) from {needle}\n{}",
selectors.len(),
changes.join("\n")
);
Ok(success_result(
text,
json!({
"epic_id": needle,
"changed": changed,
"failed": failed,
"total": selectors.len(),
"outcomes": rows,
}),
))
}
fn move_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
let column = req_string(args, "column")?;
let commit = opt_string(args, "commit")?;
let yes = args.get("yes").and_then(Value::as_bool).unwrap_or(false);
// Through the shared guard, not `mv::apply`. Calling the bare core here was a
// parity defect: MCP could move blocked work to done with no warning, no
// confirmation, and no History entry, while the CLI refused it. Same accept /
// reject decision on every surface is the whole of `surface-parity.md` §2,
// and this tool is a "bulk or import path" in the sense T016 step 6 names.
// WP07: the `reason` argument, closing the gap WP03 recorded and WP04 could
// not close. The seam already took an override reason; the CLI got the flag in
// WP04; this is the MCP spelling of the same thing, so a client is no longer
// refused by a rule it has no way to satisfy.
//
// Passed straight through — no MCP-side validation, no trimming, no default. The
// service trims a blank reason and refuses the move, so a client that sends
// `""` gets the same refusal the CLI gives, with the same message.
let reason = opt_string(args, "reason")?;
let override_wip = args
.get("override")
.and_then(Value::as_bool)
.unwrap_or(false);
// WP06 T028/T029 (mission `configurable-wip-limits-01M3C6WX`): the shared WIP
// policy, exactly as the CLI's `wip_gate` asks it. MCP owns no evaluator and
// compares no status strings: the decision, its diagnostics and the override's
// History entry all come from the one service (C-006).
let issues = match store.list() {
Ok(issues) => issues,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let selector = match crate::commands::parse_selector(store, &raw) {
Ok(selector) => selector,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let moved_issue = match store.get(selector) {
Ok(issue) => issue,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let context = crate::wip::MoveContext::new(config, &issues);
let mut request = crate::wip::MoveRequest::new(&moved_issue, &column);
if let Some(reason) = reason.as_deref() {
request = request.because(reason);
}
// The bound limit is the one in force now — the same request the CLI makes, and
// the same staleness rule: a limit that changed makes the override stale rather
// than silently authoritative.
let decision = match config
.default_config()
.wip
.effective_limit(&column)
.cloned()
.filter(|_| override_wip)
{
Some(limit) => {
let over = crate::wip::OverrideRequest {
// `yes` is the confirmation, and `override` alone is not: a flag
// that authorizes is not a flag that confirms the authorization
// (T029's "explicit actor/reason/confirmation").
confirmed: yes,
..crate::wip::OverrideRequest::new(
crate::storage::actor_identity(),
crate::model::utc_now_rfc3339(),
reason.clone().unwrap_or_default(),
crate::wip::BoundLimit::of(&column, &limit),
)
};
crate::wip::evaluate(&context, &request.overriding(&over))
}
None => crate::wip::evaluate(&context, &request),
};
// A refused *transition* is left to the seam, which makes the same call with the
// wording and diagnostic kind this tool has always returned (`unknown_column`,
// `invalid_transition`): one refusal, one wording (the CLI's rule).
let transition_refusal = decision.diagnostics.first().is_some_and(|diagnostic| {
diagnostic.kind == crate::wip::WipDiagnosticKind::RejectedTransition
});
if !transition_refusal && !decision.is_committable() {
// A policy refusal: nothing was written, and the client gets the decision's
// structure rather than only a sentence (T029).
return Ok(wip_result(
crate::wip::render_decision(&decision),
&decision,
true,
));
}
let wip_event = decision.history.clone();
let wip_structured = wip_value(&decision);
match mv::apply_guarded(
config,
store,
&raw,
&column,
commit.as_deref(),
reason.as_deref(),
yes,
wip_event.as_ref(),
) {
Ok(moved) => {
let mut text = format!("Moved {} to {column}", moved.issue.filename);
if moved.acknowledged {
text.push_str(" (blocked move acknowledged)");
}
if decision.outcome == crate::wip::WipOutcome::Warn {
text.push('\n');
text.push_str(&crate::wip::render_decision(&decision));
}
let mut structured = issue_value(&moved.issue, &resolution);
structured["wip"] = wip_structured;
Ok(success_result(text, structured))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
/// The shared policy's decision as structured data (WP06 T029).
///
/// The same fields the CLI renders, so a client can branch on `kind` rather than
/// parse the sentence — and `history` names the one event an applied override wrote.
fn wip_value(decision: &crate::wip::WipDecision) -> Value {
json!({
"kind": decision.as_str(),
"from": decision.from,
"to": decision.to,
"before": decision.state_before.as_str(),
"after": decision.state_after.as_str(),
"limit": decision.limit.as_ref().map(|limit| json!({
"column": decision.to,
"max_items": limit.max_items,
"max_estimate": limit.max_estimate,
"unit": limit.unit,
"policy": limit.policy.map(|policy| policy.as_str()),
"enabled": limit.enabled,
})),
"usage": decision.usage.as_ref().map(|usage| json!({
"cell": usage.cell(),
"items": usage.items,
"estimate": usage.estimate,
})),
"diagnostics": decision
.diagnostics
.iter()
.map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"message": diagnostic.message,
}))
.collect::<Vec<_>>(),
"history": decision.history.as_ref().map(|intent| json!({
"kind": intent.kind.as_str(),
"actor": intent.actor,
"timestamp": intent.timestamp,
"detail": intent.detail,
})),
})
}
/// A WIP refusal: `isError: true` **and** the structured decision beside it, so a
/// client can branch on `wip.kind` rather than parse the sentence (the shape the
/// template and view refusals already use).
fn wip_result(text: String, decision: &crate::wip::WipDecision, is_error: bool) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": { "wip": wip_value(decision) },
"isError": is_error,
})
}
/// A `kind` argument that must be present, reporting the diagnostic the CLI
/// would produce for the same mistake.
///
/// Three distinct failures, deliberately not collapsed:
///
/// 1. **absent** — the argument is missing, so the message names it and the four
/// accepted values. A protocol `-32602` would say only "invalid params",
/// which tells an agent nothing about the closed vocabulary it must choose
/// from, and the vocabulary *is* the contract.
/// 2. **wrong JSON type** — a type error is a protocol concern, reported as one.
/// 3. **an unknown value** — the service's own text, verbatim. This case was
/// being folded into case 1, which replaced the authoritative diagnostic with
/// a generic "must be one of" and lost the `unknown_kind` kind the CLI
/// reports — the exact drift `surface-parity.md` §2 forbids, introduced here
/// and caught by `t021_an_unknown_kind_rejects_with_the_clis_exact_text`.
///
/// The *field* in the missing case is spelled for the argument the caller used
/// (`kind`, not the CLI's `--type`); the diagnostic kind and field are otherwise
/// identical on both surfaces.
fn require_relation_kind(
args: &Value,
key: &str,
) -> std::result::Result<crate::relationships::RelationKind, Value> {
let advice = format!("pass `{key}` blocks|blocked_by|duplicates|related");
// **Absent and `null` are the same case.** An agent that sends `{"kind": null}`
// means "not provided", and it must get the "relation type required"
// diagnostic — not "must be a string", which describes a type error the caller
// did not make. An earlier version of this function split them and a `null`
// got the worse message.
let Some(value) = args.get(key).filter(|v| !v.is_null()) else {
return Err(error_result(
crate::relationships::Diagnostic::unknown_kind(
"kind",
format!("relation type required: {advice}"),
)
.to_string(),
));
};
// A non-string is a **protocol** error, not a domain one: the schema says
// string, and there is no domain reading of `kind: 7`.
let Some(raw) = value.as_str() else {
return Err(error_result(format!(
"invalid input: `{key}` must be a string naming one of blocks, blocked_by, duplicates, related"
)));
};
// The **whole** CLI rule, not just the parser: `parse_kind` on its own left the
// trim and the blank case to this function, so `" blocks "` was accepted by
// `tix link --type " blocks "` and rejected here.
//
// The advice is this surface's own, so the *decision* is shared while each
// surface names its own argument — §2 asks for "the same named field/selector",
// not for the CLI's flag spelling repeated to an agent.
crate::commands::link::require_kind_named(Some(raw), &advice)
.map_err(|e| error_result(crate::error::render_error(&e)))
}
/// The structured payload for one relationship.
///
/// Carries the stable id, the canonical kind, both endpoints, and the display
/// name, so an agent can act on the result with `edit_relationship` /
/// `remove_relationship` without a second round trip to recover the id.
fn relationship_value(rel: &crate::relationships::Relationship) -> Value {
json!({
"id": rel.id,
"from": rel.from,
"to": rel.to,
"kind": rel.kind.as_str(),
"display": rel.display,
})
}
/// Resolve a selector argument to an issue number, MCP-side.
///
/// The `Store` does the resolving, through the same `parse_selector` grammar the
/// CLI uses, so an ambiguous selector fails identically on both surfaces.
fn resolve_number(store: &Store, raw: &str) -> std::result::Result<u32, ()> {
store
.get(crate::commands::parse_selector(store, raw).map_err(|_| ())?)
.map(|i| i.number)
.map_err(|_| ())
}
/// Run `f` with the relationship graph over the whole store.
///
/// A closure rather than a returned `(Vec<Issue>, RelationshipGraph)` pair
/// because the graph **borrows** the issue set: the two cannot be returned
/// together. Keeping the construction here means the read tools get one graph
/// build and one read pass, and a derived read can never be computed from a
/// different snapshot than the one beside it.
///
/// The closure returns an `isError` result for a domain failure, so a
/// relationship read reports a problem rather than panicking.
fn with_graph<T, F>(store: &Store, f: F) -> std::result::Result<T, Value>
where
F: FnOnce(&crate::relationships::RelationshipGraph<'_>) -> std::result::Result<T, Value>,
{
let (issues, done_column) = store
.graph_source()
.map_err(|e| error_result(crate::error::render_error(&e)))?;
let graph = crate::commands::graph_over(&issues, &done_column);
f(&graph)
}
fn link_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let a = selector(args, "a")?;
let b = selector(args, "b")?;
// `kind` is REQUIRED. There is no default and no fallback to `related` --
// an untype relationship must never be creatable by omission (C-001).
let kind = match require_relation_kind(args, "kind") {
Ok(kind) => kind,
Err(result) => return Ok(result),
};
match store.link(&a, &b, kind) {
Ok(outcome) => {
// Report a real issue, and never panic inside a request handler.
// The endpoint that owns the edge is the canonical `from`, but
// reading it back is a lookup that can legitimately fail, so an
// unresolvable endpoint is an error result rather than an `expect`
// (F21). The old recovery also re-derived which endpoint owns the
// edge by trying both, which is the service's job.
let owner = outcome
.written
.as_ref()
.map(|i| i.number)
.unwrap_or(outcome.relationship.from);
let issue = match store.get(crate::storage::IssueSelector::Number(owner)) {
Ok(issue) => issue,
Err(e) => {
return Ok(error_result(format!(
"linked, but issue #{owner} could not be read back: {e}"
)));
}
};
// The **shared** wording, not a re-derived copy. This used to inline both
// branches of `link::link_message` verbatim, byte-identical — but the
// point of `surface-parity.md` §5 is that the two surfaces *cannot*
// diverge, and two copies of a format string cannot promise that. The
// CLI's own text is what an agent will read in a transcript, so it should
// be the same text rather than a lookalike.
//
// The information is the same as `tix link`'s plus the id, kind, both
// endpoints and the display name, so an agent can act on it.
let text = crate::commands::link::link_message(&outcome);
Ok(success_result(
text,
json!({
"issue": issue_value(&issue, &resolution),
"relationship": relationship_value(&outcome.relationship),
"changed": outcome.changed,
}),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn list_relationships(store: &Store, args: &Value) -> ToolOutcome {
let issue = opt_selector(args, "issue")?;
// An unresolvable selector is the repository's existing invalid-input error,
// not a protocol error -- same as the CLI's `tix link list <bad>`.
let subject = match &issue {
Some(raw) => match resolve_number(store, raw) {
Ok(number) => Some(number),
Err(()) => {
return Ok(with_number_resolution(store, raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
},
None => None,
};
// Optional, but an *invalid* value is still a rejection with the service's
// own text -- silently ignoring an unknown `--kind` would make a filter that
// matched nothing look like a board with no relationships.
let kind = match args.get("kind") {
None | Some(Value::Null) => None,
Some(Value::String(raw)) => match crate::commands::link::parse_kind(raw) {
Ok(kind) => Some(kind),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
Some(_) => {
return Ok(error_result(
"invalid input: `kind` must be a string naming one of blocks, blocked_by, duplicates, related"
.to_string(),
));
}
};
// `blocked_by` is an input spelling, never persisted. Filtering on it
// literally would match nothing and a documented filter would silently
// return an empty list, so it filters on the canonical `blocks` edge.
//
// The **shared** mapping, not a second copy: this used to be an inlined `match`
// identical to `link::canonical_filter`. `surface-parity.md` §3 puts spelling and
// direction rules in the service, and the sibling finding in the same review was
// a format that had genuinely diverged while a comment claimed otherwise (#1345).
let wanted = kind.map(crate::commands::link::canonical_filter);
let result = with_graph(store, |graph| {
let edges: Vec<&crate::relationships::Relationship> = match subject {
Some(number) => graph.relationships_for(number),
None => graph.all_relationships().iter().collect(),
};
let shown: Vec<&crate::relationships::Relationship> = edges
.into_iter()
.filter(|rel| wanted.is_none_or(|w| rel.kind == w))
.collect();
let values: Vec<Value> = shown.iter().map(|rel| relationship_value(rel)).collect();
// The **shared** renderer, not a second copy of the format. It covers both
// the rows and the empty-result wording, which this function used to spell
// out again in a `match (subject, wanted)`.
//
// The rows were already shared (`relationship_line`); the wording was not,
// and the row format had in fact diverged before that — this inlined
// `rel.display`, so a row read `#0 --blocks--> #1 blocks #1 Gamma`, with the
// kind and number twice, while `tix link list` stripped them. The tool's
// `content` was therefore not byte-comparable across surfaces, which its own
// description and `docs/mcp.md` both claimed.
let text = crate::commands::link::listing_text(&shown, subject, wanted);
Ok((text, values))
});
let (text, values) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, json!({ "relationships": values })))
}
/// Turn a failed selector resolution into the repository's own message.
///
/// The CLI's `parse_selector` distinguishes "no match" from "ambiguous", and MCP
/// must not collapse the two -- an ambiguous selector reported as not-found
/// sends an agent looking in the wrong place. Reusing the error string keeps the
/// two surfaces saying the same thing about the same selector.
fn with_number_resolution(store: &Store, raw: &str, not_found: impl FnOnce() -> Value) -> Value {
match crate::commands::parse_selector(store, raw) {
Ok(_) => not_found(),
Err(e) => error_result(crate::error::render_error(&e)),
}
}
fn edit_relationship(store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
let kind_arg = opt_string(args, "kind")?;
let kind = match &kind_arg {
Some(raw) => match crate::commands::link::parse_kind(raw) {
Ok(kind) => Some(kind),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
None => None,
};
let to_arg = opt_selector(args, "to")?;
let to = match &to_arg {
Some(raw) => match resolve_number(store, raw) {
Ok(number) => Some(number),
Err(()) => {
return Ok(with_number_resolution(store, raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
},
None => None,
};
if to.is_none() && kind.is_none() {
// The identity edit. Refused rather than silently no-op'd, because a
// caller who asked to change something and got "unchanged" back has been
// misled about whether the change happened.
return Ok(error_result(format!(
"invalid input: nothing to change: pass `to` and/or `kind` to edit_relationship {id}"
)));
}
match store.edit_relation(&id, to, kind) {
Ok(outcome) => {
// The **shared** wording (`link::edit_message`), not a re-derived copy.
// The no-op branch matters: it is what tells a caller that an edit
// changed nothing, and a surface that phrases it differently from the CLI
// is a surface an agent has to learn twice.
let text = crate::commands::link::edit_message(&outcome);
Ok(success_result(
text,
json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn remove_relationship(store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
match store.remove_relation(&id) {
Ok(outcome) => {
// An absent id is a success, not an error: the outcome really did
// leave the store in the requested state, and reporting it as a
// failure would make the tool unsafe to retry.
//
// The **shared** wording (`link::remove_message`). Its no-op branch reads
// the id off `outcome.relationship`, not off the request — and
// `Store::remove_relation` populates that field with the requested id for
// the absent case, so the two are byte-identical while only one of them
// stays correct if that ever changes.
let text = crate::commands::link::remove_message(&outcome);
Ok(success_result(
text,
json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn issue_chain(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let root = match resolve_number(store, &raw) {
Ok(number) => number,
Err(()) => {
return Ok(with_number_resolution(store, &raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
};
let result = with_graph(store, |graph| {
// The service's order, verbatim. Re-sorting here would let MCP and the
// CLI report different chains for one snapshot.
let chain = graph.dependency_chain(root);
let paths: Vec<Value> = chain
.paths
.iter()
.map(|path| {
Value::Array(
path.iter()
.map(|node| json!({ "number": node.number, "title": node.title }))
.collect(),
)
})
.collect();
// Node numbers *and* titles: an agent has to be able to address the next
// node, and a title alone is not a selector.
let nodes: Vec<Value> = chain
.nodes()
.iter()
.map(|node| json!({ "number": node.number, "title": node.title }))
.collect();
let text = crate::commands::link::chain_output(&chain);
Ok((
text,
json!({
"root": root,
"paths": paths,
"nodes": nodes,
"truncated": chain.truncated,
}),
))
});
let (text, structured) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, structured))
}
// ---------------------------------------------------------------------------
// WP07 / T029 — the hierarchy read tools
// ---------------------------------------------------------------------------
//
// Three derived reads, matching the three rows of the parity contract's
// operation table that are reads. Every handler goes through
// `commands::parent::source`, the same snapshot the CLI reads, and returns the
// CLI's own rendered text alongside structured values.
//
// Both, deliberately. The contract requires the same accept/reject decision, the
// same *value* (§2.5 — "not merely the same rendered text"), and the same
// derived read. A text-only tool satisfies the first and third; a numbers-only
// tool fails the "same text" half that every other MCP tool in this file keeps by
// returning the CLI's wording as its content. So the text is byte-identical and
// the numbers are present to be compared without parsing prose.
/// One snapshot of the hierarchy, plus the board's completion column.
struct HierarchySnapshot {
index: crate::hierarchy::HierarchyIndex,
issues: Vec<Issue>,
done_column: String,
}
fn hierarchy_snapshot(store: &Store) -> std::result::Result<HierarchySnapshot, ()> {
let (index, issues, done_column) = crate::commands::parent::source(store).map_err(|_| ())?;
Ok(HierarchySnapshot {
index,
issues,
done_column,
})
}
/// Resolve the required `issue` argument to a number, through the shared selector
/// parser, so a number, a filename and a title fragment all work here exactly as
/// they do on the CLI.
fn required_number(store: &Store, args: &Value) -> std::result::Result<u32, ()> {
let raw = req_string(args, "issue")?;
resolve_number(store, &raw)
}
/// The same "not in the current snapshot" refusal the CLI gives, as a tool error.
///
/// Deliberately the shared wording rather than a tool-specific one: a caller
/// comparing the two surfaces must not be able to tell them apart by a missing
/// issue.
fn missing_issue(number: u32) -> Value {
error_result(
crate::error::TixError::NotFound(format!("issue #{number} is not in the current snapshot"))
.to_string(),
)
}
fn list_children(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
// `children_text` is the CLI's own renderer, so the ordering rule (issue
// number) has one implementation rather than one per surface.
let text = match crate::commands::parent::children_text(&snapshot.index, number) {
Ok(text) => text,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let membership = snapshot.index.children_of(number);
let children: Vec<Value> = membership
.children
.iter()
.map(|child| json!({ "number": child.number, "display": child.display }))
.collect();
Ok(success_result(
text.clone(),
json!({
"issue": number,
"direct_children_only": true,
"count": children.len(),
"children": children,
"text": text,
}),
))
}
fn get_parent(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
let facts = crate::commands::parent::facts_for(
&snapshot.index,
&snapshot.issues,
number,
&snapshot.done_column,
);
// `parent_text` is the shared single-line wording, so the unresolved case
// cannot be spelled one way here and another way in the TUI or `tix show`.
let text = crate::commands::parent::parent_text(&facts);
let resolved = facts.parent.filter(|p| snapshot.index.contains(*p));
let unresolved = facts
.unresolved_reason
.map(|reason| json!({ "parent": facts.parent, "reason": reason }));
Ok(success_result(
text.clone(),
json!({
"issue": number,
"parent": resolved,
"parent_display": facts.parent_label,
// The retained reference, kept even when the target is gone: the
// reference survives so it can be repaired, and a surface that hid it
// would make the repair invisible.
"recorded_parent": facts.parent,
"unresolved": unresolved,
"text": text,
}),
))
}
fn get_rollup(store: &Store, args: &Value) -> ToolOutcome {
let number = match required_number(store, args) {
Ok(number) => number,
Err(()) => {
return Ok(error_result(
"invalid input: an `issue` selector is required".into(),
));
}
};
let snapshot = match hierarchy_snapshot(store) {
Ok(snapshot) => snapshot,
Err(()) => return Ok(error_result("could not read the issue set".into())),
};
if !snapshot.index.contains(number) {
return Ok(missing_issue(number));
}
let facts = crate::commands::parent::facts_for(
&snapshot.index,
&snapshot.issues,
number,
&snapshot.done_column,
);
// The rendered lines are read before `rollup` is moved out of `facts`.
let text = format!("{} | {}", facts.child_count_line(), facts.completion_line());
let rollup = facts.rollup;
// The numbers, as numbers. `completion_basis` is carried as its own field
// because the figures mean something only alongside the population they
// describe — "3/5" over direct children and over the subtree are different
// numbers wearing the same clothes.
let value = json!({
"issue": number,
"parent": rollup.parent,
"direct_children": rollup.direct_children,
"completed_children": rollup.completed_children,
"progress": { "done": rollup.progress.done, "total": rollup.progress.total },
"completion_basis": rollup.completion_basis.as_str(),
"text": text,
});
Ok(success_result(text, value))
}
fn issue_blocked_state(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
let number = match resolve_number(store, &raw) {
Ok(number) => number,
Err(()) => {
return Ok(with_number_resolution(store, &raw, || {
error_result(format!("invalid input: no issue matches selector `{raw}`"))
}));
}
};
let result = with_graph(store, |graph| {
let state = graph.blocked_state(number);
// `blocked_note` is the same function the CLI warning, the TUI prompt,
// the board card and the detail view use, so `reason` here is
// byte-identical to all of them (surface-parity §2).
let note = crate::commands::blocked_note(&state);
let blockers: Vec<Value> = state
.blockers
.iter()
.map(|n| json!({ "number": n.number, "title": n.title }))
.collect();
let text = note.clone().unwrap_or_else(|| "not blocked".to_string());
Ok((
text,
json!({
"issue": number,
"is_blocked": state.is_blocked,
"blockers": blockers,
"reason": state.reason,
"done_column": graph.done_column(),
}),
))
});
let (text, structured) = match result {
Ok(ok) => ok,
Err(result) => return Ok(result),
};
Ok(success_result(text, structured))
}
fn delete_issue(store: &Store, args: &Value) -> ToolOutcome {
let raw = selector(args, "issue")?;
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(error_result(
"delete requires confirm=true — the issue file is untouched".into(),
));
}
let filename = match resolve_filename(store, &raw) {
Ok(f) => f,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
match delete::remove(store, &raw, true) {
Ok(()) => Ok(success_result(
format!("Deleted {filename}"),
json!({ "deleted": filename }),
)),
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn search_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let query = req_string(args, "query")?;
match search::find(store, &query) {
Ok(hits) => {
let values: Vec<Value> = hits
.iter()
.map(|hit| json!({ "issue": issue_value(&hit.issue, &resolution), "matched_lines": hit.matched_lines }))
.collect();
Ok(success_result(
search_text(&hits),
json!({ "hits": values }),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn comment_on_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let (_, resolution) = crate::workflow::active_resolution(config);
let raw = selector(args, "issue")?;
let author = req_string(args, "author")?;
let text = req_string(args, "text")?;
let outcome = (|| -> TixResult<Issue> {
let selector = parse_selector(store, &raw)?;
let mut issue = store.get(selector)?;
issue.add_comment(&author, &text);
store.update(&issue.filename, &issue)?;
Ok(issue)
})();
match outcome {
Ok(issue) => Ok(success_result(
format!("Commented on {}", issue.filename),
issue_value(&issue, &resolution),
)),
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
/// `mentions_list`: the local user's pending mentions, as text and as data.
fn mentions_list(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let _ = config;
match cmd_mentions::pending(store) {
Ok(pending) => {
let text = if pending.is_empty() {
"no pending mentions".to_string()
} else {
pending
.iter()
.map(cmd_mentions::line)
.collect::<Vec<_>>()
.join("\n")
};
let rows: Vec<Value> = pending
.iter()
.map(|mention| {
json!({
"id": mention.id,
"issue_number": mention.issue_number,
"issue_file": mention.issue_file,
"body": mention.body,
"context": mention.context,
"created": mention.created,
"state": match mention.state {
crate::mentions::MentionState::Unread => "unread",
crate::mentions::MentionState::Read => "read",
},
})
})
.collect();
Ok(success_result(text, json!({ "mentions": rows })))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
/// `mentions_mark_read`: the one mutating mention answer.
fn mentions_mark_read(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
match cmd_mentions::mark_read(store, &id) {
Ok(message) => {
let mention = cmd_mentions::find(store, &id).ok();
let value = mention.map_or_else(
|| json!({ "id": id }),
|mention| {
json!({
"id": mention.id,
"issue_number": mention.issue_number,
"state": match mention.state {
crate::mentions::MentionState::Unread => "unread",
crate::mentions::MentionState::Read => "read",
},
})
},
);
Ok(success_result(message, value))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn export_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
let dry_run = opt_bool(args, "dry_run")?.unwrap_or(false);
let project = opt_string(args, "project")?;
match export::run_core(config, store, Some(&target), dry_run, project.as_deref()) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn import_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let target = req_string(args, "target")?;
match import::run_core(config, store, Some(&target)) {
Ok(report) => Ok(success_result(report.clone(), json!({ "report": report }))),
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn lint_issues(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let foreign = args.get("foreign").and_then(Value::as_bool).unwrap_or(true);
let findings = match lint::collect_with(config, store, foreign) {
Ok(f) => f,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let count = match store.list() {
Ok(issues) => issues.len(),
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let values: Vec<Value> = findings
.iter()
.map(|f| {
json!({
"file": f.file,
"line": f.line,
"column": f.column,
"kind": f.kind,
"value": f.value,
"message": f.message,
})
})
.collect();
Ok(success_result(
lint_text(count, &findings),
json!({ "findings": values }),
))
}
// ---------------------------------------------------------------------------
// WP06 / T028–T029 — the template registry surface
// ---------------------------------------------------------------------------
/// The project's `tix.toml` and the path it came from, loaded for a template
/// operation.
///
/// A management mutation replaces the whole `Config` and saves it atomically, so
/// it needs a **mutable** config and the path it came from — neither of which the
/// server holds (it is handed a `&Config`). `commands::templates::run` loads the
/// same pair for the same reason, and `Config::project_root` performs the same
/// upward discovery as the bootstrap, so MCP manages the file the rest of the
/// session reads. Loading per call is what makes a template another surface just
/// wrote visible to the next call.
fn template_management_config() -> TixResult<(Config, std::path::PathBuf)> {
let cwd = std::env::current_dir().map_err(crate::error::TixError::Io)?;
let root = Config::project_root(&cwd).ok_or_else(|| {
crate::error::TixError::Config("no tix.toml found — run \"tix init\" first".into())
})?;
let path = root.join("tix.toml");
let config = Config::load(&root)?;
Ok((config, path))
}
/// The stored record as JSON: stable ID, display label, explicit scope,
/// defaults, prompts, references, and preserved unknown metadata.
///
/// Every part is data the registry already owns; this projects it, so one
/// template renders identically through `list_templates`, `get_template`, and
/// the outcome of a mutation.
fn template_record_value(id: &str, template: &Template) -> Value {
json!({
"id": id,
"name": template.display_name(id),
"scope": template.scope.to_string(),
"defaults": template.defaults,
"prompts": template.prompts,
"references": template.references,
"extensions": template.extensions,
})
}
/// The record of one stored template as JSON.
fn template_value(detail: &TemplateDetail) -> Value {
template_record_value(detail.id(), detail.template())
}
/// The stored record for `id`, or `null` when the registry does not hold it.
///
/// A create/update always leaves the record present, so `None` here would mean
/// the service's own invariant broke; it is reported as `null` rather than
/// substituted with a guess.
fn template_registry_value(config: &Config, id: &str) -> Option<Value> {
config
.issue_templates
.get(id)
.map(|template| template_record_value(id, template))
}
/// A template operation's `structuredContent`.
///
/// `status` is the shared vocabulary the batch tools use — `accepted`, `no-op`,
/// `rejected`, `failed` — so a client branches on the word rather than on prose.
fn template_outcome_value(
mutation: &str,
status: &str,
template: Option<Value>,
diagnostics: Vec<Value>,
) -> Value {
json!({
"status": status,
"mutation": mutation,
"template": template,
"diagnostics": diagnostics,
})
}
/// An `isError: true` result that still carries the structured outcome.
///
/// Every other tool reports a refusal as text only. A template refusal is data
/// the caller must act on — which field, which template, which of
/// rejected/failed — so it is structured as well as worded, and a client does not
/// have to parse the sentence to find it.
fn template_error_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": true,
})
}
/// A refused template request: a structured field diagnostic plus matching text,
/// in the same `status`/`diagnostics` shape a service refusal uses.
fn template_reject(mutation: &str, field: &str, message: String) -> ToolOutcome {
let text = format!("invalid input: `{field}`: {message}");
let structured = template_outcome_value(
mutation,
"rejected",
None,
vec![json!({
"kind": "invalid-payload",
"field": field,
"message": message,
})],
);
Ok(template_error_result(text, structured))
}
/// The service's refusal as a result: the CLI's own wording, plus the field the
/// refusal named and whether it was a validation rejection or an I/O failure.
fn template_write_refusal(mutation: &str, error: &TemplateWriteError) -> Value {
let status = if error.is_refusal() {
"rejected"
} else {
"failed"
};
let message = crate::error::render_error(error.inner());
let structured = template_outcome_value(
mutation,
status,
None,
vec![json!({
"kind": status,
"field": refusal_field(&message),
"message": message.clone(),
})],
);
template_error_result(message, structured)
}
/// The field a service refusal names: the first backticked span of the rendered
/// error that is an `issue_templates.<id>.<field>` key (the registry's
/// field-aware validation errors), else the registry root.
///
/// The span is *found*, not assumed to be first, because the registry's `Config`
/// errors carry the CLI's own text in front of the key
/// (`config error: \`issue_templates.<id>.<field>\` …`).
fn refusal_field(message: &str) -> &str {
message
.split('`')
.skip(1)
.step_by(2)
.find(|span| span.starts_with("issue_templates"))
.unwrap_or("issue_templates")
}
/// A `create_template`/`update_template` request as the service's own
/// [`TemplateDefinition`].
///
/// MCP parses **no** template grammar: `defaults`, `prompts` and `references`
/// deserialize into the registry's own typed structs — each with its own
/// `deny_unknown_fields` — and the combination of a supplied name/scope mirrors
/// `commands::templates`'s definition builders. An absent block is not supplied,
/// which is what makes a rename a rename on the update path.
///
/// A JSON type error (a number where an object belongs) is a protocol `-32602`;
/// a well-typed payload the schema cannot accept is a **rejected** outcome with a
/// field-aware diagnostic, never a silent drop.
fn template_definition(args: &Value, mutation: &str) -> ToolOutcome2<TemplateDefinition> {
let name = opt_string_step(args, "name")?;
let scope = match opt_string_step(args, "scope")? {
Some(raw) => match raw.parse::<TemplateScope>() {
Ok(scope) => Some(scope),
Err(message) => return Err(template_reject(mutation, "scope", message)),
},
None => None,
};
let defaults = template_block::<TemplateDefaults>(args, "defaults", mutation)?;
let prompts = template_items::<TemplatePrompt>(args, "prompts", mutation)?;
let references = template_items::<TemplateReference>(args, "references", mutation)?;
// The same combination the CLI adapter performs: a supplied field replaces
// the whole block, an absent one is left alone (`create` fills its own
// defaults inside the service; `update` leaves the stored value untouched).
let mut definition = match (name, scope) {
(Some(name), Some(scope)) => TemplateDefinition::new(name, scope),
(Some(name), None) => TemplateDefinition::rename(name),
(None, Some(scope)) => TemplateDefinition::unnamed(scope),
(None, None) => TemplateDefinition::default(),
};
if let Some(defaults) = defaults {
definition = definition.with_defaults(defaults);
}
if let Some(prompts) = prompts {
definition = definition.with_prompts(prompts);
}
if let Some(references) = references {
definition = definition.with_references(references);
}
Ok(definition)
}
/// [`opt_string`] lifted into the inner-step error type: a JSON type error is a
/// protocol `-32602`, which on this side of the handler is `Err(proto())`.
fn opt_string_step(args: &Value, key: &str) -> ToolOutcome2<Option<String>> {
opt_string(args, key).map_err(|()| proto())
}
/// One optional typed block (`defaults`) deserialized from the request.
fn template_block<T: serde::de::DeserializeOwned>(
args: &Value,
key: &str,
mutation: &str,
) -> ToolOutcome2<Option<T>> {
match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(value) => match serde_json::from_value::<T>(value.clone()) {
Ok(block) => Ok(Some(block)),
Err(error) => Err(template_reject(mutation, key, error.to_string())),
},
}
}
/// One optional typed list (`prompts`, `references`), each item deserialized
/// separately so a bad item is diagnosed with its index.
fn template_items<T: serde::de::DeserializeOwned>(
args: &Value,
key: &str,
mutation: &str,
) -> ToolOutcome2<Option<Vec<T>>> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(items) = value.as_array() else {
// A well-formed request naming the wrong JSON type is the same protocol
// error `opt_string` gives a number where a string belongs.
return Err(proto());
};
let mut parsed = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
match serde_json::from_value::<T>(item.clone()) {
Ok(value) => parsed.push(value),
Err(error) => {
return Err(template_reject(
mutation,
&format!("{key}[{index}]"),
error.to_string(),
));
}
}
}
Ok(Some(parsed))
}
fn list_templates(_config: &Config, _store: &Store, _args: &Value) -> ToolOutcome {
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let service = TemplateService::new(&mut config, &path);
let details = service.list();
let templates: Vec<Value> = details.iter().map(template_value).collect();
Ok(success_result(
cmd_templates::render_list(&details),
json!({ "templates": templates }),
))
}
fn get_template(_config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "template")?;
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let service = TemplateService::new(&mut config, &path);
match service.show(&reference) {
Ok(detail) => Ok(success_result(
cmd_templates::render_detail(&detail),
json!({ "template": template_value(&detail) }),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn create_template(_config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
let id = req_string(args, "id")?;
let definition = match template_definition(args, "create") {
Ok(definition) => definition,
Err(outcome) => return outcome,
};
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let outcome = {
let mut service = TemplateService::new(&mut config, &path);
service.create(&id, definition)
};
match outcome {
Ok(outcome) => Ok(success_result(
format!("created template `{}`", outcome.value()),
template_outcome_value(
"create",
"accepted",
template_registry_value(&config, outcome.value()),
Vec::new(),
),
)),
Err(error) => Ok(template_write_refusal("create", &error)),
}
}
fn update_template(_config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "template")?;
let definition = match template_definition(args, "update") {
Ok(definition) => definition,
Err(outcome) => return outcome,
};
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let outcome = {
let mut service = TemplateService::new(&mut config, &path);
service.update(&reference, definition)
};
match outcome {
Ok(outcome) => {
let id = outcome.value();
let (status, text) = if outcome.is_noop() {
("no-op", format!("unchanged template `{id}`"))
} else {
("accepted", format!("updated template `{id}`"))
};
Ok(success_result(
text,
template_outcome_value(
"update",
status,
template_registry_value(&config, id),
Vec::new(),
),
))
}
Err(error) => Ok(template_write_refusal("update", &error)),
}
}
fn duplicate_template(_config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "template")?;
let new_id = opt_string(args, "new_id")?;
let name = opt_string(args, "name")?;
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let outcome = {
let mut service = TemplateService::new(&mut config, &path);
service.duplicate(&reference, new_id.as_deref(), name.as_deref())
};
match outcome {
Ok(outcome) => {
let detail = outcome.value();
Ok(success_result(
format!("duplicated template `{reference}` as `{}`", detail.id()),
template_outcome_value(
"duplicate",
"accepted",
Some(template_value(detail)),
Vec::new(),
),
))
}
Err(error) => Ok(template_write_refusal("duplicate", &error)),
}
}
fn delete_template(_config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "template")?;
// The schema declares `confirm` required, but a schema is documentation a
// client may ignore, and a write tool whose confirmation is optional is a
// write tool a client can call by accident (C-004). Checked before anything
// is loaded, so a forgotten confirmation writes nothing at all.
if opt_bool(args, "confirm")? != Some(true) {
return Ok(template_error_result(
"invalid input: `confirm` must be true to delete a template definition".to_string(),
template_outcome_value(
"delete",
"rejected",
None,
vec![json!({
"kind": "missing-confirmation",
"field": "confirm",
"message": "requires `confirm: true`",
})],
),
));
}
let (mut config, path) = match template_management_config() {
Ok(loaded) => loaded,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let outcome = {
let mut service = TemplateService::new(&mut config, &path);
service.delete(&reference, TemplateDeleteConfirmed::confirmed())
};
match outcome {
Ok(outcome) => {
let detail = outcome.value();
Ok(success_result(
format!("deleted template `{}`", detail.id()),
template_outcome_value(
"delete",
"accepted",
Some(template_value(detail)),
Vec::new(),
),
))
}
Err(error) => Ok(template_write_refusal("delete", &error)),
}
}
// ---------------------------------------------------------------------------
// text mirrors (byte-identical to the CLI's stdout rendering, NFR-005)
// ---------------------------------------------------------------------------
/// `tix list` table text.
fn list_text(
issues: &[Issue],
group: Option<&str>,
resolution: &crate::workflow::CategoryResolution,
) -> String {
list::render_default_table(issues, group, resolution, 0)
}
/// `tix status` table text.
/// `tix status` table text.
///
/// WP04: delegates to [`status::render`] instead of keeping a second copy of the
/// table. This function used to reimplement the whole rendering — column widths, the
/// `Overdue` count, the group rollup, the cycle/milestone progress — and compared
/// issues against a `done_col` **string** while the CLI compared categories. The two
/// copies agreed only because both happened to resolve the same board; the moment
/// the CLI's label changed to `complete` and this one did not, the parity test in
/// `tests/mcp.rs` failed. A duplicated renderer does not stay in sync, and this is
/// what that costs.
fn status_text(
config: &Config,
store: &Store,
issues: &[Issue],
summary: &status::BoardSummary,
show_controls: bool,
group: Option<&str>,
) -> String {
// `None`: the health summary is opt-in (`tix status --health`) and MCP's
// `board_status` takes no such flag, so both surfaces print the same board by
// default — which is what NFR-005's byte-for-byte parity requires.
status::render(config, store, issues, summary, show_controls, group, None)
}
const MAX_LINE_WIDTH: usize = 200;
/// Truncate a matched context line (same as `search::run`).
fn clipped(line: &str) -> String {
if line.chars().count() <= MAX_LINE_WIDTH {
return line.to_string();
}
let head: String = line.chars().take(MAX_LINE_WIDTH).collect();
format!("{head}…")
}
/// `tix search` result text (empty query yields `No issues found.`).
fn search_text(hits: &[SearchHit]) -> String {
if hits.is_empty() {
return "No issues found.\n".to_string();
}
let mut out = String::new();
for hit in hits {
out.push_str(&format!(
"#{} {} — {} ({})\n",
hit.issue.number, hit.issue.title, hit.issue.status, hit.issue.filename
));
for line in &hit.matched_lines {
out.push_str(&format!(" {}\n", clipped(line)));
}
}
out
}
fn sort_issues(issues: &mut [Issue], sort: Option<&str>) {
match sort.unwrap_or("created") {
"due" => issues.sort_by(|a, b| a.due_date.cmp(&b.due_date).then(a.number.cmp(&b.number))),
"start" => issues.sort_by(|a, b| {
a.start_date
.cmp(&b.start_date)
.then(a.number.cmp(&b.number))
}),
"reviewer" => {
issues.sort_by(|a, b| a.reviewer.cmp(&b.reviewer).then(a.number.cmp(&b.number)))
}
"cycle" => issues.sort_by(|a, b| {
a.cycles
.as_ref()
.and_then(|v| v.first())
.cmp(&b.cycles.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"milestone" => issues.sort_by(|a, b| {
a.milestones
.as_ref()
.and_then(|v| v.first())
.cmp(&b.milestones.as_ref().and_then(|v| v.first()))
.then(a.number.cmp(&b.number))
}),
"status" => issues.sort_by(|a, b| {
a.status
.display()
.cmp(b.status.display())
.then(a.number.cmp(&b.number))
}),
"priority" => issues.sort_by(|a, b| {
a.priority
.to_string()
.cmp(&b.priority.to_string())
.then(a.number.cmp(&b.number))
}),
_ => issues.sort_by(|a, b| a.created.cmp(&b.created).then(a.number.cmp(&b.number))),
}
}
/// `tix lint` text: the CLI's clean line, or a count line plus one
/// `file:line:col kind value message` line per finding
/// (`contracts/mcp-tools.md` §3.12).
fn lint_text(n_issues: usize, findings: &[lint::TixIssueFinding]) -> String {
// WP06: the summary line now follows the **CLI's** rule, not "any finding at
// all". `mcp-tools.md` §4 says tool text "mirrors the CLI's stdout rendering …
// so a human reading tool text sees identical facts", and before this change the
// two disagreed: the CLI said `Lint OK with 3 warnings` for a board relying on
// the safe default, while this said `Checked 1 issues; 3 finding(s)` — the same
// store, two different verdicts, one of which implied a problem.
//
// It is only visible now because WP06's `unmapped_column_default` is a
// `Severity::Warning` and is *reported*. Before that, `findings.is_empty()` and
// "no errors" were the same predicate on every real store, so the divergence had
// nothing to show itself on.
let errors = findings
.iter()
.filter(|f| f.severity == lint::Severity::Error)
.count();
if errors == 0 {
let warnings = findings.len();
return if warnings == 0 {
format!("Lint OK: {n_issues} issues checked.\n")
} else {
format!(
"Lint OK with {warnings} warning{}: {n_issues} issues checked.\n",
if warnings == 1 { "" } else { "s" }
)
};
}
// Errors: one line per finding, in the format §3.12 pins. No count line, because
// the CLI does not print one in this case either — the diagnostics speak for
// themselves and a count above them is a second thing to keep in step.
let mut out = String::new();
for f in findings
.iter()
.filter(|f| f.severity == lint::Severity::Error)
{
out.push_str(&format!(
"{}:{}:{} {} {} {}\n",
f.file, f.line, f.column, f.kind, f.value, f.message
));
}
out
}
/// Resolve an issue's filename for a selector (used to report the deleted
/// path before it is gone).
fn resolve_filename(store: &Store, raw: &str) -> TixResult<String> {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
Ok(issue.filename)
}
// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------
/// Handle a `tools/call` for one of the registered tools.
///
/// - unknown tool name → `-32601 Method not found`;
/// - strict argument type mismatch → `-32602 Invalid params`;
/// - everything else returns the *tool result* (success or `isError`), never
/// a JSON-RPC error.
pub fn call(
config: &Config,
store: &Store,
id: protocol::Id,
name: &str,
args: &Value,
) -> std::result::Result<Value, protocol::ErrorResponse> {
if !registry().iter().any(|t| t.name == name) {
return Err(protocol::method_not_found(id));
}
let arguments = if args.is_null() { &Value::Null } else { args };
// `dispatch` returns `None` for a name that is DECLARED but has no arm --
// the one failure mode a `match` over names can have that the registry
// membership check above cannot catch. That used to be `unreachable!`, which
// turned a registration mistake into a server panic on the first call, and
// made the mistake invisible until an agent happened to try that tool.
match dispatch(config, store, name, arguments) {
Some(outcome) => outcome.map_err(|()| protocol::invalid_params(id)),
None => Ok(error_result(format!(
"internal error: tool `{name}` is declared in the registry but has no dispatch arm"
))),
}
}
/// `bulk_capabilities` (T037): the portable projection, verbatim.
///
/// A tool rather than a field on the other two because `surface-parity.md` says the
/// remote projection "may report representability" — and a client that has to infer
/// it by trial and error is doing the parity work this mission exists to remove. The
/// payload is [`bulk::metadata::capabilities_json`] itself, so a client sees exactly
/// what the schema was generated from and the two cannot disagree.
fn bulk_capabilities(_config: &Config, _store: &Store, _args: &Value) -> ToolOutcome {
let caps = bulk::metadata::capabilities_json();
let mut lines = vec!["Bulk operations available in this build:".to_string()];
for op in bulk::metadata::BulkOperation::ALL {
lines.push(format!(
" {} — {} [{}]",
op.as_str(),
op.summary(),
op.availability().replace('_', " ")
));
}
lines.push("Patch fields:".to_string());
for field in bulk::metadata::PATCH_FIELDS {
lines.push(format!(
" {} — {}{}",
field.name,
field.summary,
if field.clearable { " (clearable)" } else { "" }
));
}
lines.push(format!(
"Outcome statuses: {}",
bulk::metadata::OUTCOME_STATUSES.join(", ")
));
lines.push("Selection grammar:".to_string());
for line in bulk::metadata::SELECTION_GRAMMAR {
lines.push(format!(" {line}"));
}
Ok(success_result(lines.join("\n"), caps))
}
/// The `PatchFlags` a batch request carries, read from the shared field names.
///
/// Reads **only** names the projection lists, and the schema's `additionalProperties:
/// false` is what stops anything else arriving. The check is repeated here anyway,
/// because a schema is documentation a client may ignore and this is the boundary
/// that has to hold (C-008: an unlisted field mutation is an escalation trigger).
///
/// The error type is [`ToolOutcome`], so a malformed argument and a refused request
/// are different **types** rather than two spellings of the same one: `Err(())` is a
/// `-32602` and `Ok(error_result(..))` is a tool result. Getting that wrong is the
/// difference between an agent reading "name exactly one of --select, --filter or
/// --search" and an agent seeing a protocol failure with no explanation.
fn bulk_flags(args: &Value) -> ToolOutcome2<cmd_bulk::PatchFlags> {
let mut flags = cmd_bulk::PatchFlags::default();
for (key, slot) in [
("status", &mut flags.status),
("priority", &mut flags.priority),
("assignee", &mut flags.assignee),
("labels", &mut flags.labels),
("start-date", &mut flags.start_date),
("due-date", &mut flags.due_date),
("estimate", &mut flags.estimate),
("cycles", &mut flags.cycles),
("milestones", &mut flags.milestones),
] {
if let Err(problem) = bulk::metadata::check_field(key) {
return Err(refuse(problem.to_string()));
}
let value = opt_string(args, key).map_err(|()| proto())?;
// A blank value is **absent**, exactly as the CLI treats `--estimate ""`:
// `Unchanged`, never `Clear` and never `Set("")`. WP06 found that rule the
// hard way and it is the one thing an MCP client is most likely to get wrong.
*slot = value.filter(|v| !v.trim().is_empty());
}
if let Some(clear) = opt_string(args, "clear").map_err(|()| proto())? {
let field = clear.trim().to_string();
if !field.is_empty() {
if let Err(problem) = bulk::metadata::check_field(&field) {
return Err(refuse(problem.to_string()));
}
if !cmd_bulk::PatchFlags::is_clearable(&field) {
return Err(refuse(format!(
"cannot clear '{field}' — clearable fields: {}",
bulk::metadata::clearable_field_names().join(", ")
)));
}
flags.clear = vec![field];
}
}
Ok(flags)
}
/// The selection request, built by the **shared** builder so the refusal is the
/// CLI's message rather than this handler's.
fn bulk_request(args: &Value) -> ToolOutcome2<cmd_bulk::SelectionRequest> {
let selectors = opt_string(args, "select").map_err(|()| proto())?;
let filter = opt_string(args, "filter").map_err(|()| proto())?;
let search = opt_string(args, "search").map_err(|()| proto())?;
let scope = match opt_string(args, "scope").map_err(|()| proto())?.as_deref() {
None | Some("") => bulk::SelectionScope::Active,
// The scope names the CLI's `--scope` accepts, spelled the same way. A wrong
// name is refused rather than silently becoming `Active`, because the two
// non-default scopes are how a user reaches archived issues and defaulting
// would quietly narrow their request.
Some("all") => bulk::SelectionScope::ActiveAndArchived,
Some("archived") => bulk::SelectionScope::ArchivedOnly,
Some(other) => {
return Err(refuse(format!(
"unknown scope '{other}' — `active` (default), `all` (active and \
archived), or `archived`"
)));
}
};
let selectors = selectors
.map(|raw| raw.split(',').map(str::to_string).collect::<Vec<_>>())
.unwrap_or_default();
// The **shared** builder, so a client that names zero or two grammars reads the
// CLI's own message rather than a schema's.
cmd_bulk::SelectionRequest::new(&selectors, filter.as_deref(), search.as_deref(), scope)
.map_err(|e| refuse(e.to_string()))
}
/// One selected issue's pair outcome, as a surface reports it.
///
/// A **projection**, not an apply type. A pair writes two files and a field edit
/// writes one, so there is no single `ItemOutcome` that describes both honestly —
/// and the point of the row is that the *status word* is the shared part, not the
/// payload.
struct PairRow {
number: u32,
title: String,
/// From `PairOutcome::status` or one of the plan-side words above; the same
/// vocabulary `ItemOutcome::status` uses.
status: &'static str,
line: String,
/// Both ends for a pair, empty for a refusal or a no-op.
files: Vec<String>,
}
/// The plan as JSON: selection order, proposed changes, paths, diagnostics.
///
/// Every field `surface-parity.md` §1 lists, and nothing invented — a key here that
/// the CLI does not print is a second contract.
fn bulk_plan_value(plan: &bulk::plan::BatchPlan, applied: bool) -> Value {
json!({
"applied": applied,
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"items": plan.items.iter().map(|item| json!({
"number": item.number,
"title": item.title,
"status": item.status(),
"changes": item.changes.iter()
.map(|(field, change)| {
// `as_from_to` rather than a match here, so a `Clear` and a
// `NoChange` project the same way on every surface.
let (from, to) = change.as_from_to();
json!({ "field": field, "from": from, "to": to })
})
.collect::<Vec<_>>(),
"path_change": item.path_change.as_ref().map(|p| json!({
"from": p.from,
"to": p.to,
})),
"history": item.history.iter()
.map(|h| json!({ "kind": h.kind, "detail": h.detail }))
.collect::<Vec<_>>(),
"diagnostic": item.diagnostic.as_ref().map(|d| json!({
"issue": d.issue,
"field": d.field,
"message": d.message,
})),
})).collect::<Vec<_>>(),
})
}
/// The report as JSON: exactly one outcome per selected issue (NFR-002).
fn bulk_report_value(report: &bulk::ApplyReport, plan: &bulk::plan::BatchPlan) -> Value {
let titles: Vec<(u32, String)> = plan
.items
.iter()
.map(|i| (i.number, i.title.clone()))
.collect();
json!({
"applied": report.applied(),
"no_op": report.noop(),
"rejected": report.rejected(),
"failed": report.failed(),
"outcomes": report.outcomes.iter().map(|(number, outcome)| {
let title = titles.iter().find(|(n, _)| n == number)
.map(|(_, t)| t.as_str()).unwrap_or("");
json!({
"number": number,
"title": title,
// The shared vocabulary word, from `ItemOutcome::status` — not a
// re-derived string, or NFR-003 would be a claim about this handler.
"status": outcome.status(),
"line": outcome.as_line(*number, title),
})
}).collect::<Vec<_>>(),
})
}
/// `bulk_preview` / `bulk_apply` (T036): the shared plan and apply, projected to JSON.
fn bulk_run(store: &Store, args: &Value, apply: bool) -> ToolOutcome {
if apply {
// C-004. Checked **before** anything is resolved, so a client that forgot the
// confirmation gets the refusal without having paid for a 10,000-issue plan.
match opt_bool(args, "confirm") {
Ok(Some(true)) => {}
Ok(_) => {
return Ok(error_result(
"confirm is required and must be true — a batch apply writes files \
and appends History, so nothing is written without it (C-004). Call \
bulk_preview first; it is read-only."
.to_string(),
));
}
Err(()) => return Err(()),
}
}
// An absent or non-string `operation` is a **protocol** error (`-32602`), the
// same treatment `req_string` gives every other required argument. A well-formed
// but unknown operation is a **tool** result, because the client sent well-formed
// JSON and deserves a message it can read rather than a protocol failure.
let operation_raw = match req_string(args, "operation") {
Ok(raw) => raw,
Err(()) => return Err(()),
};
let operation_raw = operation_raw.trim().to_string();
if let Err(problem) = bulk::metadata::check_operation(&operation_raw) {
return Ok(error_result(problem.to_string()));
}
let operation = bulk::metadata::BulkOperation::from_name(&operation_raw)
.expect("check_operation accepted it, so from_name did too");
// Whether the build can **perform** it is decided at the apply, not here: the
// refusal has to name every selected issue, and that needs the plan. Refusing at
// the request boundary would produce a message about a mission with no issue in
// it — the one thing WP05's CLI explicitly avoids.
// The helpers return a finished `ToolOutcome` on refusal, so this is one `?`:
// a protocol error stays a protocol error and a refusal stays a tool result,
// because the helper already chose which.
// The helpers return a **finished** `ToolOutcome` on refusal, so this `?` cannot
// flatten the two kinds: `Err(())` stays a protocol error and
// `Ok(error_result(..))` comes back as a tool result, because the helper already
// chose which and the handler returns it untouched.
let (request, flags) = match (bulk_request(args), bulk_flags(args)) {
(Ok(request), Ok(flags)) => (request, flags),
// The **first** refusal wins. Running both and reporting the second would let
// a client see a schema complaint about a field when the real problem is that
// it named two selection grammars.
(Err(outcome), _) | (_, Err(outcome)) => return outcome,
};
let selection = match cmd_bulk::resolve(store, &request) {
Ok(selection) => selection,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let patch = match operation {
bulk::metadata::BulkOperation::Edit => match flags.to_patch() {
Ok(patch) => patch,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
bulk::metadata::BulkOperation::Link | bulk::metadata::BulkOperation::Unlink => {
let target = opt_string(args, "target")?;
let Some(target) = target.filter(|t| !t.trim().is_empty()) else {
return Ok(error_result(
"a pair needs a target — `target` is required for link and unlink".to_string(),
));
};
// The kind is resolved **once**, through the relationship service's own
// parser, exactly as the CLI's `run_pair` does. A missing or unknown kind
// is therefore the service's message and not this handler's, which is the
// parity `surface-parity.md` asks for. Resolving it per issue would report
// the same refusal ten times for a ten-issue batch.
let kind =
match crate::commands::link::require_kind(opt_string(args, "type")?.as_deref()) {
Ok(kind) => kind,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// Per selected issue: `from` is a single issue in the relationship
// vocabulary, so a batch is a set of edges rather than one edge with a
// compound endpoint. The same rule the TUI follows.
//
// And each is planned and applied on its own, so a mixed result is a set
// of per-item statuses rather than one verdict for the batch (C-005).
let mut outcomes: Vec<PairRow> = Vec::new();
for issue in selection.issues() {
let op = if operation == bulk::metadata::BulkOperation::Unlink {
bulk::RelationshipOp::unlink(issue.number.to_string(), target.clone(), kind)
} else {
bulk::RelationshipOp::link(issue.number.to_string(), target.clone(), kind)
};
let one = bulk::Selection::from_issues(
vec![issue.clone()],
bulk::SelectionSource::Explicit,
bulk::SelectionScope::Active,
);
let per_item = cmd_bulk::build(
store,
one,
bulk::IssuePatch {
relationship: Some(op),
..bulk::IssuePatch::default()
},
)
.map(|mut plan| {
plan.confirmation = apply.then(|| bulk::Confirmation::issue(&plan));
plan
});
match per_item {
// A pair is planned and applied per item, so a mixed result is a
// set of per-item statuses rather than one verdict for the batch
// (C-005). A preview reports what each pair *would* do, from the
// plan it built and nothing else — which is what keeps a preview
// read-only (C-004).
Ok(plan) if apply => match bulk::apply_pair(store, &plan) {
Ok(outcome) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: outcome.status(),
line: outcome.as_line(),
files: outcome.files().to_vec(),
}),
// A write that failed is a **failed** status with the error
// attached, not a refusal: the attempt happened. The diagnostic
// is built rather than invented — `field: "target"` and
// `issue:` are the plan's own, so a client sees the same shape
// a field apply produces.
Err(e) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: "failed",
line: format!(
"#{} {} -> failed (target): {e}",
issue.number, issue.title
),
files: Vec::new(),
}),
},
Ok(plan) => {
let (status, line) =
match plan.items.first().and_then(|i| i.diagnostic.as_ref()) {
Some(d) => ("rejected", d.message.clone()),
None if plan
.items
.first()
.is_some_and(bulk::plan::ItemPlan::is_actionable) =>
{
(
"applied",
format!("#{} {} -> would apply", issue.number, issue.title),
)
}
None => (
"no-op",
format!(
"#{} {} -> already in that state",
issue.number, issue.title
),
),
};
outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status,
line,
files: Vec::new(),
});
}
Err(e) => outcomes.push(PairRow {
number: issue.number,
title: issue.title.clone(),
status: "rejected",
line: format!("#{} {} -> refused: {e}", issue.number, issue.title),
files: Vec::new(),
}),
}
}
// No batch `ApplyReport` is built here, and deliberately: a pair apply has
// its own outcome type because a pair writes **two** files, so forcing it
// into `ApplyReport` would produce a report whose `write_failures` is
// always empty and whose `Applied` carries one filename for two writes.
// `PairRow` is the projection — one row per selected issue, `status` from
// the same vocabulary — and it is what both the text and the JSON read.
let applied = outcomes.iter().filter(|r| r.status == "applied").count();
let no_op = outcomes.iter().filter(|r| r.status == "no-op").count();
let rejected = outcomes.iter().filter(|r| r.status == "rejected").count();
let failed = outcomes.iter().filter(|r| r.status == "failed").count();
let structured = json!({
"applied": applied,
"no_op": no_op,
"rejected": rejected,
"failed": failed,
"operation": operation.as_str(),
"outcomes": outcomes.iter().map(|r| json!({
"number": r.number,
"title": r.title,
"status": r.status,
"line": r.line,
// Both ends, because a pair is two named endpoints and a report
// that names one of them is a flattened write with extra steps.
"files": r.files,
})).collect::<Vec<_>>(),
});
let mut text = format!(
"{} issue(s) selected for {}\n",
outcomes.len(),
operation.as_str()
);
for row in &outcomes {
text.push_str(&format!("{}\n", row.line));
}
text.push_str(&format!(
"\n{applied} applied, {rejected} refused, {no_op} no-op, {failed} failed\n"
));
return Ok(success_result(text, structured));
}
bulk::metadata::BulkOperation::Archive => bulk::IssuePatch {
archive: Some(bulk::ArchiveRequest::archive()),
..bulk::IssuePatch::default()
},
};
let plan = match cmd_bulk::build(store, selection, patch) {
Ok(plan) => plan,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
if !apply {
return Ok(success_result(
cmd_bulk::render_plan(&plan, cmd_bulk::Mode::Preview),
bulk_plan_value(&plan, false),
));
}
// The apply-time refusal seam (`check_applicable`) now admits every operation —
// `archive` delegates to the store's archive service — so a performability refusal
// no longer exists to report here. The malformed-operation refusal still lives at
// the boundary (`check_operation`, above), where no plan exists to name.
let mut confirmed = plan.clone();
confirmed.confirmation = Some(bulk::Confirmation::issue(&plan));
// The archive direction goes through the store's archive service via the same
// `apply_archive` the CLI uses; every other operation is the field path. Both
// return the shared `ApplyReport`, so the JSON projection and the text are shared
// (surface parity) rather than two descriptions of the same batch.
let report = match operation {
bulk::metadata::BulkOperation::Archive => match bulk::apply_archive(store, &confirmed) {
Ok(report) => report,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
_ => match bulk::apply_fields(store, &confirmed, &crate::storage::actor_identity(), None) {
Ok(report) => report,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
},
};
let structured = bulk_report_value(&report, &confirmed);
let text = cmd_bulk::render_report(&report, &confirmed, cmd_bulk::Mode::Apply);
Ok(success_result(text, structured))
}
/// The scope refusal every triage surface shares.
///
/// The CLI's `commands::triage::scope_of` is private to that module, and the TUI
/// carries its own spelling for the same reason (`ui::triage::scope_of`), so this
/// is the third copy of one sentence — which is exactly why a test pins all three
/// together: `mcp_triage_without_a_section_refuses_with_the_clis_own_message`
/// compares this constant with what `tix triage list` prints for the same project,
/// so the copies cannot drift silently.
const NO_INBOX: &str =
"`tix triage` needs a `[triage]` section in tix.toml, and this project has none";
/// Resolve the inbox scope, or the refusal every triage view shares.
///
/// The refusal is rendered through [`crate::error::TixError`] rather than returned
/// as a bare sentence, so it carries the same `invalid input: …` prefix every other
/// refusal on this surface carries — and is then byte-equal to what the CLI's own
/// `scope_of` produces from the same constant, which is what
/// `t041_the_no_inbox_refusal_is_the_clis_own_sentence` asserts.
fn triage_scope(config: &Config) -> std::result::Result<inbox::InboxScope, ToolOutcome> {
match inbox::scope(config) {
Ok(Some(scope)) => Ok(scope),
Ok(None) => Err(Ok(error_result(
crate::error::TixError::InvalidInput(NO_INBOX.to_string()).to_string(),
))),
Err(e) => Err(Ok(error_result(crate::error::render_error(&e)))),
}
}
/// `triage_list` (T038/T041): the queue, with the CLI's own text and a structured
/// mirror of the same one snapshot.
///
/// The text comes from `commands::triage::render_list` — the exact string
/// `tix triage list` prints — so a client that logs MCP output and CLI output sees
/// one document rather than two descriptions of one queue (NFR-003). The
/// structured value is derived from the same [`inbox::collect`] pass the renderer
/// made, over the same corpus and the same `today`.
fn triage_list(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let by_age = match opt_bool(args, "age") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
let counts_only = match opt_bool(args, "count") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Err(()),
};
// The refusal comes **first**, and from one place. A project with no `[triage]`
// section has no inbox on any triage surface, and
// `mcp_triage_without_a_section_refuses_with_the_clis_own_message` compares this
// sentence with what `tix triage list` prints, byte for byte.
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_list(store, by_age, counts_only) {
Ok(text) => text,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
let mut rows: Vec<&inbox::QueueEntry<'_>> = metrics.entries.iter().collect();
if by_age {
// The CLI's `--age` rule, restated rather than re-invented: `render_list`'s
// comparator is private to `commands::triage`, exactly as `ui::triage`'s copy
// is, so this is the third spelling of one ordering. Oldest first, and an
// unknown age **last** on both ends rather than coerced to `today`.
//
// The copy is safe because it is *checked*:
// `mcp_triage_list_prints_the_clis_own_queue_and_counts` compares this
// structure's row order with the order of the CLI's own rendered rows, so a
// comparator that drifted from the CLI's would fail there rather than quietly
// returning the text in one order and the data in another.
rows.sort_by(|a, b| match (a.age_days, b.age_days) {
(Some(x), Some(y)) => y.cmp(&x),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
}
let structured = json!({
"counts": triage_counts_value(&metrics),
// `count: true` asks for the counts without the rows, and this returns the
// same thing the text does — a client that asked not to receive 10,000 rows
// does not receive them in `structuredContent` either.
"entries": if counts_only {
json!([])
} else {
triage_entries_value(&rows)
},
});
Ok(success_result(text, structured))
}
/// `triage_status` (T038/T041): the summary, CLI text plus its structured mirror.
fn triage_status(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let scope = match triage_scope(config) {
Ok(scope) => scope,
Err(outcome) => return outcome,
};
let text = match cmd_triage::render_status(store) {
Ok(text) => text,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let corpus = match crate::search::Corpus::build(store) {
Ok(corpus) => corpus,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
Ok(success_result(
text,
json!({ "counts": triage_counts_value(&metrics) }),
))
}
/// The counts and histogram, as one object (FR-005).
///
/// Every figure is `inbox::collect`'s — `accepted` is the completion reading and
/// `declined` is the `canceled` category, both through #183 — so this cannot drift
/// from `tix triage status` or from `tix status` (C-002).
fn triage_counts_value(metrics: &inbox::QueueMetrics<'_>) -> Value {
json!({
"total": metrics.total,
"awaiting": metrics.awaiting,
"deferred": metrics.deferred,
"accepted": metrics.accepted,
"declined": metrics.declined,
"duplicated": metrics.duplicated,
"unknown_age": metrics.unknown_age,
"age": metrics.age_distribution.iter()
.map(|bucket| json!({ "bucket": bucket.label, "count": bucket.count }))
.collect::<Vec<_>>(),
})
}
/// The queue rows, in the order the query returned them (FR-001, FR-005).
///
/// `entered_at` is the stamp the age was derived from and is `null` exactly when
/// `age_days` is — an entry nobody can age says so twice rather than reporting an
/// age of zero (data-model §2).
fn triage_entries_value(entries: &[&inbox::QueueEntry<'_>]) -> Value {
Value::Array(
entries
.iter()
.map(|entry| {
let issue = entry.issue;
json!({
"number": issue.number,
"title": issue.title,
"status": issue.status.display(),
"age_days": entry.age_days,
"entered_at": match entry.entered_at {
crate::triage::EntryTime::Recorded(at) => json!(at),
crate::triage::EntryTime::Unknown => Value::Null,
},
"source": crate::triage::entry_source(issue),
// Three answers, not two: no decision (`null`), the decision
// (`{…}`), and a recorded line that will not re-parse
// (`"unreadable"`). The last is the diagnostic a maintainer
// needs — collapsing it into `null` said "nobody has decided
// this" about a file that carries a decision (tix #1494).
"last_decision": match crate::triage::history::last_decision_state(issue) {
crate::triage::history::LastDecision::Readable(record) => json!({
"kind": record.kind.as_str(),
"prior_state": record.prior_state,
"result": record.result,
"reason": record.reason,
"actor": record.actor,
"timestamp": record.timestamp,
"target_issue": record.target_issue,
}),
crate::triage::history::LastDecision::Unreadable => json!("unreadable"),
crate::triage::history::LastDecision::None => Value::Null,
},
})
})
.collect(),
)
}
/// The selection a triage request names: one issue, a selector list, or a filter.
///
/// Three grammars, exactly one allowed — the rule `tix triage batch` enforces
/// through the same builder, so two named grammars produce the CLI's own message
/// rather than a schema complaint.
fn triage_selection(store: &Store, args: &Value) -> ToolOutcome2<bulk::Selection> {
// Blank counts as absent, for all three: a whitespace-only `select` is not a
// grammar a client named, and letting it through hands the refusal to the shared
// builder, whose message names `--search` — a flag this surface does not have.
// `issue` is filtered the same way, and for the same reason: an empty `issue` is
// not a named grammar either (review cycle 2).
let one = opt_selector(args, "issue")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let select = opt_string(args, "select")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let filter = opt_string(args, "filter")
.map_err(|()| proto())?
.filter(|raw| !raw.trim().is_empty());
let named =
usize::from(one.is_some()) + usize::from(select.is_some()) + usize::from(filter.is_some());
if named == 0 {
return Err(refuse(
"name the issues to decide: `issue` for one issue, or `select`/`filter` for \
a selection. A decision with no issue in it has nothing to report on."
.to_string(),
));
}
if named > 1 {
return Err(refuse(
"name exactly one of `issue`, `select` or `filter` — they are three grammars \
for the same selection, and this tool does not guess which you meant"
.to_string(),
));
}
// The shared builder splits and trims the comma list
// (`cmd_bulk::SelectionRequest::new` → `split_selectors`), so this does not
// split it a second time: one grammar, one splitter, and a trailing comma is
// dropped there rather than refused here with a message about an empty
// selector.
let mut selectors: Vec<String> = select.into_iter().collect();
if let Some(one) = one {
selectors.push(one);
}
// The **shared** request and resolver, so a selector that matches nothing or
// matches twice is the CLI's diagnostic (`unknown_issue`, ambiguity) and not a
// second implementation of selector resolution.
let request = cmd_bulk::SelectionRequest::new(
&selectors,
filter.as_deref(),
None,
bulk::SelectionScope::Active,
)
.map_err(|e| refuse(e.to_string()))?;
cmd_bulk::resolve(store, &request).map_err(|e| refuse(e.to_string()))
}
/// `triage_preview` / `triage_apply` (T039): the shared plan and apply, projected.
///
/// One pair of tools covers a single issue and a batch, because both go through
/// [`crate::triage::batch::build_plan`] — one preview engine, one refusal
/// vocabulary, one per-item outcome shape, exactly as the TUI's modal does
/// (`ModalScope::One` or `Many`). A single-issue call therefore reports the same
/// per-item status words a 10-issue call reports, which is what makes NFR-003 a
/// structural claim rather than a promise.
fn triage_run(config: &Config, store: &Store, args: &Value, apply: bool) -> ToolOutcome {
if apply {
// C-004. Checked **before** anything is resolved, so a client that forgot the
// confirmation gets the refusal without having paid for a plan.
match opt_bool(args, "confirm") {
Ok(Some(true)) => {}
Ok(_) => {
return Ok(error_result(
"confirm is required and must be true — a triage apply writes files \
and appends History, so nothing is written without it (C-004). Call \
triage_preview first; it is read-only."
.to_string(),
));
}
Err(()) => return Err(()),
}
}
// An absent or non-string `decision` is a **protocol** error (`-32602`); a
// well-formed but unknown decision is a **tool** result carrying the service's
// own message, because the client sent well-formed JSON and deserves to read
// why it was refused.
let raw = match req_string(args, "decision") {
Ok(raw) => raw,
Err(()) => return Err(()),
};
let kind = match raw.trim().parse::<DecisionKind>() {
Ok(kind) => kind,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let reason = match opt_string(args, "reason") {
Ok(value) => value.unwrap_or_default(),
Err(()) => return Err(()),
};
// A blank actor is passed through rather than replaced by the git identity: the
// CLI refuses `--actor ""` with `missing_actor`, and an MCP client that sent an
// empty string should hear the same thing. Falling back here would make the two
// surfaces disagree about whether a decision was anonymous.
let actor = match opt_string(args, "actor") {
Ok(value) => value.unwrap_or_else(crate::storage::actor_identity),
Err(()) => return Err(()),
};
let mut request = TriageDecision::new(kind, crate::model::utc_now_rfc3339(), actor, reason);
// `to` is read **raw**, and refused whenever the decision cannot use it — blank
// included, because the flag itself is the mistake. That is the CLI's own rule, and
// reading `to` only for an accept (the first cut of this handler) let a client name
// a column that was silently dropped while `tix triage batch --decision decline
// --to …` refused the identical request (review cycle 1). On an *accept* a blank
// `--to` means "no explicit target", so the configured `accepted_status` applies —
// also the CLI's rule.
let to = match opt_string(args, "to") {
Ok(value) => value,
Err(()) => return Err(()),
};
if let Some(target) = to.as_deref() {
if kind != DecisionKind::Accept {
return Ok(error_result(format!(
"`to` names an accept target, and this decision is a {kind} — a {kind} \
decision has no target column, so `to: \"{target}\"` would be silently \
ignored. Name the target on an accept, and use `of` for a duplicate."
)));
}
}
if kind == DecisionKind::Accept {
request.target_status = to.filter(|t| !t.trim().is_empty());
if request.target_status.is_none() {
request.target_status = config
.triage
.as_ref()
.and_then(|triage| triage.accepted_status.clone());
}
}
// `of` is passed through **raw** for the same reason: `build_plan` refuses it with
// the CLI's own sentence when the decision has no duplicate target (blank included)
// and resolves it otherwise, so this handler has no opinion about it at all.
let of = match opt_string(args, "of") {
Ok(value) => value,
Err(()) => return Err(()),
};
let selection = match triage_selection(store, args) {
Ok(selection) => selection,
Err(outcome) => return outcome,
};
// `--of` on a non-duplicate decision, an unresolvable target, and a project with
// no `[triage]` section are all plan-level refusals, detected before the first
// write so a whole selection either starts or does not.
let plan = match batch::build_plan(store, selection, request, of.as_deref()) {
Ok(plan) => plan,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
if !apply {
return Ok(success_result(
cmd_triage::render_plan(&plan),
triage_plan_value(&plan, false),
));
}
let mut confirmed = plan.clone();
confirmed.confirm();
let report = match batch::apply(store, &confirmed) {
Ok(report) => report,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let text = cmd_triage::render_report(&report, &confirmed);
let structured = triage_report_value(&report, &confirmed);
Ok(success_result(text, structured))
}
/// The plan as JSON: selection order, proposed changes, the History intent and the
/// per-item status — the same shape [`bulk_plan_value`] emits, for the same reason
/// (one projection per surface would be a second contract).
fn triage_plan_value(plan: &TriagePlan, applied: bool) -> Value {
json!({
"applied": applied,
"decision": plan.decision.kind.as_str(),
"selection": plan.selection.issues().iter()
.map(|i| json!({ "number": i.number, "title": i.title }))
.collect::<Vec<_>>(),
"items": plan.items.iter().map(|item| json!({
"number": item.number,
"title": item.title,
"status": item.status(),
"changes": item.changes.iter()
.map(|(field, change)| {
let (from, to) = change.as_from_to();
json!({ "field": field, "from": from, "to": to })
})
.collect::<Vec<_>>(),
"history": item.history.iter()
.map(|h| json!({ "kind": h.kind, "detail": h.detail }))
.collect::<Vec<_>>(),
"diagnostic": item.diagnostic.as_ref().map(|d| json!({
"issue": d.issue,
"field": d.field,
"message": d.message,
})),
})).collect::<Vec<_>>(),
"summary": plan.summary(),
})
}
/// The report as JSON: exactly one outcome per selected issue (NFR-002), with the
/// shared status word rather than a re-derived string.
fn triage_report_value(report: &bulk::ApplyReport, plan: &TriagePlan) -> Value {
let titles: Vec<(u32, String)> = plan
.items
.iter()
.map(|i| (i.number, i.title.clone()))
.collect();
json!({
"applied": report.applied(),
"no_op": report.noop(),
"rejected": report.rejected(),
"failed": report.failed(),
"outcomes": report.outcomes.iter().map(|(number, outcome)| {
let title = titles.iter().find(|(n, _)| n == number)
.map(|(_, t)| t.as_str()).unwrap_or("");
json!({
"number": number,
"title": title,
"status": outcome.status(),
"line": outcome.as_line(*number, title),
})
}).collect::<Vec<_>>(),
})
}
// ---------------------------------------------------------------------------
// WP06 / T029–T031: saved-view tools
// ---------------------------------------------------------------------------
/// The service the MCP view tools build: the shared registry over a freshly
/// reloaded `Config` and this build's (empty) project directory.
type ViewSvc<'a> = ViewService<'a, BTreeSet<String>>;
/// The reloaded repository state one view tool call operates on.
struct ViewContext {
config: Config,
path: std::path::PathBuf,
projects: BTreeSet<String>,
}
/// Reload the repository config for one view call.
///
/// The server bootstraps one `Config` for the session, but a saved-view mutation
/// **is** a `tix.toml` write — the registry is that file's `[views]` section, and
/// `ViewService` commits through the existing atomic save. Reloading per call is
/// what makes a `view_create` visible to the next `view_list` in the same
/// session, and it keeps this adapter honest: the registry on disk is the one
/// `tix view` reads and writes, so no surface can hold a private copy (C-007).
fn view_context(store: &Store) -> std::result::Result<ViewContext, Value> {
let path = cmd_views::config_path(store);
let root = path.parent().unwrap_or_else(|| Path::new("."));
match Config::load(root) {
Ok(config) => Ok(ViewContext {
config,
path,
projects: cmd_views::project_directory(),
}),
Err(error) => Err(error_result(error.to_string())),
}
}
/// The exact sentence `tix view delete` without `--confirm` prints, so the two
/// surfaces refuse the same call in the same words.
const DELETE_CONFIRMATION: &str = "deleting a view needs --confirm: it removes the saved definition, never its issues or comments";
/// Refuse any argument this tool does not declare.
///
/// Every view descriptor promises `additionalProperties: false`, and the
/// mission's boundary is that a saved view is a *data* definition: there is no
/// hosted sharing, no host account, and no executable query payload (C-003/
/// C-004/C-009). Silently ignoring an undeclared key is how a client comes to
/// believe it configured `host`/`execute`/`permissions`; refusing it with a
/// field diagnostic is what makes the boundary observable (T030).
fn view_arguments(args: &Value, allowed: &[&str]) -> std::result::Result<(), Value> {
let Some(object) = args.as_object() else {
return Ok(());
};
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !allowed.contains(key))
.collect();
if unknown.is_empty() {
return Ok(());
}
let diagnostics: Vec<ViewDiagnostic> = unknown
.iter()
.map(|key| {
ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
format!("`{key}` is not a field of this tool"),
)
.with_field(*key)
.with_remedy(
"remove it: a saved view is repository data, and host identity, permissions, \
and executable query payloads are not saved-view fields",
)
})
.collect();
let rendered = ViewRefusal::from_diagnostics(diagnostics.clone()).render();
Err(view_rejection(rendered, &diagnostics))
}
/// `view_list` (T029): the registry in declaration order, as `tix view list`
/// renders it, plus each entry's normalized plan, extensions, and diagnostics.
fn view_list(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["scope"]) {
return Ok(value);
}
let scope = match opt_string(args, "scope")? {
Some(raw) => match cmd_views::parse_scope_kind(&raw) {
Ok(kind) => Some(kind),
Err(error) => return Ok(error_result(error.to_string())),
},
None => None,
};
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let mut rows = Vec::new();
let mut entries = Vec::new();
for view in service.list() {
if scope.is_some_and(|kind| view.scope.kind != kind) {
continue;
}
rows.push(format!(
"{}\t{}\t{}\t{}",
view.id,
view.name,
cmd_views::scope_label(&view.scope),
view.scope.resolution(&ctx.projects),
));
entries.push(match service.show(view.id.as_str()) {
Ok(detail) => view_value(
detail.view(),
Some(detail.plan()),
detail.diagnostics(),
&ctx.projects,
),
// A stored plan this build cannot interpret is listed (the CLI lists
// it too) with its refusal's diagnostics rather than silently dropped.
Err(refusal) => view_value(&view, None, refusal.diagnostics(), &ctx.projects),
});
}
let mut text = String::new();
for row in &rows {
text.push_str(row);
text.push('\n');
}
Ok(success_result(text, json!({ "views": entries })))
}
/// `view_show` (T029): one definition — id, name, scope, normalized plan,
/// preserved extensions, and diagnostics.
fn view_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["view"]) {
return Ok(value);
}
let reference = req_string(args, "view")?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
match service.show(&reference) {
Ok(detail) => Ok(success_result(
view_show_text(
detail.view(),
detail.plan(),
&ctx.projects,
detail.diagnostics(),
),
view_value(
detail.view(),
Some(detail.plan()),
detail.diagnostics(),
&ctx.projects,
),
)),
Err(refusal) => Ok(view_refusal_result(&refusal)),
}
}
/// `view_create` (T030): a new definition under a freshly allocated stable id.
fn view_create(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(
args,
&[
"name",
"scope",
"project",
"owner",
"shared_with",
"filter",
"sort",
"group",
"hide_columns",
"show_mismatch",
],
) {
return Ok(value);
}
let name = req_string(args, "name")?;
let scope_args = view_scope_args(args)?;
let plan_args = view_plan_args(args)?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let scope = match cmd_views::build_scope(&scope_args) {
Ok(scope) => scope,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
let plan = match cmd_views::plan_from_args(store, &plan_args) {
Ok(plan) => plan,
Err(error) => return Ok(view_filter_rejection(&error)),
};
match service.create(ViewDefinition::new(name, scope, plan)) {
Ok(outcome) => {
let audit = outcome.audit().cloned();
let id = outcome.into_value();
Ok(success_result(
format!("Created {id}"),
view_accepted_value("accepted", "create", &id, &service, &ctx.projects, audit),
))
}
Err(error) => Ok(view_write_result(&error)),
}
}
/// `view_update` (T030): merge semantics — only the named fields change, so a
/// rename cannot drop the plan and an unnamed plan field survives.
fn view_update(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(
args,
&[
"view",
"name",
"filter",
"sort",
"group",
"hide_columns",
"show_mismatch",
],
) {
return Ok(value);
}
let reference = req_string(args, "view")?;
let name = opt_string(args, "name")?;
let plan_args = view_plan_args(args)?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let detail = match service.show(&reference) {
Ok(detail) => detail,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
let new_name = name.unwrap_or_else(|| detail.view().name.clone());
let scope = detail.view().scope.clone();
let plan = if plan_args.is_any() {
match cmd_views::overlay_plan_args(store, detail.plan(), &plan_args) {
Ok(plan) => plan,
Err(error) => return Ok(view_filter_rejection(&error)),
}
} else {
detail.plan().clone()
};
match service.update(&reference, ViewDefinition::new(new_name, scope, plan)) {
Ok(outcome) => {
let no_op = outcome.is_noop();
let audit = outcome.audit().cloned();
let id = outcome.into_value();
let (status, word) = if no_op {
("no-op", "Unchanged")
} else {
("accepted", "Updated")
};
Ok(success_result(
format!("{word} {id}"),
view_accepted_value(status, "update", &id, &service, &ctx.projects, audit),
))
}
Err(error) => Ok(view_write_result(&error)),
}
}
/// `view_duplicate` (T030): copy under a new stable id, carrying the source's
/// scope, plan, and preserved extensions verbatim.
fn view_duplicate(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["view", "name"]) {
return Ok(value);
}
let reference = req_string(args, "view")?;
let name = opt_string(args, "name")?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
match service.duplicate(&reference, name.as_deref()) {
Ok(outcome) => {
let audit = outcome.audit().cloned();
let id = outcome.into_value();
Ok(success_result(
format!("Duplicated {id}"),
view_accepted_value("accepted", "duplicate", &id, &service, &ctx.projects, audit),
))
}
Err(error) => Ok(view_write_result(&error)),
}
}
/// `view_delete` (T030): the **definition only**, and only under a deliberate
/// confirmation (C-005).
fn view_delete(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["view", "confirm"]) {
return Ok(value);
}
let reference = req_string(args, "view")?;
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(view_rejection(
DELETE_CONFIRMATION.to_string(),
&[ViewDiagnostic::new(
ViewDiagnosticKind::InvalidView,
DELETE_CONFIRMATION,
)
.with_field("confirm")
.with_remedy(
"pass `confirm: true` to acknowledge that only the saved definition is removed",
)],
));
}
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
match service.delete(&reference, DeleteConfirmed::confirmed()) {
Ok(outcome) => {
let audit = outcome.audit().cloned();
let removed = outcome.into_value();
let view = removed.view();
let plan = ViewPlan::from_toml(&view.plan).ok();
let mut text = format!("Deleted {}", view.id);
if removed.marker_cleared() {
text.push_str("\nactive-marker: cleared");
}
Ok(success_result(
text,
json!({
"status": "accepted",
"operation": "delete",
"view": view_value(view, plan.as_ref(), &[], &ctx.projects),
"marker_cleared": removed.marker_cleared(),
"audit": audit.as_ref().map(audit_value),
}),
))
}
Err(error) => Ok(view_write_result(&error)),
}
}
/// `view_share` (T030/FR-003): replace the explicit scope metadata, keeping the
/// name and plan. Scope stays metadata — no account, no permission (C-003).
fn view_share(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["view", "scope", "project", "owner", "shared_with"])
{
return Ok(value);
}
let reference = req_string(args, "view")?;
let scope_args = view_scope_args(args)?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let detail = match service.show(&reference) {
Ok(detail) => detail,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
let name = detail.view().name.clone();
let plan = detail.plan().clone();
let scope = match cmd_views::build_scope(&scope_args) {
Ok(scope) => scope,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
match service.update(&reference, ViewDefinition::new(name, scope, plan)) {
Ok(outcome) => {
let no_op = outcome.is_noop();
let audit = outcome.audit().cloned();
let id = outcome.into_value();
let (status, word) = if no_op {
("no-op", "Unchanged")
} else {
("accepted", "Shared")
};
Ok(success_result(
format!("{word} {id}"),
view_accepted_value(status, "share", &id, &service, &ctx.projects, audit),
))
}
Err(error) => Ok(view_write_result(&error)),
}
}
/// `view_apply` (T031): read-only apply — plan, issue set, board summary, active
/// marker, and diagnostics. Writes nothing (C-005).
fn view_apply(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(args, &["view"]) {
return Ok(value);
}
let reference = req_string(args, "view")?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
// Read the board and the category resolution *before* the service takes the
// mutable borrow of the config — the same order `commands::views::run` uses.
let board = ctx.config.default_config().clone();
let resolution = crate::workflow::active_resolution(&ctx.config).1;
let mut service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let detail = match service.show(&reference) {
Ok(detail) => detail,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
// The marker is installed after a successful resolve and is informational:
// nothing on disk changes and live filters are never overwritten (C-006).
if let Err(refusal) = service.mark_active(&reference, Some(ViewSurface::Mcp)) {
return Ok(view_refusal_result(&refusal));
}
let corpus = match Corpus::build(store) {
Ok(corpus) => corpus,
Err(error) => return Ok(error_result(error.to_string())),
};
let result = match service.apply(&reference, &board, &corpus, &resolution) {
Ok(result) => result,
Err(refusal) => return Ok(view_refusal_result(&refusal)),
};
let marker = service.active();
Ok(success_result(
view_apply_text(detail.view(), detail.plan(), &result),
json!({
"view_id": detail.view().id.as_str(),
"name": detail.view().name,
"scope": scope_value(&detail.view().scope),
"resolution": detail.view().scope.resolution(&ctx.projects).as_str(),
"plan": plan_value(detail.plan()),
"active": marker.map(marker_value),
"issues": result.issue_numbers(),
"count": result.issue_numbers().len(),
"board": board_summary_value(result.board()),
"diagnostics": result
.diagnostics()
.iter()
.map(diagnostic_value)
.collect::<Vec<_>>(),
}),
))
}
/// `view_status` (T031): the saved plan against a live plan assembled from the
/// plan arguments here, field by field. Read-only, informational (C-006).
fn view_status(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Err(value) = view_arguments(
args,
&[
"view",
"filter",
"sort",
"group",
"hide_columns",
"show_mismatch",
],
) {
return Ok(value);
}
let reference = opt_string(args, "view")?;
let live_args = view_plan_args(args)?;
let mut ctx = match view_context(store) {
Ok(ctx) => ctx,
Err(value) => return Ok(value),
};
let service = ViewService::new(&mut ctx.config, &ctx.projects, &ctx.path);
let live = match cmd_views::plan_from_args(store, &live_args) {
Ok(plan) => plan,
Err(error) => return Ok(view_filter_rejection(&error)),
};
let (divergence, view) = match &reference {
Some(reference) => match service.show(reference) {
Ok(detail) => {
let divergence = ViewDivergence::between(detail.plan(), &live);
(divergence, Some(detail))
}
Err(refusal) => return Ok(view_refusal_result(&refusal)),
},
// No reference is no saved plan to compare: the service's explicit
// "no active view" diagnostic, never a silent `in_sync: true`.
None => (ViewDivergence::missing_marker(&live), None),
};
let mut text = String::new();
if let Some(detail) = &view {
text.push_str(&format!(
"id: {}\nname: {}\n",
detail.view().id,
detail.view().name
));
}
text.push_str(&format!("in-sync: {}\n", divergence.in_sync()));
for difference in divergence.differences() {
text.push_str(&format!(
"difference: {} saved={} live={}\n",
difference.field(),
difference.saved().unwrap_or("(unset)"),
difference.live().unwrap_or("(unset)"),
));
}
for diagnostic in divergence.diagnostics() {
text.push_str(&format!(
"diagnostic: {}\n",
cmd_views::render_diagnostic(diagnostic)
));
}
Ok(success_result(
text,
json!({
"in_sync": divergence.in_sync(),
// The identity the comparison is about: the named view, exactly as
// `tix view status <id>` prints it. A comparison with no reference
// has no id, which is a fact about the request, not an omission.
"view_id": view.as_ref().map(|detail| detail.view().id.as_str()),
"saved_plan": divergence.saved_plan().map(plan_value),
"live_plan": plan_value(divergence.live_plan()),
"differences": divergence
.differences()
.iter()
.map(|difference| json!({
"field": difference.field(),
"saved": difference.saved(),
"live": difference.live(),
}))
.collect::<Vec<_>>(),
"diagnostics": divergence
.diagnostics()
.iter()
.map(diagnostic_value)
.collect::<Vec<_>>(),
}),
))
}
// -- argument adaptation --------------------------------------------------
/// The saved-view scope arguments, read from the same key names the CLI's
/// `ViewScopeArgs` flags have.
fn view_scope_args(args: &Value) -> std::result::Result<crate::cli::ViewScopeArgs, ()> {
Ok(crate::cli::ViewScopeArgs {
scope: opt_string(args, "scope")?,
project: opt_string(args, "project")?,
owner: opt_string(args, "owner")?,
shared_with: opt_string(args, "shared_with")?,
})
}
/// The saved-view plan arguments, read from the same key names the CLI's
/// `ViewPlanArgs` flags have, so the two surfaces shape one plan (C-007).
fn view_plan_args(args: &Value) -> std::result::Result<crate::cli::ViewPlanArgs, ()> {
Ok(crate::cli::ViewPlanArgs {
filter: opt_string(args, "filter")?,
sort: opt_string(args, "sort")?,
group: opt_string(args, "group")?,
hide_columns: opt_string(args, "hide_columns")?,
show_mismatch: opt_bool(args, "show_mismatch")?,
})
}
// -- result projection ----------------------------------------------------
/// One saved view as a surface DTO: identity, scope, normalized plan, preserved
/// extensions, and diagnostics (C-007).
fn view_value(
view: &SavedView,
plan: Option<&ViewPlan>,
diagnostics: &[ViewDiagnostic],
projects: &BTreeSet<String>,
) -> Value {
json!({
"id": view.id.as_str(),
"name": view.name,
"scope": scope_value(&view.scope),
"scope_label": cmd_views::scope_label(&view.scope),
"resolution": view.scope.resolution(projects).as_str(),
"plan": plan.map(plan_value),
"extensions": json_table(&view.extensions),
"diagnostics": diagnostics.iter().map(diagnostic_value).collect::<Vec<_>>(),
})
}
/// The accepted half of a view mutation (T030): its status, the resulting
/// record, and the audit event when one was emitted.
fn view_accepted_value(
status: &str,
operation: &str,
id: &ViewId,
service: &ViewSvc<'_>,
projects: &BTreeSet<String>,
audit: Option<ViewAuditEvent>,
) -> Value {
let view = match service.show(id.as_str()) {
Ok(detail) => view_value(
detail.view(),
Some(detail.plan()),
detail.diagnostics(),
projects,
),
Err(refusal) => json!({
"id": id.as_str(),
"diagnostics": refusal.diagnostics().iter().map(diagnostic_value).collect::<Vec<_>>(),
}),
};
json!({
"status": status,
"operation": operation,
"view": view,
"audit": audit.as_ref().map(audit_value),
})
}
/// A **rejected** view operation: the CLI's exact error text, plus the ordered
/// diagnostics the shared registry produced.
///
/// `isError: true` like every other domain failure. Unlike the issue tools, the
/// diagnostics are also structured: `surface-parity.md` asks every surface to
/// expose the same diagnostics, and an agent must be able to act on `field`
/// without parsing prose (T030).
fn view_refusal_result(refusal: &ViewRefusal) -> Value {
view_rejection(refusal.render(), refusal.diagnostics())
}
/// A rejection with an explicit diagnostic list.
fn view_rejection(text: String, diagnostics: &[ViewDiagnostic]) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": {
"status": "rejected",
"diagnostics": diagnostics.iter().map(diagnostic_value).collect::<Vec<_>>(),
},
"isError": true,
})
}
/// A view **write** that was refused (nothing written) or failed for an
/// infrastructure reason (the atomic save did not complete). The registry keeps
/// the two apart and so does this projection.
fn view_write_result(error: &ViewWriteError) -> Value {
match error {
ViewWriteError::Refused(refusal) => view_refusal_result(refusal),
ViewWriteError::Failed(failure) => json!({
"content": [{ "type": "text", "text": failure.to_string() }],
"structuredContent": { "status": "failed", "diagnostics": [] },
"isError": true,
}),
}
}
/// A plan the **existing grammar** refused while it was being assembled: an
/// unknown `category:`/`scope:` token, an unresolvable `parent:` selector.
///
/// The message is the resolver's own, verbatim — the same text
/// `tix view create --filter …` prints — and it is given the plan's own
/// `invalid_filter` kind and `plan.filter` field so a client can act on it
/// structurally rather than by reading the sentence.
fn view_filter_rejection(error: &crate::error::TixError) -> Value {
let diagnostic = ViewDiagnostic::new(ViewDiagnosticKind::InvalidFilter, error.to_string())
.with_field("plan.filter");
view_rejection(error.to_string(), &[diagnostic])
}
/// The structured diagnostics, one shape for every view tool.
fn diagnostic_value(diagnostic: &ViewDiagnostic) -> Value {
json!({
"kind": diagnostic.kind().as_str(),
"field": diagnostic.field(),
"message": diagnostic.message(),
"remedy": diagnostic.remedy(),
"view_id": diagnostic.view_id().map(ViewId::as_str),
})
}
/// The scope family and its metadata (scope is data, never an identity).
fn scope_value(scope: &ViewScope) -> Value {
json!({
"kind": scope.kind.as_str(),
"project_id": scope.project_id,
"owner": scope.owner,
"shared_with": scope.shared_with,
"extensions": json_table(&scope.extensions),
})
}
/// The normalized plan in the **persisted** `[plan]` shape, so the plan a client
/// sees is the plan a round trip stores (C-007/NFR-002).
fn plan_value(plan: &ViewPlan) -> Value {
json_table(&plan.to_toml())
}
/// A TOML table as JSON, or `null` when a value has no JSON spelling.
fn json_table(table: &toml::Table) -> Value {
serde_json::to_value(table).unwrap_or(Value::Null)
}
/// One accepted mutation's audit event.
fn audit_value(event: &ViewAuditEvent) -> Value {
json!({
"mutation": event.kind().as_str(),
"view_id": event.view_id().as_str(),
"view_name": event.view_name(),
})
}
/// The active marker as a DTO (informational provenance only).
fn marker_value(marker: &ActiveViewMarker) -> Value {
json!({
"view_id": marker.view_id().as_str(),
"source": marker.source().map(ViewSurface::as_str),
"applied_at": marker.applied_at(),
"applied_plan": plan_value(marker.applied_plan()),
})
}
/// The applied board as a summary: one count per visible and hidden column, and
/// the mismatched pseudo-column. Derived from the shared `BoardState` the filter
/// path built — never re-counted from the corpus.
fn board_summary_value(board: &crate::ui::board::BoardState) -> Value {
json!({
"columns": board.columns.iter().map(board_column_value).collect::<Vec<_>>(),
"hidden": board.hidden.iter().map(board_column_value).collect::<Vec<_>>(),
"mismatch": board.mismatch.cards.len(),
})
}
fn board_column_value(column: &crate::ui::board::BoardColumn) -> Value {
json!({ "name": column.name, "count": column.cards.len() })
}
// -- text mirrors ---------------------------------------------------------
/// `tix view show`'s stdout, byte for byte.
fn view_show_text(
view: &SavedView,
plan: &ViewPlan,
projects: &BTreeSet<String>,
diagnostics: &[ViewDiagnostic],
) -> String {
let mut out = format!(
"id: {}\nname: {}\nscope: {}\nresolution: {}\n",
view.id,
view.name,
cmd_views::scope_label(&view.scope),
view.scope.resolution(projects),
);
for line in cmd_views::plan_lines(plan) {
out.push_str(&line);
out.push('\n');
}
for diagnostic in diagnostics {
out.push_str(&format!(
"diagnostic: {}\n",
cmd_views::render_diagnostic(diagnostic)
));
}
out
}
/// `tix view apply`'s stdout, byte for byte.
fn view_apply_text(view: &SavedView, plan: &ViewPlan, result: &ViewApplyResult) -> String {
let mut out = format!(
"id: {}\nname: {}\nscope: {}\n",
view.id,
view.name,
cmd_views::scope_label(&view.scope),
);
for line in cmd_views::plan_lines(plan) {
out.push_str(&line);
out.push('\n');
}
out.push_str(&format!("active: {}\n", view.id));
out.push_str(&format!(
"issues: {}\n",
cmd_views::join_numbers(result.issue_numbers())
));
out.push_str(&format!("count: {}\n", result.issue_numbers().len()));
for diagnostic in result.diagnostics() {
out.push_str(&format!(
"diagnostic: {}\n",
cmd_views::render_diagnostic(diagnostic)
));
}
out
}
// ---------------------------------------------------------------------------
// WP08 / T038-T042 - the recurring tools
// ---------------------------------------------------------------------------
//
// The MCP peer of `tix recurring`, over the same services and - through
// `cmd_recurring` - the same resolver and the same date context. MCP owns no
// scheduler, no rule parser, no series writer and no second clock: the arguments
// are read, `SeriesIndex::build` indexes what the store already holds, and
// `detect`, `generate` and `apply_lifecycle` make every decision. A mutation
// needs `confirm: true`, exactly as the CLI needs `--yes`, and every one of them
// returns the shared outcome vocabulary (`created`/`no-op`/`rejected`,
// `applied`/`no-op`/`rejected`) rather than a surface-specific word (T040).
/// The series index for the current repository: one read of the issue tree, one
/// build (T038). Exactly the index the CLI, the board and the Schedule page build.
fn recurring_index(store: &Store) -> TixResult<SeriesIndex> {
Ok(SeriesIndex::build(&store.list()?))
}
/// One series as every surface reports it (T040): identity, metadata, the derived
/// cursor and skipped dates, and the rule's own summary rather than a second one.
fn series_value(series: &Series) -> Value {
json!({
"id": series.id.as_str(),
"name": series.name,
"state": series.state.as_str(),
"rule": series.rule.summary(),
"rule_kind": series.rule.kind.as_str(),
"anchor": match series.rule.anchor_key() {
Ok(key) => Value::String(key.as_str().to_string()),
Err(_) => Value::Null,
},
"next_cursor": series.next_cursor.as_ref().map(DateKey::as_str),
"skipped": series.skipped.iter().map(DateKey::as_str).collect::<Vec<_>>(),
"issue_number": series.issue_number,
"filename": series.filename,
})
}
/// The CLI's `tix recurring list` table, row for row (`NFR-003`).
fn series_rows_text(index: &SeriesIndex) -> String {
let mut out = String::from("id\tname\tstate\tnext_cursor\tskipped\n");
for series in index.series() {
out.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\n",
series.id,
series.name,
series.state.as_str(),
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-"),
series.skipped.len()
));
}
out
}
/// The CLI's `tix recurring show` block, line for line (`NFR-003`).
fn series_detail_text(series: &Series, occurrences: &[&str]) -> String {
let anchor = match series.rule.anchor_key() {
Ok(key) => key.as_str().to_string(),
Err(_) => "(invalid)".to_string(),
};
let skipped = if series.skipped.is_empty() {
"-".to_string()
} else {
series
.skipped
.iter()
.map(DateKey::as_str)
.collect::<Vec<_>>()
.join(",")
};
let occurrences = if occurrences.is_empty() {
"-".to_string()
} else {
occurrences.join(",")
};
format!(
"id: {}\nname: {}\nstate: {}\nrule: {}\nanchor: {}\nnext_cursor: {}\nskipped: {}\n\
issue: #{} ({})\noccurrences: {}\n",
series.id,
series.name,
series.state.as_str(),
series.rule.summary(),
anchor,
series
.next_cursor
.as_ref()
.map(DateKey::as_str)
.unwrap_or("-"),
skipped,
series.issue_number,
series.filename,
occurrences
)
}
/// The index's findings, as the block a surface prints and as values (T040).
fn series_diagnostics(index: &SeriesIndex) -> (String, Value) {
let mut text = String::new();
for diagnostic in index.diagnostics() {
text.push_str(&diagnostic.render());
text.push('\n');
}
let values: Vec<Value> = index
.diagnostics()
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"issue_number": diagnostic.issue_number,
"filename": diagnostic.filename,
"field": diagnostic.field,
"message": diagnostic.message,
})
})
.collect();
(text, Value::Array(values))
}
/// The CLI's `tix recurring detect` output, line for line (`NFR-003`).
fn detection_text(detection: &Detection) -> String {
let mut out = String::new();
if detection.is_empty() {
out.push_str(&format!(
"No occurrences due across {} series.\n",
detection.series_examined()
));
} else {
out.push_str("series_id\toccurrence_key\tdue_date\trule\n");
for warning in detection.warnings() {
out.push_str(&format!(
"{}\t{}\t{}\t{}\n",
warning.series_id, warning.occurrence_key, warning.due_date, warning.rule_summary
));
}
}
for note in detection.notes() {
out.push_str(&format!(
"note: {}\t{}\t{}\n",
note.kind.as_str(),
note.series_id,
note.message
));
}
out
}
/// The due answer as values (T040): the date context it was resolved against, each
/// warning's **stable occurrence key** (what a lifecycle tool takes), the actions
/// the warning accepts, and why a series that was examined is not due.
fn detection_value(detection: &Detection, context: &RuleContext) -> Value {
json!({
"date": context.today.to_string(),
"timezone": context.timezone.as_ref().map(ToString::to_string),
"series_examined": detection.series_examined(),
"warnings": detection
.warnings()
.iter()
.map(|warning| {
json!({
"summary": warning.summary(),
"series_id": warning.series_id.as_str(),
"series_name": warning.series_name,
"occurrence_key": warning.occurrence_key.as_str(),
"due_date": warning.due_date.as_str(),
"rule": warning.rule_summary,
"available_actions": warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>(),
"notes": detection
.notes()
.iter()
.map(|note| {
json!({
"kind": note.kind.as_str(),
"series_id": note.series_id.as_str(),
"message": note.message,
})
})
.collect::<Vec<_>>(),
})
}
/// The CLI's generation lines, word for word.
fn generation_text(outcome: &GenerationOutcome) -> String {
match outcome {
GenerationOutcome::Created {
key,
issue_number,
filename,
..
} => format!("Created occurrence #{issue_number} for {key}\n file: {filename}\n"),
GenerationOutcome::NoOp { key, message, .. } => {
format!("No-op for {key}: {message}\n")
}
GenerationOutcome::Rejected { .. } => outcome.render(),
}
}
/// The generation outcome as values (T040).
fn generation_value(outcome: &GenerationOutcome) -> Value {
match outcome {
GenerationOutcome::Created {
series_id,
key,
date,
issue_number,
filename,
} => json!({
"status": "created",
"series_id": series_id.as_str(),
"occurrence_key": key.as_str(),
"date": date.as_str(),
"issue_number": issue_number,
"filename": filename,
}),
GenerationOutcome::NoOp {
series_id,
key,
state,
message,
} => json!({
"status": "no-op",
"series_id": series_id.as_str(),
"occurrence_key": key.as_str(),
"key_state": key_state_str(*state),
"message": message,
}),
GenerationOutcome::Rejected { diagnostics } => json!({
"status": "rejected",
"diagnostics": diagnostics
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"message": diagnostic.message,
})
})
.collect::<Vec<_>>(),
}),
}
}
/// The shared word for a key's state (`generated`/`skipped`/`free`).
fn key_state_str(state: KeyState) -> &'static str {
match state {
KeyState::Generated => "generated",
KeyState::Skipped => "skipped",
KeyState::Free => "free",
}
}
/// The CLI's lifecycle lines, word for word.
fn lifecycle_text(series_id: &SeriesId, outcome: &LifecycleOutcome) -> String {
match outcome {
LifecycleOutcome::Applied {
action,
state,
detail,
..
} => format!(
"{}: series {series_id} is now {}\n {detail}\n",
action.as_str(),
state.as_str()
),
LifecycleOutcome::NoOp {
action, message, ..
} => format!("{}: no-op: {message}\n", action.as_str()),
LifecycleOutcome::Rejected { .. } => outcome.render(),
}
}
/// The lifecycle outcome as values (T040), including the History entry it wrote.
fn lifecycle_value(outcome: &LifecycleOutcome) -> Value {
match outcome {
LifecycleOutcome::Applied {
series_id,
action,
state,
key,
detail,
} => json!({
"status": "applied",
"series_id": series_id.as_str(),
"action": action.as_str(),
"state": state.as_str(),
"occurrence_key": key.as_ref().map(|key| key.as_str()),
"history": {
"kind": crate::model::HistoryEventKind::TransitionAccepted.as_str(),
"detail": detail,
},
}),
LifecycleOutcome::NoOp {
series_id,
action,
message,
} => json!({
"status": "no-op",
"series_id": series_id.as_str(),
"action": action.as_str(),
"message": message,
}),
LifecycleOutcome::Rejected { diagnostics } => json!({
"status": "rejected",
"diagnostics": diagnostics
.iter()
.map(lifecycle_diagnostic_value)
.collect::<Vec<_>>(),
}),
}
}
/// One lifecycle diagnostic, with the kind that makes it branchable.
fn lifecycle_diagnostic_value(diagnostic: &LifecycleDiagnostic) -> Value {
json!({
"kind": diagnostic.kind.as_str(),
"message": diagnostic.message,
})
}
/// A rejected outcome: `isError: true`, the CLI's rendered refusal as the text, and
/// the structured outcome beside it.
///
/// The same shape the templates and views families use for a rejected write
/// (`template_error_result`, the view rejection), and for the reason T040 gives:
/// a rejection has to stay diagnosable, and a sentence alone is not.
fn rejected_result(text: String, structured: Value) -> Value {
json!({
"content": [{ "type": "text", "text": text }],
"structuredContent": structured,
"isError": true,
})
}
/// The confirmation a request carries: MCP's `confirm`, mapped onto the service's
/// `confirmed` flag.
///
/// MCP does **not** gate this itself, and that is the point: every service refuses
/// an unconfirmed request with its own `not_confirmed` diagnostic *before* the store
/// is touched, so the CLI's `--yes` and this flag produce the same refusal word for
/// word because they are the same code path (C-006). A second gate here could only
/// drift from it.
fn recurring_confirmed(args: &Value) -> std::result::Result<bool, ()> {
Ok(opt_bool(args, "confirm")?.unwrap_or(false))
}
/// Who the History entry is attributed to: the caller's `actor`, or the boundary's
/// identity, which is what the CLI records when it is not told.
fn recurring_actor(args: &Value) -> std::result::Result<String, ()> {
Ok(opt_string(args, "actor")?.unwrap_or_else(cmd_recurring::actor))
}
fn list_recurring_series(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let (diagnostics_text, diagnostics) = series_diagnostics(&index);
let mut text = series_rows_text(&index);
text.push_str(&diagnostics_text);
Ok(success_result(
text,
json!({
"series": index.series().map(series_value).collect::<Vec<_>>(),
"series_count": index.series_count(),
"diagnostics": diagnostics,
}),
))
}
fn get_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = req_string(args, "series")?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let occurrences: Vec<&crate::recurrence::series::OccurrenceRecord> = index
.occurrences()
.filter(|record| record.series_id == series.id)
.collect();
let dates: Vec<&str> = occurrences
.iter()
.map(|record| record.date.as_str())
.collect();
let (diagnostics_text, diagnostics) = series_diagnostics(&index);
let mut text = series_detail_text(&series, &dates);
text.push_str(&diagnostics_text);
Ok(success_result(
text,
json!({
"series": series_value(&series),
"occurrences": occurrences
.iter()
.map(|record| {
json!({
"key": record.key.as_str(),
"date": record.date.as_str(),
"issue_number": record.issue_number,
"filename": record.filename,
})
})
.collect::<Vec<_>>(),
"diagnostics": diagnostics,
}),
))
}
fn detect_recurring_due(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let detection = detect(&index, &context);
Ok(success_result(
detection_text(&detection),
detection_value(&detection, &context),
))
}
fn generate_recurring_occurrence(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = req_string(args, "series")?;
let date = req_string(args, "date")?;
let actor = recurring_actor(args)?;
let confirmed = recurring_confirmed(args)?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
// The key is derived from the series' **stable id** and the explicit date, never
// from the display name: identity is the id (C-002).
let context = match cmd_recurring::context_for(Some(&date), None) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let key = OccurrenceKey::new(&series.id, &DateKey::from_date(context.today));
let request = GenerationRequest::new(series.id.clone(), key, actor);
let request = if confirmed {
request.confirm()
} else {
request
};
match generate(config, store, &index, &request) {
Ok(outcome @ GenerationOutcome::Rejected { .. }) => Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
generation_value(&outcome),
)),
Ok(outcome) => Ok(success_result(
generation_text(&outcome),
generation_value(&outcome),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
/// The six lifecycle tools are the six [`LifecycleAction`]s, so the shared service
/// sees exactly what `tix recurring <action>` sends it (T039).
fn lifecycle_tool(store: &Store, args: &Value, action: LifecycleAction) -> ToolOutcome {
let raw = req_string(args, "series")?;
let confirmed = recurring_confirmed(args)?;
// A keyed action is about one date and a state action is not; a `date` on a state
// action is refused rather than ignored, so a caller that meant something else
// finds out instead of getting a silent pause.
let date = match (action.needs_key(), opt_string(args, "date")?) {
(true, Some(date)) => date,
(true, None) => {
return Ok(error_result(format!(
"`{}` is about one occurrence: `date` (`YYYY-MM-DD`) is required, because \
the key that records the decision is `<series-id>@<date>`",
action.as_str()
)));
}
(false, Some(_)) => {
return Ok(error_result(format!(
"`{}` is a series-level action and takes no `date`: it is not about one \
occurrence",
action.as_str()
)));
}
(false, None) => String::new(),
};
let actor = recurring_actor(args)?;
let index = match recurring_index(store) {
Ok(index) => index,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let series = match cmd_recurring::resolve(&index, &raw) {
Ok(series) => series,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let mut request = LifecycleRequest::new(series.id.clone(), action, actor);
if action.needs_key() {
let context = match cmd_recurring::context_for(Some(&date), None) {
Ok(context) => context,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
request = request.about(OccurrenceKey::new(
&series.id,
&DateKey::from_date(context.today),
));
}
if let Some(reason) = opt_string(args, "reason")? {
request = request.because(reason);
}
let request = if confirmed {
request.confirm()
} else {
request
};
match apply_lifecycle(store, &request) {
Ok(outcome @ LifecycleOutcome::Rejected { .. }) => Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
lifecycle_value(&outcome),
)),
Ok(outcome) => Ok(success_result(
lifecycle_text(&series.id, &outcome),
lifecycle_value(&outcome),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn pause_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Pause)
}
fn resume_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Resume)
}
fn end_recurring_series(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::End)
}
fn skip_recurring_occurrence(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Skip)
}
fn defer_recurring_occurrence(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Defer)
}
fn ignore_recurring_occurrence(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
lifecycle_tool(store, args, LifecycleAction::Ignore)
}
// ---------------------------------------------------------------------------
// WP08 (mission `repeating-cycle-schedules-01M3C7W1`) — the cycle-schedule surface
// ---------------------------------------------------------------------------
//
// The MCP peer of `tix cycle schedule …`. Every verb is a thin adapter: the
// arguments are read, the same `ScheduleIndex` the CLI builds is built from the
// records on disk, and the same service is called. This section owns no cadence
// arithmetic, no cursor, no snapshot writer and no History grammar (C-008) — the
// outcomes it returns are the services' own (`created`/`no-op`/`rejected`,
// `applied`/`no-op`/`rejected`, and the warning and diagnostic *kinds*), never a
// private spelling.
//
// # One clock, at the boundary
//
// Nothing below reads a clock. `detect` and `generate` take an explicit `date`
// and otherwise resolve today through the same boundary the CLI uses
// (`cmd_recurring::context_for`); a lifecycle action's audit instant is resolved
// the same way (`crate::model::utc_now_rfc3339`), exactly as
// `tix cycle schedule <action>` resolves it, and an explicit `timestamp` overrides
// it for a caller that needs a reproducible record.
//
// # Confirmation
//
// Every mutating verb requires `confirm: true`; without it the tool refuses with
// the CLI's own sentence — built through the crate's one renderer, under the same
// `E-tix-cycle-schedule` id and the same `not_confirmed` kind — and writes
// nothing (C-004). The service re-checks consent as well, so the gate cannot be
// bypassed by a caller that reaches a service path directly.
//
// # One registry for the config CRUD
//
// `create`/`edit`/`delete` are the shared [`crate::cycle_schedule::registry`]
// service's — the same three functions `tix cycle schedule …` and the TUI's `F8`
// Schedule page call (WP07 review finding H4). This adapter reads the arguments,
// hands the service a `ScheduleDraft`/`ScheduleEdit`/selector, and reports what it
// returns; the id-exists refusal, the spent-id guard (a stored record **or** a
// closed snapshot), the selector precedence and every refusal sentence therefore
// have one implementation rather than the copy this file used to carry, which had
// already drifted behind the CLI's.
/// The registry, the project root and the `tix.toml` path one call needs.
///
/// Re-read per call, for the reason the project/epic tools re-read: the server
/// bootstrapped once, so a config snapshot would not see a schedule this session
/// just created — while the CLI, a fresh process each time, always does. The path
/// is found through the issue directory (the same upward walk every command uses),
/// so it is the file the rest of the session reads.
fn cycle_schedule_config(
store: &Store,
) -> TixResult<(Config, std::path::PathBuf, std::path::PathBuf)> {
let (config, path) = registry_config(store)?;
let root = path
.parent()
.map_or_else(|| std::path::PathBuf::from("."), Path::to_path_buf);
Ok((config, root, path))
}
/// The read model, built from the configuration and the records on disk — the same
/// one pass the CLI makes, so a stale snapshot can never drive a write (WP02/WP04).
fn cycle_schedule_index(config: &Config, root: &Path) -> TixResult<ScheduleIndex> {
Ok(ScheduleIndex::build(config, &CycleStore::new(root).list()?))
}
/// A domain failure as the FR-014 failure shape, rendered by the one boundary
/// renderer — byte for byte what the CLI prints to stderr.
fn cycle_schedule_failure(error: &crate::error::TixError) -> Value {
error_result(crate::error::render_error(error))
}
/// The refusal for an unconfirmed mutation: the CLI's own sentence, so
/// `tix cycle schedule <action>` without `--confirm` and this tool without
/// `confirm: true` read the same. Nothing is written (C-004).
fn cycle_schedule_unconfirmed(action: &str) -> Value {
cycle_schedule_failure(®istry::refuse(
"not_confirmed",
format!(
"`tix cycle schedule {action}` changes state; pass `--confirm` to proceed — nothing \
was written"
),
))
}
/// Required `u32` argument.
fn req_u32(args: &Value, key: &str) -> std::result::Result<u32, ()> {
let value = args.get(key).ok_or(())?;
let number = value.as_u64().ok_or(())?;
u32::try_from(number).map_err(|_| ())
}
/// Optional `u32` argument; `null` counts as absent, like [`opt_string`].
fn opt_u32(args: &Value, key: &str) -> std::result::Result<Option<u32>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_u64()
.and_then(|number| u32::try_from(number).ok())
.map(Some)
.ok_or(())
}
/// How many candidates starting on or before `through` the schedule would create.
///
/// A **horizon selector**, not a second generator: it steps through the shared
/// arithmetic owner the generator itself uses, starting at the same
/// [`ScheduleIndex::next_key`] the generator starts at, and counts how many land on
/// or before the date. The creation itself is still the generator's, under its own
/// count contract (`look_ahead`).
fn cycle_candidates_through(
index: &ScheduleIndex,
id: &CycleScheduleId,
through: &str,
) -> TixResult<u32> {
let through = registry::canonical_date(through)?;
let Some(first) = index.next_key(id) else {
return Ok(0);
};
let Some(record) = index.schedule(id) else {
return Ok(0);
};
let Some(rule) = cadence_rule(record) else {
return Ok(0);
};
let mut count: u32 = 0;
let mut date = first.start_date.clone();
while let Some(start) = date {
if start.as_str() > through.as_str() {
break;
}
count = count.saturating_add(1);
if count == u32::MAX {
break;
}
let Ok(context) = cmd_recurring::context_for(Some(start.as_str()), None) else {
break;
};
match rule.next_occurrence(&context) {
Ok(next) => date = Some(next.as_str().to_string()),
Err(_) => break,
}
}
Ok(count)
}
/// Who a History entry is attributed to: the caller's `actor`, or the boundary's
/// identity — which is what the CLI records when it is not told.
fn cycle_schedule_actor(args: &Value) -> std::result::Result<String, ()> {
Ok(opt_string(args, "actor")?.unwrap_or_else(cmd_recurring::actor))
}
/// The cadence in words, through the shared rule's own summary, or the same
/// placeholder the CLI prints when the record defines no resolvable cadence.
fn cycle_cadence_summary(record: &CycleScheduleConfiguration) -> String {
cadence_rule(record)
.map(|rule| rule.summary())
.unwrap_or_else(|| "(no cadence)".to_string())
}
/// The duration as the CLI renders it: `"<count> <unit>"`, or `"<count> (no unit)"`.
fn cycle_duration_text(record: &CycleScheduleConfiguration) -> String {
match record.duration.unit {
Some(unit) => format!("{} {}", record.duration.count, unit.as_str()),
None => format!("{} (no unit)", record.duration.count),
}
}
/// The cursor as values, with the two derived fields a surface shows.
fn cycle_cursor_value(cursor: &GenerationCursor) -> Value {
json!({
"next_sequence": cursor.next_sequence,
"next_start": cursor.next_start,
"last_generated_key": cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str),
})
}
/// One instance as every surface reports it.
fn cycle_instance_value(cycle: &GeneratedCycle) -> Value {
json!({
"id": cycle.id.as_str(),
"schedule_id": cycle.schedule_id.as_str(),
"sequence": cycle.sequence,
"name": cycle.name,
"state": cycle.state.as_str(),
"start_date": cycle.start_date,
"due_date": cycle.due_date,
})
}
/// One schedule as values: identity, definition, its *derived* cursor and its
/// instance count — the `ScheduleIndex`'s answer, never a re-derivation.
fn cycle_schedule_value(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> Value {
let cursor = index.cursor(id);
json!({
"id": id.as_str(),
"name": record.name,
"state": record.state.as_str(),
"cadence": cycle_cadence_summary(record),
"cadence_kind": record.cadence.kind.map(RuleKind::as_str),
"interval": record.cadence.interval,
"unit": record.cadence.unit.map(IntervalUnit::as_str),
"duration": {
"count": record.duration.count,
"unit": record.duration.unit.map(IntervalUnit::as_str),
},
"start_date": record.start_date,
"timezone": record.timezone.as_ref().map(ToString::to_string),
"look_ahead": record.look_ahead,
"next_sequence": cursor.next_sequence,
"next_start": cursor.next_start,
"last_generated_key": cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str),
"generated": index.cycles_of(id).count(),
})
}
/// The CLI's `tix cycle schedule list` row, column for column (`NFR-003`).
fn cycle_schedule_row(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> String {
let cursor = index.cursor(id);
let next = cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string());
format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
id,
record.name,
record.state.as_str(),
cycle_cadence_summary(record),
cycle_duration_text(record),
record.start_date,
record.look_ahead,
next,
index.cycles_of(id).count()
)
}
/// The CLI's `tix cycle schedule show` block, line for line (`NFR-003`).
///
/// The identity findings the CLI prints to stderr are **not** appended: the text
/// channel is the CLI's stdout, and the findings travel in `structuredContent`
/// beside it, which is what keeps the two surfaces byte-comparable.
fn cycle_schedule_detail(
id: &CycleScheduleId,
record: &CycleScheduleConfiguration,
index: &ScheduleIndex,
) -> String {
let cursor = index.cursor(id);
let last = cursor
.last_generated_key
.as_ref()
.map(GeneratedCycleId::as_str)
.unwrap_or("-");
let next = cursor
.next_sequence
.map(|sequence| sequence.to_string())
.unwrap_or_else(|| "-".to_string());
let mut text = format!(
"id: {id}\nname: {}\nstate: {}\ncadence: {}\nduration: {}\nstart_date: {}\ntimezone: \
{}\nlook_ahead: {}\nnext_sequence: {next}\nnext_start: {}\nlast_generated_key: \
{last}\ngenerated: {}\n",
record.name,
record.state.as_str(),
cycle_cadence_summary(record),
cycle_duration_text(record),
record.start_date,
record
.timezone
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| "(none)".to_string()),
record.look_ahead,
cursor.next_start.as_deref().unwrap_or("-"),
index.cycles_of(id).count()
);
for cycle in index.cycles_of(id) {
text.push_str(&format!(
" {}\t{}\t{} -> {}\n",
cycle.id,
cycle.state.as_str(),
cycle.start_date,
cycle.due_date
));
}
text
}
/// The index's identity findings, as the block the CLI prints and as values.
///
/// `only` restricts the block to one schedule, exactly as `tix cycle schedule
/// show` filters; `None` reports the whole index, as `list` does.
fn cycle_schedule_diagnostics(
index: &ScheduleIndex,
only: Option<&CycleScheduleId>,
) -> (String, Value) {
let wanted: Vec<&crate::cycle_schedule::identity::IdentityDiagnostic> = index
.diagnostics()
.iter()
.filter(|diagnostic| match only {
Some(id) => diagnostic.schedule_id.as_ref() == Some(id),
None => true,
})
.collect();
let mut text = String::new();
for diagnostic in &wanted {
text.push_str(&diagnostic.render());
text.push('\n');
}
let values: Vec<Value> = wanted
.iter()
.map(|diagnostic| {
json!({
"kind": diagnostic.kind.as_str(),
"schedule_id": diagnostic.schedule_id.as_ref().map(CycleScheduleId::as_str),
"cycle_id": diagnostic.cycle_id.as_ref().map(GeneratedCycleId::as_str),
"field": diagnostic.field,
"message": diagnostic.message,
})
})
.collect();
(text, Value::Array(values))
}
/// One warning as values (T042): the stable schedule/instance identity, the kind,
/// the effective dates, the candidate a confirmed generation would create, the
/// explicit decisions the warning accepts, and the field-aware finding a `failure`
/// carries.
fn cycle_warning_value(warning: &crate::cycle_schedule::evaluate::ScheduleWarning) -> Value {
json!({
"kind": warning.kind.as_str(),
"schedule_id": warning.schedule_id.as_str(),
"cycle_id": warning.cycle_id.as_ref().map(GeneratedCycleId::as_str),
"effective_dates": warning.effective_dates.as_ref().map(|dates| json!({
"start": dates.start,
"due": dates.due,
})),
"next_generation": warning.next_generation.as_ref().map(|key| json!({
"id": key.id.as_str(),
"sequence": key.sequence,
"start_date": key.start_date,
})),
"available_actions": warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>(),
"diagnostic": warning.diagnostic.as_ref().map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
})),
})
}
/// The CLI's `tix cycle schedule detect` table, row for row (`NFR-003`).
fn cycle_detection_text(evaluation: &crate::cycle_schedule::evaluate::CycleEvaluation) -> String {
if evaluation.is_empty() {
return format!(
"No cycle-schedule warnings across {} schedule(s).\n",
evaluation.schedules_examined()
);
}
let mut text = String::from(
"schedule_id\tkind\tcycle_id\tstart\tdue\tnext_start\tnext_id\tactions\tdetail\n",
);
for warning in evaluation.warnings() {
let start = warning
.effective_dates
.as_ref()
.map(|dates| dates.start.as_str())
.unwrap_or("-");
let due = warning
.effective_dates
.as_ref()
.map(|dates| dates.due.as_str())
.unwrap_or("-");
let next_start = warning
.next_generation
.as_ref()
.and_then(|key| key.start_date.as_deref())
.unwrap_or("-");
let next_id = warning
.next_generation
.as_ref()
.map(|key| key.id.as_str())
.unwrap_or("-");
let actions = if warning.available_actions.is_empty() {
"-".to_string()
} else {
warning
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>()
.join(",")
};
let detail = warning
.diagnostic
.as_ref()
.map(|diagnostic| diagnostic.message.as_str())
.unwrap_or("-");
let cycle_id = warning
.cycle_id
.as_ref()
.map(GeneratedCycleId::as_str)
.unwrap_or("-");
text.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
warning.schedule_id,
warning.kind.as_str(),
cycle_id,
start,
due,
next_start,
next_id,
actions,
detail
));
}
text
}
/// One generation outcome as values, in the service's own vocabulary.
fn cycle_generation_outcome_value(outcome: &CycleGenerationOutcome) -> Value {
match outcome {
CycleGenerationOutcome::Created { key, cycle, .. } => json!({
"status": "created",
"key": key.as_str(),
"cycle": cycle_instance_value(cycle),
}),
CycleGenerationOutcome::NoOp {
key,
state,
message,
..
} => json!({
"status": "no-op",
"key": key.as_str(),
"state": state.as_str(),
"message": message,
}),
CycleGenerationOutcome::Rejected {
key, diagnostics, ..
} => json!({
"status": "rejected",
"key": key.as_ref().map(GeneratedCycleId::as_str),
"diagnostics": diagnostics
.iter()
.map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
}))
.collect::<Vec<_>>(),
}),
}
}
/// The whole generation pass as values: the audit context, one outcome per
/// requested key, the counts, and the cursor after the pass.
fn cycle_report_value(report: &CycleGenerationReport) -> Value {
json!({
"actor": report.actor(),
"as_of": report.as_of().to_string(),
"schedule_id": report.schedule_id().as_str(),
"outcomes": report
.outcomes()
.iter()
.map(cycle_generation_outcome_value)
.collect::<Vec<_>>(),
"created": report.created().count(),
"no_ops": report.no_ops().count(),
"rejected": report.rejected().count(),
"refused": report.is_refused(),
"cursor": report.cursor().map(cycle_cursor_value),
})
}
/// The CLI's generation lines, word for word, split into what it prints and the
/// refusals it raises (`(text, failures)`).
fn cycle_generation_text(report: &CycleGenerationReport) -> (String, Vec<String>) {
let mut text = String::new();
let mut failures = Vec::new();
for outcome in report.outcomes() {
if outcome.is_rejected() {
failures.push(outcome.render());
} else {
text.push_str(&outcome.render());
text.push('\n');
}
}
if report.is_empty() {
text.push_str(&report.render());
text.push('\n');
}
(text, failures)
}
/// One lifecycle outcome as values (T042), with the snapshot identity a close
/// captured and the resulting window an action moved.
fn cycle_lifecycle_value(outcome: &CycleLifecycleOutcome) -> Value {
match outcome {
CycleLifecycleOutcome::Applied {
action,
schedule_id,
cycle_id,
snapshot_path,
dates,
message,
} => json!({
"status": "applied",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"snapshot_path": snapshot_path,
"dates": dates
.as_ref()
.map(|(start, due)| json!({ "start": start, "due": due })),
"message": message,
}),
CycleLifecycleOutcome::NoOp {
action,
schedule_id,
cycle_id,
message,
} => json!({
"status": "no-op",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"message": message,
}),
CycleLifecycleOutcome::Rejected {
action,
schedule_id,
cycle_id,
diagnostics,
} => json!({
"status": "rejected",
"action": action.as_str(),
"schedule_id": schedule_id.as_str(),
"cycle_id": cycle_id.as_ref().map(GeneratedCycleId::as_str),
"diagnostics": diagnostics
.iter()
.map(|diagnostic| json!({
"kind": diagnostic.kind.as_str(),
"field": diagnostic.field,
"message": diagnostic.message,
}))
.collect::<Vec<_>>(),
}),
}
}
/// One preserved metadata map as JSON, without dropping an unknown key.
///
/// Named for the *shape* rather than for one feature: a capacity snapshot's
/// `measurements`/`extensions` and a time entry's `extensions` (WP06) are the same
/// conversion, and a second copy is how two payloads come to render one preserved key
/// two ways. A map that cannot be a JSON value keeps the key with a `null` rather than
/// dropping it.
fn yaml_map_value(metadata: &std::collections::BTreeMap<String, serde_yaml::Value>) -> Value {
let mut map = serde_json::Map::with_capacity(metadata.len());
for (key, value) in metadata {
map.insert(
key.clone(),
serde_json::to_value(value).unwrap_or(Value::Null),
);
}
Value::Object(map)
}
/// One immutable snapshot as values (T042): the stable identity, the historical
/// dates and the audit context, all read from the snapshot file rather than
/// recomputed from the live schedule (C-007).
fn cycle_snapshot_value(snapshot: &CycleSnapshot) -> Value {
json!({
"cycle_id": snapshot.cycle_id.as_str(),
"schedule_id": snapshot.schedule_id.as_str(),
"sequence": snapshot.sequence,
"start_date": snapshot.start_date,
"due_date": snapshot.due_date,
"closed_at": snapshot.closed_at,
"closed_by": snapshot.closed_by,
"state_summary": snapshot.state_summary,
"measurements": yaml_map_value(&snapshot.measurements),
"extensions": yaml_map_value(&snapshot.extensions),
})
}
// -- the handlers ------------------------------------------------------------
fn cycle_schedule_list(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = String::from(
"id\tname\tstate\tcadence\tduration\tstart_date\tlook_ahead\tnext_sequence\tgenerated\n",
);
for (id, record) in index.schedules() {
text.push_str(&cycle_schedule_row(id, record, &index));
}
let (_, diagnostics) = cycle_schedule_diagnostics(&index, None);
Ok(success_result(
text,
json!({
"schedules": index
.schedules()
.map(|(id, record)| cycle_schedule_value(id, record, &index))
.collect::<Vec<_>>(),
"schedule_count": index.schedule_count(),
"generated_count": index.generated_count(),
"diagnostics": diagnostics,
}),
))
}
fn cycle_schedule_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let selector = req_string(args, "schedule")?;
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
// Unreachable: `registry::resolve` resolves only ids the index holds.
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{selector}`"),
)));
};
let (_, diagnostics) = cycle_schedule_diagnostics(&index, Some(&id));
Ok(success_result(
cycle_schedule_detail(&id, record, &index),
json!({
"schedule": cycle_schedule_value(&id, record, &index),
"cursor": cycle_cursor_value(&index.cursor(&id)),
"instances": index
.cycles_of(&id)
.map(cycle_instance_value)
.collect::<Vec<_>>(),
"diagnostics": diagnostics,
}),
))
}
fn cycle_schedule_create(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("create"));
}
// Every spelling is parsed by the shared registry (`record_for`), so this tool
// and `tix cycle schedule create` agree on what a cadence family or a unit is
// by construction (C-008/H4).
let draft = ScheduleDraft {
id: req_string(args, "id")?,
name: req_string(args, "name")?,
cadence: req_string(args, "cadence")?,
interval: opt_u32(args, "interval")?,
unit: opt_string(args, "unit")?,
duration: req_u32(args, "duration")?,
duration_unit: req_string(args, "duration_unit")?,
start_date: req_string(args, "start_date")?,
timezone: req_string(args, "timezone")?,
look_ahead: opt_u32(args, "look_ahead")?,
};
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
// The id-exists refusal, the spent-id guard (a stored record **or** a closed
// snapshot still claims a deleted id) and the pre-persistence validation all
// live in the registry, so a create the CLI refuses is refused here with the
// same sentence — and writes nothing.
let id = match registry::create(&mut config, &root, &draft) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{id}`"),
)));
};
Ok(success_result(
format!("created schedule `{id}`\n"),
cycle_schedule_value(&id, record, &index),
))
}
fn cycle_schedule_edit(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("edit"));
}
let selector = req_string(args, "schedule")?;
// Every named field becomes one patch field and the registry changes exactly
// those, carrying the rest of the record — including its cursor — over. The
// vocabulary is the registry's, so a `cadence` this tool refuses is refused by
// the CLI with the same sentence (C-008/H4).
let patch = ScheduleEdit {
name: opt_string(args, "name")?,
cadence: opt_string(args, "cadence")?,
interval: opt_u32(args, "interval")?,
unit: opt_string(args, "unit")?,
duration: opt_u32(args, "duration")?,
duration_unit: opt_string(args, "duration_unit")?,
start_date: opt_string(args, "start_date")?,
timezone: opt_string(args, "timezone")?,
look_ahead: opt_u32(args, "look_ahead")?,
};
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::edit(&mut config, &root, &selector, &patch) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{id}`"),
)));
};
Ok(success_result(
format!("updated schedule `{id}`\n"),
cycle_schedule_value(&id, record, &index),
))
}
fn cycle_schedule_delete(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("delete"));
}
let selector = req_string(args, "schedule")?;
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
// The value this reports is read through the shared read model before the
// removal; the removal itself — the selector resolution, the count of records
// deliberately left on disk and the save — is the registry's one implementation
// (C-008/H4). Nothing here re-spends an id, re-folds a name or re-writes a
// sentence of its own.
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let Some(record) = index.schedule(&id).cloned() else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!("cycle schedule `{selector}`"),
)));
};
let deleted = cycle_schedule_value(&id, &record, &index);
let deletion = match registry::delete(&mut config, &root, &selector) {
Ok(deletion) => deletion,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let generated = deletion.generated_cycles;
let mut text = format!("deleted schedule `{id}`\n");
if generated > 0 {
text.push_str(&format!(
"note: {generated} generated cycle(s) and their snapshots were left unchanged; they \
now reference a missing schedule\n"
));
}
Ok(success_result(
text,
json!({
"deleted": deleted,
"generated": generated,
}),
))
}
fn cycle_schedule_detect(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
// `date` wins; otherwise the boundary resolves today, exactly as
// `tix cycle schedule detect` does. Nothing below reads a clock.
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let evaluation = evaluate_cycle_schedules(&index, &context);
Ok(success_result(
cycle_detection_text(&evaluation),
json!({
"date": context.today.to_string(),
"timezone": context.timezone.as_ref().map(ToString::to_string),
"schedules_examined": evaluation.schedules_examined(),
"has_failures": evaluation.has_failures(),
"warnings": evaluation
.warnings()
.iter()
.map(cycle_warning_value)
.collect::<Vec<_>>(),
}),
))
}
fn cycle_schedule_generate(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed("generate"));
}
let selector = req_string(args, "schedule")?;
let through = opt_string(args, "through")?;
let date = opt_string(args, "date")?;
let timezone = opt_string(args, "timezone")?;
let actor = cycle_schedule_actor(args)?;
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let look_ahead = match through.as_deref() {
Some(through) => match cycle_candidates_through(&index, &id, through) {
Ok(count) => count,
Err(error) => return Ok(cycle_schedule_failure(&error)),
},
// No `through`: the schedule's own configured look-ahead is the horizon,
// exactly as a confirmation of what detection showed.
None => index.schedule(&id).map_or(0, |record| record.look_ahead),
};
let context = match cmd_recurring::context_for(date.as_deref(), timezone.as_deref()) {
Ok(context) => context,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let request = CycleGenerationRequest::new(id, context, look_ahead, actor).confirm();
let report = match generate_cycles(&mut config, &root, &index, &request) {
Ok(report) => report,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let value = cycle_report_value(&report);
let (text, failures) = cycle_generation_text(&report);
if !failures.is_empty() {
return Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(failures.join("\n"))),
value,
));
}
Ok(success_result(text, value))
}
/// The instance a lifecycle action names: `cycle` first, then a unique instance by
/// `from` start date, else none — the same precedence and the same refusal as the
/// CLI's own selector.
fn cycle_schedule_target(
index: &ScheduleIndex,
id: &CycleScheduleId,
cycle: Option<&str>,
from: Option<&str>,
) -> TixResult<Option<GeneratedCycleId>> {
if let Some(raw) = cycle {
return Ok(Some(GeneratedCycleId::parse(raw)?));
}
let Some(raw) = from else {
return Ok(None);
};
let wanted = registry::canonical_date(raw)?;
match index
.cycles_of(id)
.find(|cycle| cycle.start_date == wanted)
.map(|cycle| cycle.id.clone())
{
Some(cycle_id) => Ok(Some(cycle_id)),
None => Err(registry::refuse(
"unknown_cycle",
format!(
"no instance of schedule `{id}` starts on `{wanted}`; pass `--cycle \
<schedule-id>:<sequence>` to name one"
),
)),
}
}
/// The seven lifecycle tools are the seven [`CycleLifecycleAction`]s, so the shared
/// lifecycle sees exactly what `tix cycle schedule <action>` sends it (T041).
///
/// Every one is confirmed, and the outcome is the service's own
/// (`applied`/`no-op`/`rejected`); the History entry it wrote is described in the
/// message the outcome carries, and the snapshot it captured by `snapshot_path`.
fn cycle_schedule_lifecycle(
store: &Store,
args: &Value,
action: CycleLifecycleAction,
) -> ToolOutcome {
let selector = req_string(args, "schedule")?;
if !opt_bool(args, "confirm")?.unwrap_or(false) {
return Ok(cycle_schedule_unconfirmed(action.as_str()));
}
// The shared lifecycle extends by exactly one cadence period per action; this
// surface refuses any other count rather than re-deriving a multi-period step
// of its own (the CLI's `--by` check, same kind and sentence).
if let Some(periods) = opt_u32(args, "by")?
&& periods != 1
{
return Ok(cycle_schedule_failure(®istry::refuse(
"invalid_input",
format!(
"`--by {periods}` is not supported: the shared lifecycle extends an instance by \
exactly one cadence period per action, and this surface will not re-derive a \
multi-period step of its own. Extend once per period."
),
)));
}
let reason = opt_string(args, "reason")?;
let cycle = opt_string(args, "cycle")?;
let from = opt_string(args, "from")?;
let actor = cycle_schedule_actor(args)?;
// The explicit instant the action records: the caller's, or the boundary clock
// — the same source the CLI resolves it from (this crate has no clock below
// the boundary).
let timestamp = opt_string(args, "timestamp")?.unwrap_or_else(crate::model::utc_now_rfc3339);
let (mut config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match cycle_schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let id = match registry::resolve(&index, &selector) {
Ok(id) => id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let cycle_id = match cycle_schedule_target(&index, &id, cycle.as_deref(), from.as_deref()) {
Ok(cycle_id) => cycle_id,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut request = CycleLifecycleRequest::new(action, id, actor).with_timestamp(timestamp);
if let Some(cycle_id) = cycle_id {
request = request.for_cycle(cycle_id);
}
if let Some(reason) = reason {
request = request.with_reason(reason);
}
let request =
match crate::commands::capacity::with_close_capacity(request, &config, &root, store) {
Ok(request) => request,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let outcome = match apply_cycle_lifecycle(&mut config, &root, &request.confirm()) {
Ok(outcome) => outcome,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let value = cycle_lifecycle_value(&outcome);
if outcome.is_rejected() {
return Ok(rejected_result(
crate::error::render_error(&crate::error::TixError::Refused(outcome.render())),
value,
));
}
Ok(success_result(format!("{}\n", outcome.render()), value))
}
fn cycle_snapshot_list(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (_, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let snapshots = match CycleSnapshotStore::new(&root).list() {
Ok(snapshots) => snapshots,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = String::from(
"cycle_id\tschedule_id\tsequence\tstart_date\tdue_date\tclosed_at\tclosed_by\tstate\n",
);
for snapshot in &snapshots {
text.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
snapshot.cycle_id,
snapshot.schedule_id,
snapshot.sequence,
snapshot.start_date,
snapshot.due_date,
snapshot.closed_at,
snapshot.closed_by,
snapshot.state_summary
));
}
Ok(success_result(
text,
json!({
"directory": "docs/cycle_snapshots",
"snapshot_count": snapshots.len(),
"snapshots": snapshots.iter().map(cycle_snapshot_value).collect::<Vec<_>>(),
}),
))
}
/// Route a declared tool name to its handler.
///
/// `None` means "declared but not dispatched" -- see [`call`]. Split out from
/// [`call`] so the registration sweep can ask this question directly instead of
/// asserting against a hand-maintained list of names, which is what let a
/// declared-but-undispatched tool survive review rounds.
/// The policy metadata every health response carries (WP07 T035).
///
/// The version is the point: a reader comparing two repositories has to be able to
/// tell whether the two numbers were derived under the same formula, so the version,
/// the precedence and the two fallback answers travel with every value.
fn health_policy_value() -> Value {
let policy = crate::health::HealthPolicy::v1();
let states: Vec<&str> = crate::health::HealthState::ALL
.iter()
.map(|state| state.as_str())
.collect();
let precedence: Vec<&str> = policy
.precedence
.iter()
.map(|state| state.as_str())
.collect();
json!({
"version": policy.version(),
"states": states,
"precedence": precedence,
"empty_scope": policy.empty_scope().as_str(),
"missing_reference": policy.missing_reference().as_str(),
})
}
/// One scope's structured health, from the shared document.
fn health_scope_value(
document: &crate::health::PortableHealth,
scope: &crate::health::HealthScope,
) -> Value {
let entry = document.scope(scope);
let diagnostics: Vec<Value> = document
.diagnostics_for(scope)
.into_iter()
.map(health_diagnostic_value)
.collect();
json!({
"scope": scope.as_str(),
"state": entry.map(|entry| entry.state.clone()),
"source": entry.map(|entry| entry.source.clone()),
"recorded": entry.and_then(|entry| entry.recorded.clone()),
"recorded_policy_version": entry.and_then(|entry| entry.recorded_policy_version.clone()),
"updated_at": entry.and_then(|entry| entry.updated_at.clone()),
"events": entry.map(|entry| entry.events.iter().map(|event| json!({
"kind": event.kind,
"timestamp": event.timestamp,
"actor": event.actor,
"detail": event.detail,
})).collect::<Vec<Value>>()),
"rollup": entry.and_then(|entry| entry.rollup.as_ref()).map(|rollup| json!({
"state": rollup.state,
"policy_version": rollup.policy_version,
"source": rollup.source,
"contributors": rollup.contributors.iter().map(|contributor| json!({
"number": contributor.number,
"state": contributor.state,
"recorded": contributor.recorded,
})).collect::<Vec<Value>>(),
})),
"diagnostics": diagnostics,
})
}
/// One diagnostic in the wire vocabulary (the same kind/context the CLI prints).
fn health_diagnostic_value(diagnostic: &crate::health::PortableHealthDiagnostic) -> Value {
let shared = diagnostic.to_diagnostic();
json!({
"kind": shared.kind.as_str(),
"scope": shared.scope.as_str(),
"reference": shared.reference,
"field": shared.field,
"target": shared.target.map(|target| target.as_str()),
"state": shared.state,
"message": shared.message,
"remedy": shared.remedy,
})
}
/// The scope a `health_scope` call names: exactly one of `issue` or `scope`.
fn health_requested_scope(
store: &Store,
args: &Value,
) -> std::result::Result<crate::health::HealthScope, Value> {
let issue = opt_selector(args, "issue").map_err(|()| proto_error())?;
let scope = opt_string(args, "scope").map_err(|()| proto_error())?;
match (issue, scope) {
(Some(issue), None) => store
.resolve_issue(&issue)
.map(|issue| crate::health::HealthScope::of_issue(issue.number))
.map_err(|error| error_result(crate::error::render_error(&error))),
(None, Some(raw)) => crate::health::HealthScope::parse(&raw).ok_or_else(|| {
error_result(format!(
"invalid input: `{raw}` is not a scope; expected issue:<n>, project:<id>, \
epic:<id>, cycle:<id> or milestone:<id>"
))
}),
(None, None) => Err(error_result(
"invalid input: name either `issue` or `scope`".to_string(),
)),
(Some(_), Some(_)) => Err(error_result(
"invalid input: name exactly one of `issue` or `scope`".to_string(),
)),
}
}
/// A protocol error for an argument of the wrong type.
fn proto_error() -> Value {
error_result("invalid input: an argument has the wrong type".to_string())
}
/// `health_scope`: one scope's value, its derived layer and its diagnostics.
fn health_scope(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let scope = match health_requested_scope(store, args) {
Ok(scope) => scope,
Err(result) => return Ok(result),
};
let document = match crate::commands::export::run_health_core(config) {
Ok(document) => document,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
// The CLI's own text, verbatim: an agent reading MCP and a person reading the CLI
// must see the same characters (C-007).
let text = crate::commands::health::render_show(&document, &scope);
let mut structured = health_scope_value(&document, &scope);
structured["policy"] = health_policy_value();
Ok(success_result(text, structured))
}
/// `health_rollup`: a scope's derived health, with its contributors.
fn health_rollup(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let kind = match req_string(args, "scope") {
Ok(kind) => kind,
Err(()) => return Ok(proto_error()),
};
let id = match req_string(args, "id") {
Ok(id) => id,
Err(()) => return Ok(proto_error()),
};
let Some(scope) = crate::health::HealthScope::parse(&format!("{}:{}", kind.trim(), id.trim()))
else {
return Ok(error_result(format!(
"invalid input: `{kind}` is not a health scope; expected issue, project, epic, \
cycle or milestone"
)));
};
let _ = store;
let document = match crate::commands::export::run_health_core(config) {
Ok(document) => document,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let text = crate::commands::health::render_rollup(&document, &scope);
let mut structured = health_scope_value(&document, &scope);
structured["policy"] = health_policy_value();
Ok(success_result(text, structured))
}
/// `health_history`: the append-only entries about a scope.
fn health_history(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let raw = match req_string(args, "scope") {
Ok(raw) => raw,
Err(()) => return Ok(proto_error()),
};
let Some(scope) = crate::health::HealthScope::parse(&raw) else {
return Ok(error_result(format!(
"invalid input: `{raw}` is not a scope; expected issue:<n>, project:<id>, \
epic:<id>, cycle:<id> or milestone:<id>"
)));
};
let _ = store;
let document = match crate::commands::export::run_health_core(config) {
Ok(document) => document,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let text = crate::commands::health::render_history(&document, &scope);
let mut structured = health_scope_value(&document, &scope);
structured["policy"] = health_policy_value();
Ok(success_result(text, structured))
}
/// `health_record`: the one writer, shared with the CLI and the TUI.
fn health_record(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let selector = match selector(args, "issue") {
Ok(selector) => selector,
Err(()) => return Ok(proto_error()),
};
let state = match req_string(args, "state") {
Ok(state) => state,
Err(()) => return Ok(proto_error()),
};
let rationale = match req_string(args, "rationale") {
Ok(rationale) => rationale,
Err(()) => return Ok(proto_error()),
};
let actor = match opt_string(args, "actor") {
Ok(actor) => actor,
Err(()) => return Ok(proto_error()),
};
let date = match opt_string(args, "date") {
Ok(date) => date,
Err(()) => return Ok(proto_error()),
};
let over = match opt_bool(args, "override") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Ok(proto_error()),
};
let confirm = match opt_bool(args, "confirm") {
Ok(value) => value.unwrap_or(false),
Err(()) => return Ok(proto_error()),
};
// An override is an explicit act about a whole scope, so it needs the same
// confirmation the CLI's `--confirm` demands, with the same reason.
if over && !confirm {
return Ok(error_result(
"refusing to override derived health without `confirm: true`: an override is \
an explicit act about a whole scope, and it layers over the derived value \
rather than replacing it"
.to_string(),
));
}
let number = match store.resolve_issue(&selector) {
Ok(issue) => issue.number,
Err(error) => return Ok(error_result(crate::error::render_error(&error))),
};
let actor = actor.unwrap_or_else(crate::storage::actor_identity);
let date = date.unwrap_or_else(crate::model::today);
let source = if over {
crate::health::HealthSource::Override
} else {
crate::health::HealthSource::Current
};
match crate::commands::health::record(
store,
crate::health::HealthScope::of_issue(number),
&state,
&actor,
&date,
&rationale,
source,
) {
Ok(applied) => {
let stored = applied
.issue
.health
.as_ref()
.and_then(|health| health.state.clone())
.unwrap_or_default();
// The CLI's stdout, verbatim.
let text = crate::commands::health::render_mutation(
number,
&stored,
source,
crate::health::HealthPolicy::v1().version(),
);
let (_, resolution) = crate::workflow::active_resolution(config);
let mut structured = issue_value(&applied.issue, &resolution);
structured["health_recorded"] = json!({
"state": stored,
"source": source.as_str(),
"policy_version": crate::health::HealthPolicy::v1().version(),
"history_entry": {
"kind": applied.event.kind.as_str(),
"timestamp": applied.event.timestamp,
"actor": applied.event.actor,
"detail": applied.event.detail,
},
});
Ok(success_result(text, structured))
}
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
// ---------------------------------------------------------------------------
// WP06 / T027–T031: the initiative and roadmap tools
// ---------------------------------------------------------------------------
//
// MCP is an adapter only. Every handler below reaches behaviour through
// `crate::roadmap` (or the registry's atomic boundary) and reuses the CLI
// adapter's `pub` renderers for the text channel — it owns no second query, no
// metric, no ordering rule and no registry writer. The registry is re-read from
// disk per call, exactly as the project and epic tools do it, so an initiative
// this session created is visible to the next read.
/// Project the roadmap over the loaded registry and the store's issue set.
///
/// The filter's syntax is validated **first**, before any source snapshot is
/// built or any row joined — the CLI's own precedence (`tix roadmap list` calls
/// `filter.validate()` before it reads the store), so a malformed value is
/// refused with the filter's named diagnostic even when the store read would
/// also fail (C-007). The source snapshot comes from the CLI's own
/// [`cmd_roadmap::build_sources`], so MCP, the CLI and the TUI share **one**
/// composition of the owner reads rather than three copies (C-008). Read-only.
fn project_roadmap(
config: &Config,
store: &Store,
filter: &RoadmapFilter,
) -> TixResult<RoadmapProjection> {
filter.validate()?;
let issues = store.list_all()?;
let sources = cmd_roadmap::build_sources(config, store, &issues)?;
crate::roadmap::project(config, &sources, filter)
}
/// One initiative's roadmap row under the empty filter.
fn roadmap_row_by_id(config: &Config, store: &Store, id: &str) -> TixResult<Option<RoadmapRow>> {
let projection = project_roadmap(config, store, &RoadmapFilter::default())?;
Ok(projection.initiatives.into_iter().find(|row| row.id == id))
}
/// The stable singular spelling of an association kind (data-model §2), so the
/// structured `kind` is the same word the provenance prefix uses.
fn association_kind_name(kind: AssociationKind) -> &'static str {
match kind {
AssociationKind::Project => "project",
AssociationKind::Epic => "epic",
AssociationKind::Milestone => "milestone",
}
}
/// Parse an association kind. `None` for anything outside the closed vocabulary;
/// the caller turns that into a named tool refusal rather than a protocol error.
fn association_kind(raw: &str) -> Option<AssociationKind> {
match raw.trim() {
"project" => Some(AssociationKind::Project),
"epic" => Some(AssociationKind::Epic),
"milestone" => Some(AssociationKind::Milestone),
_ => None,
}
}
/// A trimmed priority keeping the forward-compatible unknown spelling. The
/// initiative record keeps an unknown priority verbatim (the shared `Priority`
/// shape), so this mirrors the CLI's loose parse rather than the strict one.
fn loose_priority(value: &str) -> crate::model::Priority {
value
.parse()
.unwrap_or_else(|never: std::convert::Infallible| match never {})
}
/// An optional priority argument: absent or blank is absent; a written value is
/// trimmed and parsed loosely, exactly as `tix initiative create` does.
fn initiative_priority(value: Option<String>) -> Option<crate::model::Priority> {
let trimmed = value?;
let trimmed = trimmed.trim();
if trimmed.is_empty() {
None
} else {
Some(loose_priority(trimmed))
}
}
/// A blank value clears an optional text field; a written one sets it trimmed
/// (the CLI's `text_patch`).
fn initiative_text_patch(value: Option<String>) -> crate::projects::Patch<String> {
use crate::projects::Patch;
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.trim().to_string()),
}
}
/// A blank value clears a reference list; a written one replaces it (the CLI's
/// `reference_patch`).
fn initiative_reference_patch(value: Option<String>) -> crate::projects::Patch<Vec<String>> {
use crate::projects::Patch;
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(crate::commands::parse_csv_list(&text)),
}
}
/// An initiative's registry record as JSON — raw values, no derived figure.
fn initiative_record_value(id: &str, record: &InitiativeConfiguration) -> Value {
json!({
"id": id,
"name": record.name,
"outcome": record.outcome,
"owner": record.owner,
"status": record.status,
"priority": record.priority,
"labels": record.labels,
"target_date": record.target_date,
"projects": record.projects,
"epics": record.epics,
"milestones": record.milestones,
})
}
/// One roadmap diagnostic as JSON: the kind, the location it names and the
/// remedy. Every field is optional except `kind` and `message`, as the service
/// models it.
fn roadmap_diagnostic_value(diagnostic: &RoadmapDiagnostic) -> Value {
json!({
"kind": diagnostic.kind.as_str(),
"initiative": diagnostic.initiative,
"field": diagnostic.field,
"reference": diagnostic.reference,
"source": diagnostic.source,
"message": diagnostic.message,
"remedy": diagnostic.remedy,
})
}
/// One association as the row exposes it: identity, label and resolution.
fn row_reference_value(reference: &RowReference) -> Value {
json!({
"kind": association_kind_name(reference.kind),
"target": reference.target,
"label": reference.label,
"resolution": reference.resolution.as_str(),
})
}
/// A health rollup as JSON — the typed risk value a surface compares by value.
fn health_rollup_value(rollup: &crate::health::HealthRollup) -> Value {
json!({
"scope": rollup.scope.to_string(),
"state": rollup.state.as_str(),
"source": rollup.source.as_str(),
"policy_version": rollup.policy_version,
"contributions": rollup
.contributions
.iter()
.map(|contribution| json!({
"issue": contribution.number,
"state": contribution.state.as_str(),
}))
.collect::<Vec<Value>>(),
})
}
/// The owning service's own typed value for one resolved source entity, tagged
/// with which service produced it — never a blended figure (C-004).
fn progress_value(progress: &ProgressValue) -> Value {
match progress {
ProgressValue::Project(report) => json!({
"source": "project",
"value": project_health_value(report),
}),
ProgressValue::Epic(rollup) => json!({
"source": "epic",
"value": epic_rollup_value(rollup),
}),
}
}
/// One summary contribution as JSON: provenance, label, resolution, the owning
/// service's own value, its risk and the shared policy version.
fn contribution_value(contribution: &SummaryContribution) -> Value {
json!({
"source_entity": contribution.source_entity(),
"kind": association_kind_name(contribution.kind),
"target": contribution.target,
"label": contribution.label,
"resolution": contribution.resolution.as_str(),
"progress": contribution.progress.as_ref().map(progress_value),
"risk": contribution.risk.as_ref().map(health_rollup_value),
"policy_version": contribution.policy_version,
})
}
/// The source-attributed summary as JSON: the explicit state, one contribution
/// per referenced entity, the ordered source entities and policy versions, and
/// the diagnostics. No initiative-local aggregate (C-004).
fn initiative_summary_value(summary: &InitiativeSummary) -> Value {
json!({
"initiative_id": summary.initiative_id,
"state": summary.state.as_str(),
"knowable_contributions": summary.knowable_contributions(),
"source_entities": summary.source_entities,
"policy_versions": summary.policy_versions,
"contributions": summary
.contributions
.iter()
.map(contribution_value)
.collect::<Vec<Value>>(),
"diagnostics": summary
.diagnostics
.iter()
.map(roadmap_diagnostic_value)
.collect::<Vec<Value>>(),
})
}
/// One roadmap row as JSON — raw values, no formatted summary.
fn roadmap_row_value(row: &RoadmapRow) -> Value {
json!({
"id": row.id,
"name": row.name,
"outcome": row.outcome,
"owner": row.owner,
"status": row.status,
"priority": row.priority,
"labels": row.labels,
"target_date": row.target_date,
"references": row.references.iter().map(row_reference_value).collect::<Vec<Value>>(),
"summary": initiative_summary_value(&row.summary),
"diagnostics": row.diagnostics.iter().map(roadmap_diagnostic_value).collect::<Vec<Value>>(),
})
}
/// The normalized filter from the MCP arguments — the same DTO the CLI builds,
/// with the same field names, so the two surfaces cannot drift into two query
/// languages (C-007).
fn roadmap_filter(args: &Value) -> std::result::Result<RoadmapFilter, ()> {
Ok(RoadmapFilter {
period_from: opt_string(args, "period_from")?,
period_to: opt_string(args, "period_to")?,
owner: opt_string(args, "owner")?,
status: opt_string(args, "status")?,
labels: opt_string_array(args, "labels")?.unwrap_or_default(),
query: opt_string(args, "query")?,
})
}
fn list_initiatives(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The CLI's own listing, verbatim; the structured channel carries the same
// records with their ids (C-002).
let lines = cmd_roadmap::initiative_listing_lines(&config);
let initiatives: Vec<Value> = crate::roadmap::registry::ids(&config)
.map(|id| initiative_record_value(id, &config.initiatives[id]))
.collect();
Ok(success_result(
lines.join("\n"),
json!({ "initiatives": initiatives }),
))
}
fn get_initiative(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let id = match crate::roadmap::registry::resolve_initiative_id(&config, &reference) {
Ok(id) => id,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let Some(record) = crate::roadmap::registry::get(&config, &id) else {
return Ok(error_result(format!(
"invalid input: unknown initiative `{reference}`"
)));
};
// The CLI's own record rendering (references shown as recorded); the
// structured channel adds the projection's resolution and diagnostics.
let text = cmd_roadmap::initiative_record_lines(&id, record).join("\n");
let row = match roadmap_row_by_id(&config, store, id.as_str()) {
Ok(Some(row)) => row,
Ok(None) => {
return Ok(error_result(format!(
"internal error: the roadmap projection returned no row for `{id}`"
)));
}
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let mut structured = initiative_record_value(id.as_str(), record);
structured["references"] = json!(
row.references
.iter()
.map(row_reference_value)
.collect::<Vec<Value>>()
);
structured["summary"] = initiative_summary_value(&row.summary);
structured["diagnostics"] = json!(
row.diagnostics
.iter()
.map(roadmap_diagnostic_value)
.collect::<Vec<Value>>()
);
Ok(success_result(text, structured))
}
fn create_initiative(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let name = req_string(args, "name")?;
let outcome = trimmed_optional(opt_string(args, "outcome")?);
let owner = trimmed_optional(opt_string(args, "owner")?);
let status = trimmed_optional(opt_string(args, "status")?);
let priority = initiative_priority(opt_string(args, "priority")?);
let labels = opt_string(args, "labels")?
.map(|text| crate::commands::parse_csv_list(&text))
.unwrap_or_default();
let target_date = trimmed_optional(opt_string(args, "target_date")?);
let projects = opt_string(args, "projects")?
.map(|text| crate::commands::parse_csv_list(&text))
.unwrap_or_default();
let epics = opt_string(args, "epics")?
.map(|text| crate::commands::parse_csv_list(&text))
.unwrap_or_default();
let milestones = opt_string(args, "milestones")?
.map(|text| crate::commands::parse_csv_list(&text))
.unwrap_or_default();
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let new = NewInitiative {
name,
outcome,
owner,
status,
priority,
labels,
target_date,
projects,
epics,
milestones,
extensions: crate::roadmap::registry::InitiativeExtensions::default(),
};
// The registry allocates the id and validates the whole record through its
// atomic boundary, so a refusal persists nothing (C-008).
match crate::roadmap::registry::create_initiative(&mut config, &path, &new) {
Ok(id) => {
let record = &config.initiatives[id.as_str()];
Ok(success_result(
format!("Created {} ({id})", new.name),
initiative_record_value(id.as_str(), record),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn update_initiative(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
let name = opt_string(args, "name")?;
let outcome = opt_string(args, "outcome")?;
let owner = opt_string(args, "owner")?;
let status = opt_string(args, "status")?;
let priority = opt_string(args, "priority")?;
let labels = opt_string(args, "labels")?;
let target_date = opt_string(args, "target_date")?;
let projects = opt_string(args, "projects")?;
let epics = opt_string(args, "epics")?;
let milestones = opt_string(args, "milestones")?;
if name.is_none()
&& outcome.is_none()
&& owner.is_none()
&& status.is_none()
&& priority.is_none()
&& labels.is_none()
&& target_date.is_none()
&& projects.is_none()
&& epics.is_none()
&& milestones.is_none()
{
// The CLI's wording for the same refusal, from the one shared helper.
return Ok(error_result(crate::error::render_error(
&cmd_roadmap::initiative_edit_requires_a_field(),
)));
}
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let id = match crate::roadmap::registry::resolve_initiative_id(&config, &reference) {
Ok(id) => id,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// The patch shape `tix initiative edit` builds: `--name` always sets (a name
// is never cleared, which the loader refuses), the other fields follow the
// blank-clears convention.
let edit = InitiativeEdit {
name: name.map_or(
crate::projects::Patch::Unchanged,
crate::projects::Patch::Set,
),
outcome: initiative_text_patch(outcome),
owner: initiative_text_patch(owner),
status: initiative_text_patch(status),
priority: match priority {
None => crate::projects::Patch::Unchanged,
Some(text) if text.trim().is_empty() => crate::projects::Patch::Clear,
Some(text) => crate::projects::Patch::Set(loose_priority(text.trim())),
},
labels: initiative_reference_patch(labels),
target_date: initiative_text_patch(target_date),
projects: initiative_reference_patch(projects),
epics: initiative_reference_patch(epics),
milestones: initiative_reference_patch(milestones),
};
match crate::roadmap::registry::edit_initiative(&mut config, &path, &id, &edit) {
Ok(id) => {
let record = &config.initiatives[id.as_str()];
Ok(success_result(
format!("Updated initiative {id}"),
initiative_record_value(id.as_str(), record),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn delete_initiative(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
// The schema declares `confirm` required, but a schema is documentation a
// client may ignore; checked before anything is resolved, so a forgotten
// confirmation writes nothing (the convention `delete_project` follows).
if opt_bool(args, "confirm")? != Some(true) {
return Ok(error_result(
"invalid input: `confirm` must be true to delete an initiative — only the registry \
entry is removed and no project, epic, milestone or issue is deleted"
.to_string(),
));
}
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let id = match crate::roadmap::registry::resolve_initiative_id(&config, &reference) {
Ok(id) => id,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let Some(record) = crate::roadmap::registry::get(&config, &id).cloned() else {
return Ok(error_result(format!(
"invalid input: unknown initiative `{reference}`"
)));
};
// Removes the registry entry **only**: the record's own references are
// reported and nothing cascades, so no source entity is touched and no
// association is silently repaired (C-005). The CLI's own sentence, through
// the one `reference_inventory` renderer.
match crate::roadmap::registry::delete_initiative(&mut config, &path, &id) {
Ok(()) => Ok(success_result(
format!(
"Deleted initiative {} ({id})\nRemoved references (nothing cascades): {}",
record.name,
cmd_roadmap::reference_inventory(&record),
),
json!({
"id": id.as_str(),
"name": record.name,
"references": {
"projects": record.projects,
"epics": record.epics,
"milestones": record.milestones,
},
}),
)),
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
/// `initiative_associate` / `initiative_dereference`, one registry call each.
fn initiative_association(store: &Store, args: &Value, associate: bool) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
let raw_kind = req_string(args, "kind")?;
let Some(kind) = association_kind(&raw_kind) else {
return Ok(error_result(format!(
"invalid input: `{raw_kind}` is not an association kind; expected project, epic or \
milestone"
)));
};
let target = req_string(args, "target")?;
let (mut config, path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
// Resolved once, up front, so an unknown initiative refuses before any write;
// the id, not the reference, is what the service records against (mirroring
// the CLI, which resolves first too).
let id = match crate::roadmap::registry::resolve_initiative_id(&config, &reference) {
Ok(id) => id,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let outcome = if associate {
crate::roadmap::registry::associate(&mut config, &path, id.as_str(), kind, &target)
} else {
crate::roadmap::registry::dissociate(&mut config, &path, id.as_str(), kind, &target)
};
match outcome {
Ok(changed) => {
let text = if associate {
if changed {
format!("Associated {kind} {target} with initiative {id}")
} else {
format!("Already associated: {kind} {target} with initiative {id}")
}
} else if changed {
format!("Dereferenced {kind} {target} from initiative {id}")
} else {
format!("Not associated: {kind} {target} with initiative {id}")
};
Ok(success_result(
text,
json!({
"initiative": id.as_str(),
"kind": association_kind_name(kind),
"target": target,
"changed": changed,
}),
))
}
Err(e) => Ok(error_result(crate::error::render_error(&e))),
}
}
fn initiative_associate(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
initiative_association(store, args, true)
}
fn initiative_dereference(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
initiative_association(store, args, false)
}
fn roadmap_list(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let filter = match roadmap_filter(args) {
Ok(filter) => filter,
Err(()) => return Ok(proto_error()),
};
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let projection = match project_roadmap(&config, store, &filter) {
Ok(projection) => projection,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let mut lines: Vec<String> = Vec::new();
if let Some(line) = cmd_roadmap::roadmap_filter_line(&filter) {
lines.push(line);
}
lines.extend(cmd_roadmap::roadmap_list_lines(&projection));
let structured = json!({
"filters": serde_json::to_value(&filter).unwrap_or(Value::Null),
"total": projection.total,
"initiatives": projection
.initiatives
.iter()
.map(roadmap_row_value)
.collect::<Vec<Value>>(),
"diagnostics": projection
.diagnostics
.iter()
.map(roadmap_diagnostic_value)
.collect::<Vec<Value>>(),
});
Ok(success_result(lines.join("\n"), structured))
}
/// The one row a single-row roadmap read addresses, or the refusal to return.
fn roadmap_row_outcome(
config: &Config,
store: &Store,
reference: &str,
) -> ToolOutcome2<RoadmapRow> {
let id = match crate::roadmap::registry::resolve_initiative_id(config, reference) {
Ok(id) => id,
Err(e) => return Err(Ok(error_result(crate::error::render_error(&e)))),
};
match roadmap_row_by_id(config, store, id.as_str()) {
Ok(Some(row)) => Ok(row),
Ok(None) => Err(Ok(error_result(format!(
"internal error: the roadmap projection returned no row for `{id}`"
)))),
Err(e) => Err(Ok(error_result(crate::error::render_error(&e)))),
}
}
fn roadmap_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let row = match roadmap_row_outcome(&config, store, &reference) {
Ok(row) => row,
Err(outcome) => return outcome,
};
// The CLI's own row rendering, verbatim (C-007/NFR-003).
let text = cmd_roadmap::roadmap_row_lines(&row).join("\n");
Ok(success_result(text, roadmap_row_value(&row)))
}
fn roadmap_summary(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "initiative")?;
let (config, _path) = match registry_config(store) {
Ok(loaded) => loaded,
Err(e) => return Ok(error_result(crate::error::render_error(&e))),
};
let row = match roadmap_row_outcome(&config, store, &reference) {
Ok(row) => row,
Err(outcome) => return outcome,
};
// The text channel is the CLI's own row rendering (which *is* the summary:
// one contribution line per source entity plus the diagnostics), so the two
// surfaces cannot word one summary two ways.
let text = cmd_roadmap::roadmap_row_lines(&row).join("\n");
Ok(success_result(text, initiative_summary_value(&row.summary)))
}
// ---------------------------------------------------------------------------
// WP07 (mission `cycle-capacity-planning-01M3C85C`) — the capacity tools
// ---------------------------------------------------------------------------
/// One configured entry as the CLI's `list`/`show` columns.
///
/// An absent ceiling or unit is `null` in the structure (text `-`), never `0` (C-004).
/// The `display` is the resolved identity, so a stable id and a name are visibly one
/// scope.
fn capacity_entry_value(
id: &str,
kind: CapacityScopeKind,
entry: &CapacityEntry,
index: &ScheduleIndex,
) -> Value {
let reference = entry.scope_id().unwrap_or_default();
json!({
"id": id,
"scope": kind.as_str(),
"reference": reference,
"display": cmd_capacity::scope_label(kind, reference, index),
"capacity": entry.capacity,
"unit": entry.unit,
"enabled": entry.enabled,
})
}
/// The resolved scope in the wire vocabulary, shared by a calculation and a finding.
fn capacity_scope_value(scope: &CapacityScope) -> Value {
json!({
"kind": scope.kind.as_str(),
"reference": scope.reference,
"display": scope.display,
})
}
/// One shared finding: the kind, the scope, the numbers, the unit, the issues, and the
/// actionable `reason`/`remedy` pair (C-006/#1643). Every absent number is `null`.
fn capacity_warning_value(warning: &CapacityWarning) -> Value {
json!({
"kind": warning.kind.as_str(),
"scope": capacity_scope_value(&warning.scope),
"issues": warning.issues,
"current": warning.current,
"limit": warning.limit,
"overflow": warning.overflow,
"unit": warning.unit,
"reason": warning.reason,
"remedy": warning.remedy,
})
}
/// The shared calculation as values: every DTO field, absent values `null` (never `0`).
fn capacity_calculation_value(calculation: &CapacityCalculation) -> Value {
json!({
"scope": capacity_scope_value(&calculation.scope),
"state": calculation.state().as_str(),
"capacity": calculation.capacity,
"unit": calculation.unit,
"committed": calculation.committed,
"completed": calculation.completed,
"remaining": calculation.remaining,
"overflow": calculation.overflow,
"utilization": calculation.utilization,
"counted_issues": calculation.counted_issues,
"uncounted_issues": calculation.uncounted_issues,
"unestimated_issues": calculation.unestimated_issues,
"over_capacity": calculation.is_over_capacity(),
"source": {
"issues": calculation.source.issues,
"units": calculation.source.units,
},
"diagnostics": calculation
.diagnostics
.iter()
.map(capacity_warning_value)
.collect::<Vec<_>>(),
})
}
/// The immutable close evidence as values — the DTO's own fields, absent `null`.
fn capacity_evidence_value(evidence: &CapacitySnapshot) -> Value {
json!({
"snapshot_id": evidence.snapshot_id,
"cycle_id": evidence.cycle_id,
"closed_at": evidence.closed_at,
"closed_by": evidence.closed_by,
"scope": capacity_scope_value(&evidence.scope),
"state": evidence.state.as_str(),
"capacity": evidence.capacity,
"unit": evidence.unit,
"committed": evidence.committed,
"completed": evidence.completed,
"remaining": evidence.remaining,
"overflow": evidence.overflow,
"utilization": evidence.utilization,
"counted_issues": evidence.counted_issues,
"uncounted_issues": evidence.uncounted_issues,
"unestimated_issues": evidence.unestimated_issues,
"source": {
"issues": evidence.source.issues,
"units": evidence.source.units,
},
"diagnostics": evidence
.diagnostics
.iter()
.map(capacity_warning_value)
.collect::<Vec<_>>(),
})
}
/// The rendered findings, under the CLI's own policy: a finding about an estimate or a
/// reference explains why a total is short, so it always renders; the overflow report is
/// the opt-in half, added only when `warnings` is asked for. The shared
/// [`CapacityWarning::render`] owns the sentence, so MCP never words a warning of its own.
fn capacity_findings_text(diagnostics: &[CapacityWarning], warnings: bool) -> String {
diagnostics
.iter()
.filter(|warning| warnings || !matches!(warning.kind, CapacityWarningKind::Overflow))
.map(CapacityWarning::render)
.collect::<Vec<_>>()
.join("\n")
}
/// The capacity-affecting `## History` entries of the generated cycle's own record,
/// oldest first — the audit trail C-008 asks a reader to be able to see.
///
/// The record's log is the one place a close, a reopen and a closed-cycle capacity edit
/// are recorded; this filters it to those three kinds and returns each entry's kind, its
/// timestamp, its actor and the one-line `detail` the library wrote. It reads only: it
/// appends nothing, reorders nothing and rewrites nothing.
fn capacity_history(root: &Path, cycle_id: &GeneratedCycleId) -> TixResult<Vec<Value>> {
let store = CycleStore::new(root);
let records = RecordStore::new(store.dir()).list::<crate::cycle_store::CycleRecord>()?;
for file in records {
let Ok(Some(cycle)) = GeneratedCycle::from_record(&file.value) else {
continue;
};
if cycle.id != *cycle_id {
continue;
}
let (history, _) = History::parse_front(&file.body);
return Ok(history
.events()
.into_iter()
.filter(|event| capacity_history_kind(event.kind))
.map(|event| {
json!({
"kind": event.kind.as_str(),
"timestamp": event.timestamp.clone(),
"actor": event.actor.clone(),
"detail": event.detail.clone(),
})
})
.collect());
}
Ok(Vec::new())
}
/// Whether a `## History` kind is one of the capacity-affecting cycle events the snapshot
/// tool reports: the close, the reopen and the closed-cycle capacity edit.
fn capacity_history_kind(kind: HistoryEventKind) -> bool {
matches!(
kind,
HistoryEventKind::CycleClosed
| HistoryEventKind::CycleReopened
| HistoryEventKind::CycleCapacityEdited
)
}
/// `capacity_list`: every configured entry, in declaration order.
///
/// The text is the CLI's `tix capacity list` output exactly, so the two surfaces are
/// byte-comparable; the structure carries the same columns as typed values. Read-only:
/// the configuration is loaded, never saved (C-006).
fn capacity_list(_config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = String::from("id\tscope\treference\tdisplay\tcapacity\tunit\tenabled\n");
let mut entries = Vec::new();
for (id, entry) in config.capacity.iter() {
// A loaded section is validated, so every entry names exactly one scope.
let Some(kind) = entry.scope_kind() else {
continue;
};
let reference = entry.scope_id().unwrap_or_default();
let display = cmd_capacity::scope_label(kind, reference, &index);
text.push_str(&format!(
"{id}\t{kind}\t{reference}\t{display}\t{ceiling}\t{unit}\t{enabled}\n",
kind = kind.as_str(),
ceiling = cmd_capacity::number(entry.capacity),
unit = entry.unit.as_deref().unwrap_or("-"),
enabled = entry.enabled,
));
entries.push(capacity_entry_value(id, kind, entry, &index));
}
let count = entries.len();
Ok(success_result(
text,
json!({ "entries": entries, "entry_count": count }),
))
}
/// `capacity_show <REFERENCE>`: one entry, by the scope the reference resolves to.
fn capacity_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "reference")?;
let scope = opt_string(args, "scope")?;
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let wanted = match scope
.as_deref()
.map(cmd_capacity::parse_scope_kind)
.transpose()
{
Ok(wanted) => wanted,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let (id, kind, entry) = match cmd_capacity::one_entry(&config, wanted, &reference, &index) {
Ok(found) => found,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let entry_reference = entry.scope_id().unwrap_or_default();
let text = format!(
"id: {id}\nscope: {kind}\nreference: {reference}\ndisplay: {display}\n\
capacity: {capacity}\nunit: {unit}\nenabled: {enabled}\n",
kind = kind.as_str(),
reference = entry_reference,
display = cmd_capacity::scope_label(kind, entry_reference, &index),
capacity = cmd_capacity::number(entry.capacity),
unit = entry.unit.as_deref().unwrap_or("-"),
enabled = entry.enabled,
);
Ok(success_result(
text,
capacity_entry_value(id, kind, entry, &index),
))
}
/// `capacity_calculate --scope <KIND> --id <REFERENCE>`: the shared DTO and its findings.
///
/// There is no arithmetic here: the values are the ones
/// [`crate::commands::capacity::calculation_for`] derived — the same entry point the CLI
/// calls — and an absent one is `null` (text `-`), never `0`. Read-only (C-006).
fn capacity_calculate(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let scope = req_string(args, "scope")?;
let reference = req_string(args, "id")?;
let warnings = opt_bool(args, "warnings")?.unwrap_or(false);
let kind = match cmd_capacity::parse_scope_kind(&scope) {
Ok(kind) => kind,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
// The CLI's own gate, in its own order: `tix capacity calculate` parses `--scope`
// and then refuses a blank or padded `--id` by name, before it reads anything, so
// this surface cannot turn an empty reference into a plausible *unconfigured* scope.
// `capacity show` and `capacity snapshot` are deliberately **not** gated this way
// (and so call no gate here): the CLI does not gate them either, and a reference
// nothing answers to is its own `not_found` there — gating them here would make the
// two surfaces refuse the same input with different kinds.
let reference = match cmd_capacity::required("--id", &reference) {
Ok(reference) => reference.to_string(),
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let calculation = match cmd_capacity::calculation_for(&config, &root, store, kind, &reference) {
Ok(calculation) => calculation,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let mut text = cmd_capacity::calculation_text(&calculation);
let findings = capacity_findings_text(&calculation.diagnostics, warnings);
if !findings.is_empty() {
text.push('\n');
text.push_str(&findings);
text.push('\n');
}
Ok(success_result(
text,
capacity_calculation_value(&calculation),
))
}
/// `capacity_snapshot <CYCLE>`: a closed cycle's immutable capacity evidence, plus the
/// close/reopen/edit `## History` summaries.
///
/// A pure read through the existing immutable snapshot store and the WP04 reader. A
/// cycle with no snapshot at all is a refusal (the tool asked for something that is not
/// there); a snapshot that carries no capacity payload — written before this mission — is
/// `evidence: none`, a value and never an error, so a legacy record stays readable. The
/// evidence is never recomputed: it is the payload the close wrote (NFR-002).
fn capacity_snapshot(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
let reference = req_string(args, "cycle")?;
let warnings = opt_bool(args, "warnings")?.unwrap_or(false);
let (config, root, _path) = match cycle_schedule_config(store) {
Ok(loaded) => loaded,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let index = match schedule_index(&config, &root) {
Ok(index) => index,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let cycle_id = match resolve_cycle(&index, &reference) {
CycleReference::Generated { id, .. } => id,
CycleReference::Legacy { .. } => {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!(
"cycle `{reference}` is a hand-written cycle and has no generated snapshot; \
`tix capacity snapshot` reads the evidence a generated cycle's close \
captured"
),
)));
}
CycleReference::Unknown { .. } => {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!(
"no cycle `{reference}` resolves to a record, so there is no snapshot to read"
),
)));
}
};
let snapshots = CycleSnapshotStore::new(&root);
let Some(stored) = (match snapshots.read(&cycle_id) {
Ok(stored) => stored,
Err(error) => return Ok(cycle_schedule_failure(&error)),
}) else {
return Ok(cycle_schedule_failure(&crate::error::TixError::NotFound(
format!(
"cycle `{cycle_id}` has no snapshot: it has not been closed, or its snapshot was \
removed"
),
)));
};
let evidence = match CapacitySnapshot::from_cycle_snapshot(&stored) {
Ok(evidence) => evidence,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let history = match capacity_history(&root, &cycle_id) {
Ok(history) => history,
Err(error) => return Ok(cycle_schedule_failure(&error)),
};
let snapshot_path = format!(
"docs/cycle_snapshots/{filename}",
filename = CycleSnapshotStore::filename_for(&cycle_id)
);
let mut text = format!("cycle: {cycle_id}\nsnapshot: {snapshot_path}\n");
let structured = match evidence {
None => {
text.push_str("evidence: none\n");
json!({
"cycle_id": cycle_id.as_str(),
"snapshot_path": snapshot_path,
"evidence": "none",
"capacity": Value::Null,
"history": history,
})
}
Some(evidence) => {
text.push_str("evidence: present\n");
text.push_str(&cmd_capacity::evidence_text(&evidence));
let findings = capacity_findings_text(&evidence.diagnostics, warnings);
if !findings.is_empty() {
text.push('\n');
text.push_str(&findings);
text.push('\n');
}
json!({
"cycle_id": cycle_id.as_str(),
"snapshot_path": snapshot_path,
"evidence": "present",
"capacity": capacity_evidence_value(&evidence),
"history": history,
})
}
};
Ok(success_result(text, structured))
}
// ---------------------------------------------------------------------------
// WP06 (mission `per-issue-time-tracking-01M3C9A4`) — the time tools
// ---------------------------------------------------------------------------
//
// Six tools, one per `tix time` verb: three reads (`time_show`, `time_history`,
// `time_total`) and three mutations (`time_add`, `time_edit`, `time_delete`). The
// reads answer from [`TimeSnapshot`]'s DTOs and write nothing; the mutations call
// [`apply_time`] and own no writer. Nothing below sums a minute, validates an entry,
// resolves a period or reads a `## History` grammar — that is the point of WP06, and
// `tests/mcp.rs`'s `parity_time_*` cases compare these handlers with the CLI's own
// output, including the CLI's stderr for a refusal, so a second rule or a second
// wording fails the suite rather than shipping.
//
// The *bytes* are not assembled here: every renderer a parity case compares is
// [`cmd_time`]'s own — the refusal block ([`cmd_time::refusal_blocks`]), the scope block
// ([`cmd_time::scope_block`]), the `-`-mark formatters ([`cmd_time::number`],
// [`cmd_time::cell`]), the breakdown vocabulary and its refusal
// ([`cmd_time::parse_breakdown`]), the unconfirmed-delete sentence
// ([`cmd_time::TIME_DELETE_CONFIRMATION`]), the four mutation sentences
// ([`cmd_time::mutation_sentence`]) and `time_total`'s archived-selector refusal
// ([`cmd_time::archived_refusal`]), so a later edit to the CLI's rendering reaches both
// surfaces or neither. What remains here is the *line layout* of a read — one header line
// and one tab-separated row per entry or deletion — and the row layout is all that is
// left to keep in step, which the byte-for-byte parity cases do.
/// The arguments each time tool declares.
///
/// The schemas promise `additionalProperties: false` and nothing below a schema
/// enforces it, so this is the boundary that has to hold: a key outside the list is
/// refused by name ([`time_arguments`]), which is also how a `timer`, a `timezone` or
/// a `billing` payload is answered (C-009) instead of being silently ignored.
const TIME_SHOW_ARGS: &[&str] = &["issue"];
const TIME_HISTORY_ARGS: &[&str] = &["issue"];
const TIME_TOTAL_ARGS: &[&str] = &["issue", "assignee", "from", "to", "audit", "by"];
const TIME_ADD_ARGS: &[&str] = &[
"issue",
"minutes",
"date",
"author",
"note",
"actor",
"timestamp",
"reason",
];
const TIME_EDIT_ARGS: &[&str] = &[
"issue",
"entry",
"minutes",
"date",
"clear_date",
"note",
"clear_note",
"author",
"reason",
"actor",
"timestamp",
];
const TIME_DELETE_ARGS: &[&str] = &["issue", "entry", "reason", "confirm", "actor", "timestamp"];
/// The sentence a contradictory edit is refused with.
///
/// `clap` refuses `--date` beside `--clear-date` (and `--note` beside `--clear-note`)
/// before `commands::time` ever runs, and a `clap` usage block is not a sentence two
/// surfaces can share. This is therefore the one refusal the two spell differently,
/// and it exists because accepting the pair would accept a call the CLI rejects.
fn time_edit_conflict(field: &str, clear: &str) -> String {
format!(
"a time edit names `{field}` once: pass either `{field}` (the replacement value) or \
`{clear}` (remove it), never both — nothing was written"
)
}
/// Refuse any argument a time tool does not declare.
///
/// The declared model is closed (C-002, C-009): a duration in whole minutes, an
/// optional `YYYY-MM-DD` calendar date, an author and an optional note. Silently
/// ignoring `{"timer": true}` is how a client comes to believe it started one — and
/// how a timezone, an overlap policy or a billing rate would arrive by accident — so
/// an undeclared key is refused by name, with the reason and the remedy.
fn time_arguments(args: &Value, allowed: &[&str]) -> Option<Value> {
let object = args.as_object()?;
let unknown: Vec<&str> = object
.keys()
.map(String::as_str)
.filter(|key| !allowed.contains(key))
.collect();
if unknown.is_empty() {
return None;
}
let listed = unknown
.iter()
.map(|key| format!("`{key}`"))
.collect::<Vec<String>>()
.join(", ");
let verb = if unknown.len() == 1 { "is" } else { "are" };
Some(error_result(format!(
"invalid input: {listed} {verb} not a field of this tool. The time model is one duration in \
whole minutes plus an optional `YYYY-MM-DD` calendar date, an author and an optional note \
(C-002): there is no timer, no start/stop state, no timezone conversion, no overlap rule \
and no billing, payroll or invoicing field, and this surface has none either."
)))
}
/// The refusal a shared `tix` error renders as, verbatim.
///
/// [`crate::error::render_error`] is the crate's one error renderer, so a sentence this
/// surface shares with the CLI — the missing confirmation, an unknown `--by`
/// dimension, an unresolvable selector — reaches an agent as the bytes the CLI writes
/// to stderr.
fn time_refusal(error: &crate::error::TixError) -> Value {
error_result(crate::error::render_error(error))
}
/// The shared sentence for a caller's invalid input.
fn time_input_refusal(sentence: String) -> Value {
time_refusal(&crate::error::TixError::InvalidInput(sentence))
}
/// The summed minutes as a JSON number, or `null` when the scope counted nothing.
///
/// The shared accumulator holds an `i128` and every counted minute is at or above zero
/// ([`TimeEntry::minutes`] refuses a negative one), so `u64` is always wide enough for
/// a real store. A sum that somehow is not is refused rather than rounded, wrapped or
/// reported as `null`: a value the payload has and cannot type is not a value it may
/// quietly drop, and a silent `null` here would read as "nothing was counted".
fn time_minutes_value(minutes: Option<i128>) -> std::result::Result<Value, String> {
match minutes {
None => Ok(Value::Null),
Some(minutes) => u64::try_from(minutes)
.map(|minutes| Value::Number(minutes.into()))
.map_err(|_| {
format!(
"internal error: the counted total is {minutes} minutes, which is not a number \
this payload can carry; the text channel carries it, and nothing was written"
)
}),
}
}
/// One shared finding: the kind, what it is about, the reason and the remedy.
///
/// Every field is the DTO's own, and `scope`/`entry` is the DTO's own invariant
/// (`is_scope_finding`): a finding about an entry always names it.
fn time_diagnostic_value(finding: &TimeDiagnostic) -> Value {
json!({
"kind": finding.kind.as_str(),
"issue": finding.issue,
"entry_id": finding.entry_id,
"field": finding.field,
"reason": finding.reason,
"remedy": finding.remedy,
"scope": if finding.is_scope_finding() { "scope" } else { "entry" },
})
}
/// Every finding, in the DTO's own order (scope findings first, then per entry).
fn time_diagnostics_value(findings: &[TimeDiagnostic]) -> Value {
Value::Array(findings.iter().map(time_diagnostic_value).collect())
}
/// One current entry as the DTO's own fields.
///
/// The preserved metadata goes into a **nested** `extensions` object rather than being
/// flattened beside the typed fields. A flattened bag is how a preserved key comes to
/// shadow a projected one — the defect a sibling mission's review found, where a client
/// could no longer read back the document the tool had written — and an entry's
/// preserved keys are untyped by definition, so the payload keeps them apart.
/// `duration_minutes`, `date` and `note` are `null` when the entry carries none: a
/// malformed entry stays readable and never reads as zero minutes.
fn time_entry_value(issue: u32, entry: &TimeEntry) -> Value {
json!({
"issue": issue,
"id": entry.id,
"duration_minutes": entry.duration_minutes,
"date": entry.date,
"author": entry.author,
"note": entry.note,
"extensions": yaml_map_value(entry.extensions.as_map()),
})
}
/// One total as values: the DTO's fields, nothing recomputed.
///
/// `total_minutes` and `deleted_count` are `null` when the DTO says so — the first when
/// nothing was counted (never `0`), the second unless the filter asked for audit
/// context, because an unrequested count and a zero count are different facts.
fn time_total_value(total: &TimeTotal) -> std::result::Result<Value, String> {
Ok(json!({
"group": total.group.label(),
"scope": total.filter.describe(),
"state": total.state().as_str(),
"total_minutes": time_minutes_value(total.total_minutes)?,
"entry_count": total.entry_count,
"malformed_count": total.malformed_count,
"undated_count": total.undated_count,
"is_partial": total.is_partial(),
"deleted_count": total.deleted_count,
"diagnostics": time_diagnostics_value(&total.diagnostics),
}))
}
/// One scope's text and structure, or the refusal that has neither.
///
/// A `state` of `invalid_filter` or `unknown_issue` is a **refusal, not an answer**:
/// no entry was evaluated, so there is no total to report, and the findings say what
/// to fix. That is the CLI's own rule (it exits 2 on the same two states), and the
/// refusal *bytes* are the CLI's own too — [`cmd_time::refusal_blocks`] is the one owner
/// of that block, so an agent reading an error result reads the CLI's stderr.
fn time_scope_payload(total: &TimeTotal) -> std::result::Result<(String, Value), String> {
if matches!(
total.state(),
TimeTotalState::InvalidFilter | TimeTotalState::UnknownIssue
) {
return Err(cmd_time::refusal_blocks(&total.diagnostics));
}
Ok((cmd_time::scope_block(total), time_total_value(total)?))
}
/// The issue a time read is about, indexed on its own.
///
/// The crate's own selector grammar resolves it (`store.resolve_issue`), so an
/// archived or otherwise off-view issue is still readable by the selector that names
/// it — exactly as `tix time show` reads it — and the snapshot holds that one issue.
fn time_issue_snapshot(store: &Store, selector: &str) -> TixResult<(u32, TimeSnapshot)> {
let issue = store.resolve_issue(selector)?;
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
Ok((issue.number, snapshot))
}
/// `time_show`: the issue's current entries and its total.
fn time_show(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_SHOW_ARGS) {
return Ok(refusal);
}
let selector = selector(args, "issue")?;
let (number, snapshot) = match time_issue_snapshot(store, &selector) {
Ok(read) => read,
Err(error) => return Ok(time_refusal(&error)),
};
let filter = TimeFilter::new().for_issue(number);
let total = snapshot.total(&filter);
let (block, value) = match time_scope_payload(&total) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
// The CLI's own table, then the CLI's own scope block: one header line, one
// tab-separated row per selected entry, and the block.
let mut lines = vec!["id\tminutes\tdate\tauthor\tnote".to_string()];
let mut entries = Vec::new();
for indexed in snapshot.entries() {
if !filter.selects(indexed.issue, &indexed.entry) {
continue;
}
let entry = &indexed.entry;
lines.push(format!(
"{}\t{}\t{}\t{}\t{}",
entry.id,
cmd_time::number(entry.minutes()),
cmd_time::cell(entry.date.as_deref()),
cmd_time::cell(Some(entry.author.as_str())),
cmd_time::cell(entry.note.as_deref()),
));
entries.push(time_entry_value(indexed.issue, entry));
}
lines.push(block);
let mut value = value;
value["entries"] = Value::Array(entries);
// The CLI prints one line per `println!`, so its stdout ends with a newline; the
// text channel is that stdout verbatim, which is what the parity cases compare.
Ok(success_result(format!("{}\n", lines.join("\n")), value))
}
/// `time_history`: the audit view for one issue (FR-005, T017).
fn time_history(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_HISTORY_ARGS) {
return Ok(refusal);
}
let selector = selector(args, "issue")?;
let (number, snapshot) = match time_issue_snapshot(store, &selector) {
Ok(read) => read,
Err(error) => return Ok(time_refusal(&error)),
};
let audit = snapshot.audit(&TimeFilter::new().for_issue(number));
let current = audit.issues.first().map_or(0, |issue| issue.current);
let mut lines = vec![
format!("scope: {}", audit.filter.describe()),
format!("current: {current}"),
format!("deleted_count: {}", audit.deleted_count),
format!("corrected_count: {}", audit.corrected_count),
"entry\tdeleted_at\tdeleted_by".to_string(),
];
for issue in &audit.issues {
for deletion in &issue.deletions {
lines.push(format!(
"{}\t{}\t{}",
cmd_time::cell(deletion.entry_id.as_deref()),
cmd_time::cell(Some(deletion.deleted_at.as_str())),
cmd_time::cell(Some(deletion.deleted_by.as_str())),
));
}
}
let value = json!({
"scope": audit.filter.describe(),
"current": current,
"deleted_count": audit.deleted_count,
"corrected_count": audit.corrected_count,
"issues": audit.issues.iter().map(|issue| json!({
"issue": issue.issue,
"current": issue.current,
"corrections": issue.corrections,
"deletions": issue.deletions.iter().map(|deletion| json!({
"issue": deletion.issue,
"entry_id": deletion.entry_id,
"deleted_at": deletion.deleted_at,
"deleted_by": deletion.deleted_by,
})).collect::<Vec<Value>>(),
})).collect::<Vec<Value>>(),
"diagnostics": time_diagnostics_value(&audit.diagnostics),
});
// The CLI's stdout ends with the last `println!`'s newline; the text is it verbatim.
Ok(success_result(format!("{}\n", lines.join("\n")), value))
}
/// `time_total`: the scope's shared total, and optionally its breakdown (FR-003).
fn time_total(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_TOTAL_ARGS) {
return Ok(refusal);
}
// The selector is resolved first, before anything is read: `tix time total <ISSUE>`
// takes the same selector and refuses an unresolvable one the same way. An issue
// that resolves but is **archived** is `total`'s own refusal — the snapshot is the
// project's active enumeration, so it deliberately does not hold it — and it is the
// CLI's, rendered by the shared owner rather than re-worded here.
let selected = match opt_selector(args, "issue")? {
Some(raw) => match store.resolve_issue(&raw) {
Ok(issue) if issue.archived => {
return Ok(time_refusal(&cmd_time::archived_refusal(&issue, &raw)));
}
Ok(issue) => Some(issue.number),
Err(error) => return Ok(time_refusal(&error)),
},
None => None,
};
// The breakdown is the CLI's own parse, so an unknown `by` word is refused in the
// CLI's sentence.
let breakdown = match cmd_time::parse_breakdown(opt_string(args, "by")?.as_deref()) {
Ok(breakdown) => breakdown,
Err(error) => return Ok(time_refusal(&error)),
};
let mut filter = TimeFilter::new();
if let Some(number) = selected {
filter = filter.for_issue(number);
}
if let Some(assignee) = opt_string(args, "assignee")? {
filter = filter.for_assignee(assignee);
}
if let Some(from) = opt_string(args, "from")? {
filter = filter.since(from);
}
if let Some(to) = opt_string(args, "to")? {
filter = filter.until(to);
}
if opt_bool(args, "audit")?.unwrap_or(false) {
filter = filter.including_deleted();
}
// One snapshot over the project's active issues — the enumeration `tix list`,
// `tix capacity` and `tix time total` share — so no two blocks can count a
// different membership.
let snapshot = match store.list() {
Ok(issues) => TimeSnapshot::of(&issues),
Err(error) => return Ok(time_refusal(&error)),
};
let scope = snapshot.total(&filter);
let (mut text, mut value) = match time_scope_payload(&scope) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
if let Some(breakdown) = breakdown {
let groups = match breakdown {
cmd_time::Breakdown::Issue => snapshot.by_issue(&filter),
cmd_time::Breakdown::Assignee => snapshot.by_assignee(&filter),
cmd_time::Breakdown::Date => snapshot.by_date(&filter),
};
let mut values = Vec::with_capacity(groups.len());
for group in &groups {
// A breakdown of a scope that already answered cannot be refused — the
// groups partition the same selection — but the arm keeps a shared change
// from silently emitting an unrefused, unlabelled group.
let (block, group_value) = match time_scope_payload(group) {
Ok(payload) => payload,
Err(refusal) => return Ok(error_result(refusal)),
};
// The scope block, a blank line, then the group — as the CLI prints them.
text.push_str("\n\n");
text.push_str(&block);
values.push(group_value);
}
value["groups"] = Value::Array(values);
}
// The CLI's stdout ends with the last `println!`'s newline; the text is it verbatim.
Ok(success_result(format!("{}\n", text), value))
}
/// What an applied mutation did, in the vocabulary the payload and the CLI's sentence
/// both use.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeMutationStatus {
/// A create.
Created,
/// A correction that changed something.
Updated,
/// A request that asked for exactly what was already there.
Unchanged,
/// A deletion.
Deleted,
}
impl TimeMutationStatus {
/// The stable spelling the payload carries.
const fn as_str(self) -> &'static str {
match self {
Self::Created => "created",
Self::Updated => "updated",
Self::Unchanged => "unchanged",
Self::Deleted => "deleted",
}
}
}
/// Fold the shared attribution onto a request: who, when, why.
///
/// `actor` and `timestamp` are the service's own seams and are optional — `None` means
/// the crate's git identity and its own clock, exactly as the CLI's flags leave them.
/// `reason` is **not** validated here: the service requires one for an edit and a
/// delete and refuses a blank one by name, so the requirement keeps one owner rather
/// than two.
fn time_request(
args: &Value,
request: TimeMutationRequest,
) -> std::result::Result<TimeMutationRequest, ()> {
let mut request = request;
if let Some(actor) = opt_string(args, "actor")? {
request = request.by(actor);
}
if let Some(timestamp) = opt_string(args, "timestamp")? {
request = request.at(timestamp);
}
if let Some(reason) = opt_string(args, "reason")? {
request.attribution.reason = Some(reason);
}
Ok(request)
}
/// Apply one mutation through the shared service and answer with its own outcome.
///
/// The text is the CLI's own sentence for the same mutation; the structure carries the
/// service's outcome: the status, the stable id, the prior and new values (the
/// [`TimeEntry`] DTO's own fields, `null` for the side a mutation does not have) and
/// the **one** History event that was appended — `null` for the no-op an unchanged
/// request is, which is the only case that writes nothing (C-003, NFR-002).
fn time_mutation(store: &Store, request: &TimeMutationRequest) -> ToolOutcome {
let applied = match apply_time(store, request) {
Ok(applied) => applied,
Err(error) => return Ok(time_refusal(&error)),
};
let outcome = &applied.outcome;
let number = applied.issue.number;
let status = if !outcome.changed {
TimeMutationStatus::Unchanged
} else if outcome.before.is_none() {
TimeMutationStatus::Created
} else if outcome.after.is_some() {
TimeMutationStatus::Updated
} else {
TimeMutationStatus::Deleted
};
// The CLI's own sentence, from the CLI's one owner: a human reading either surface
// reads the same thing (C-006).
let text = cmd_time::mutation_sentence(&applied);
let structured = json!({
"status": status.as_str(),
"issue": number,
"entry_id": outcome.entry_id,
"changed": outcome.changed,
"before": outcome.before.as_ref().map(|entry| time_entry_value(number, entry)),
"after": outcome.after.as_ref().map(|entry| time_entry_value(number, entry)),
"history": applied.event().map(|event| json!({
"kind": event.kind.as_str(),
"actor": event.actor,
"timestamp": event.timestamp,
"detail": event.detail,
})),
});
Ok(success_result(format!("{text}\n"), structured))
}
/// `time_add`: one create, through the shared service.
fn time_add(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_ADD_ARGS) {
return Ok(refusal);
}
let issue = selector(args, "issue")?;
let minutes = req_i64(args, "minutes")?;
let author = req_string(args, "author")?;
let mut entry = NewTimeEntry::new(minutes, author);
if let Some(date) = opt_string(args, "date")? {
entry = entry.on(date);
}
if let Some(note) = opt_string(args, "note")? {
entry = entry.with_note(note);
}
let request = time_request(args, TimeMutationRequest::create(issue, entry))?;
time_mutation(store, &request)
}
/// `time_edit`: one correction, naming only the fields the caller replaced.
fn time_edit(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_EDIT_ARGS) {
return Ok(refusal);
}
let issue = selector(args, "issue")?;
let entry_id = req_string(args, "entry")?;
// `clap` refuses these pairs before the CLI's adapter runs, so the MCP refuses
// them too: accepting the pair would accept a call the CLI rejects.
let named = |key: &str| args.get(key).is_some_and(|value| !value.is_null());
if named("date") && named("clear_date") {
return Ok(time_input_refusal(time_edit_conflict("date", "clear_date")));
}
if named("note") && named("clear_note") {
return Ok(time_input_refusal(time_edit_conflict("note", "clear_note")));
}
let mut change = TimeEntryEdit::new();
if let Some(minutes) = opt_i64(args, "minutes")? {
change = change.duration(minutes);
}
if let Some(date) = opt_string(args, "date")? {
change = change.date(date);
}
if opt_bool(args, "clear_date")?.unwrap_or(false) {
change = change.clear_date();
}
if let Some(note) = opt_string(args, "note")? {
change = change.note(note);
}
if opt_bool(args, "clear_note")?.unwrap_or(false) {
change = change.clear_note();
}
if let Some(author) = opt_string(args, "author")? {
change = change.author(author);
}
let request = TimeMutationRequest::edit(
issue,
entry_id,
change,
opt_string(args, "reason")?.unwrap_or_default(),
);
let request = time_request(args, request)?;
time_mutation(store, &request)
}
/// `time_delete`: remove one entry from the current projection, under a confirmation.
///
/// The confirmation is checked **first**, before the store is read, exactly as the CLI
/// checks `--confirm` — so an unconfirmed delete touches nothing at all, and the
/// refusal is the CLI's own sentence.
fn time_delete(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(refusal) = time_arguments(args, TIME_DELETE_ARGS) {
return Ok(refusal);
}
if args.get("confirm").and_then(Value::as_bool) != Some(true) {
return Ok(time_input_refusal(
cmd_time::TIME_DELETE_CONFIRMATION.to_string(),
));
}
let issue = selector(args, "issue")?;
let entry_id = req_string(args, "entry")?;
let request = TimeMutationRequest::delete(
issue,
entry_id,
opt_string(args, "reason")?.unwrap_or_default(),
);
let request = time_request(args, request)?;
time_mutation(store, &request)
}
/// Required `i64` argument: an integer or nothing.
///
/// A float, a numeric string or a boolean is a strict `-32602` protocol error, the same
/// way [`req_u32`] refuses them; a negative value is *not* checked here, because the
/// shared entry validator is the one owner of that rule and it refuses one by field
/// name.
fn req_i64(args: &Value, key: &str) -> std::result::Result<i64, ()> {
args.get(key).and_then(Value::as_i64).ok_or(())
}
/// Optional `i64`; `null` counts as absent, like [`opt_string`].
fn opt_i64(args: &Value, key: &str) -> std::result::Result<Option<i64>, ()> {
let Some(value) = args.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value.as_i64().map(Some).ok_or(())
}
// ---------------------------------------------------------------------------
// WP07 (mission `burndown-scope-analytics-01M3C9J1`) — the analytics tools
// ---------------------------------------------------------------------------
//
// Four tools over the shared burndown/scope-analytics surface: the closed
// vocabularies and formulas (`analytics_metadata`), the daily count/estimate series
// (`analytics_burndown`), the read-only end-date forecast (`analytics_projection`)
// and the deterministic chart document (`analytics_export`).
//
// Nothing below folds a day, converts an estimate, resolves a period or serializes a
// document of its own. [`cmd_analytics`]'s report builders are the CLI's three verbs'
// own builders, so `content` is `tix analytics …`'s stdout byte for byte, and the
// document those builders returned **is** the `structuredContent`: a value the record
// cannot support stays `null` there (and `-` in the text), never a `0` (C-005), and a
// request-level refusal is [`crate::error::render_error`]'s block — the same bytes the
// CLI writes to stderr and exits non-zero with.
/// The one request shape every analytics tool shares: the CLI's own
/// [`AnalyticsScopeArgs`], so the two surfaces parse a scope, period, mode and unit one
/// way rather than through a second argument reader.
///
/// `scope` and `id` are required — a request with nothing to fold is not a request —
/// while the period bounds, mode and unit are optional exactly as the CLI's flags are.
fn analytics_args(args: &Value) -> std::result::Result<AnalyticsScopeArgs, ()> {
Ok(AnalyticsScopeArgs {
scope: req_string(args, "scope")?,
id: req_string(args, "id")?,
from: opt_string(args, "from")?,
to: opt_string(args, "to")?,
mode: opt_string(args, "mode")?,
unit: opt_string(args, "unit")?,
})
}
/// Refuse an argument that would name a **hosted** analytics source, by name.
///
/// [`call`] checks registry membership only — nothing enforces the
/// `additionalProperties: false` every schema declares — so a call carrying
/// `source: "gitlab"` would otherwise be answered from the local record while the caller
/// believed a remote one had been read. A configured `gitlab`/`linear` target cannot
/// carry a chart document at all (the document says so itself, as an
/// `unsupported_remote_field` finding), so no source *is* ever read here: this surface is
/// local-only by contract (C-009), and a silent drop would misstate which record produced
/// the numbers.
///
/// A targeted check, like `reject_retired_links`, and for the same reason: general
/// unknown-key rejection would change every tool's contract at once, which is not a
/// scope call to make inside a fix for a silent misattribution.
fn reject_hosted_source(args: &Value) -> Option<Value> {
["source", "target", "remote"].into_iter().find_map(|key| {
args.as_object()
.is_some_and(|object| object.contains_key(key))
.then(|| {
error_result(format!(
"invalid input: analytics reads only the local record, and `{key}` names \
a hosted source. There is no analytics service behind `gitlab`/`linear` \
\u{2014} those names are remote *export mappings*, not chart sources, and \
a configured target that cannot carry a chart document is reported by \
the document's own `unsupported_remote_field` finding. Name the local \
scope with `scope` and `id` instead"
))
})
})
}
/// The shared chart document as a tool's structured answer.
///
/// The document itself through `serde_json`: the one typed shape C-008 mandates, so the
/// MCP answer and the export's own bytes are one reading of the repository rather than
/// two shapes kept in step. An `Option` the record could not answer serializes as `null`
/// — never a `0` — and a coverage marker or a projection status as its stable spelling.
fn analytics_document_value(document: &ChartExport) -> Value {
serde_json::to_value(document).expect("chart document serialization is infallible")
}
/// `analytics_metadata`: the closed vocabularies, formulas and binding rules this build
/// answers with.
///
/// Every entry is the shared vocabulary's own — `ScopeKind::ALL`, `Mode::ALL` with
/// [`Mode::formula_version`]/[`precision_of`], `Coverage::ALL`, `ProjectionStatus::ALL`,
/// [`AnalyticsDiagnosticKind::ALL`] with its remedy table, the chart document's
/// `FORMAT`/`SCHEMA_VERSION`, and [`declared_units`] — so a seventh diagnostic kind or a
/// third mode appears here the moment the vocabulary grows instead of needing a second
/// list kept in this file. Read-only: the default board is loaded, never saved.
fn analytics_metadata(config: &Config, _store: &Store, args: &Value) -> ToolOutcome {
if let Some(value) = reject_hosted_source(args) {
return Ok(value);
}
let modes: Vec<Value> = Mode::ALL
.into_iter()
.map(|mode| {
json!({
"mode": mode.as_str(),
"formula_version": mode.formula_version(),
"precision": precision_of(mode),
"unit_required": mode == Mode::Estimate,
"default": mode == Mode::Count,
})
})
.collect();
let diagnostics: Vec<Value> = AnalyticsDiagnosticKind::ALL
.into_iter()
.map(|kind| json!({ "kind": kind.as_str(), "remedy": kind.default_remedy() }))
.collect();
let declared = declared_units(config);
let structured = json!({
"scope_kinds": ScopeKind::ALL.map(ScopeKind::as_str),
"period": {
"source": "the scope's own planning dates (a cycle's start_date/due_date, a \
milestone's target date)",
"overridable": ["from", "to"],
"format": "YYYY-MM-DD",
"max_days": MAX_SERIES_DAYS,
},
"modes": modes,
"units": {
"rule": "estimate mode needs a unit the project's default board declares a \
conversion for; count mode is unitless",
"declared": declared,
},
"projection": {
"formula_version": PROJECTION_FORMULA_VERSION,
"statuses": ProjectionStatus::ALL.map(ProjectionStatus::as_str),
"origin": "as_of (the caller's day, never a clock)",
"end_date": "the scope's own planning end date, or `to`",
},
"coverage": Coverage::ALL.map(Coverage::as_str),
"snapshot": "a scope bound to a closed snapshot folds that snapshot's period and \
provenance; events recorded after its close are not contributions",
"diagnostics": diagnostics,
"export": { "format": CHART_FORMAT, "schema_version": CHART_SCHEMA_VERSION },
"source": "the local record only \u{2014} no hosted analytics service (C-009)",
});
// The same facts as a block, for a reader that is not parsing JSON. Strictly derived
// from the values above, so the two cannot disagree about a vocabulary.
let mut text = String::new();
text.push_str("source: the local record only (no hosted analytics service)\n");
text.push_str(&format!(
"scope_kinds: {}\n",
ScopeKind::ALL.map(ScopeKind::as_str).join(", ")
));
text.push_str(&format!(
"period: the scope's own planning dates, replaceable one bound at a time with \
from/to; YYYY-MM-DD; at most {MAX_SERIES_DAYS} days\n"
));
for mode in Mode::ALL {
text.push_str(&format!(
"mode: {name} formula={formula} precision={precision} unit={unit}{default}\n",
name = mode.as_str(),
formula = mode.formula_version(),
precision = precision_of(mode),
unit = if mode == Mode::Estimate {
"required"
} else {
"unitless"
},
default = if mode == Mode::Count { " default" } else { "" },
));
}
if declared.is_empty() {
text.push_str(
"units: the default board declares none, so estimate mode refuses with \
missing_conversion\n",
);
} else {
text.push_str(&format!(
"units: the default board declares: {}\n",
declared.join(", ")
));
}
text.push_str(&format!(
"coverage: {}\n",
Coverage::ALL.map(Coverage::as_str).join(", ")
));
text.push_str(&format!(
"projection: formula={PROJECTION_FORMULA_VERSION} statuses: {}; origin as_of; \
end_date the scope's planning end or to\n",
ProjectionStatus::ALL
.map(ProjectionStatus::as_str)
.join(", ")
));
text.push_str(
"snapshot: a scope bound to a closed snapshot folds the snapshot's own period and \
provenance\n",
);
text.push_str(&format!(
"diagnostics: {}\n",
AnalyticsDiagnosticKind::ALL
.map(AnalyticsDiagnosticKind::as_str)
.join(", ")
));
text.push_str(&format!(
"export: {CHART_FORMAT} schema {CHART_SCHEMA_VERSION}\n"
));
Ok(success_result(text, structured))
}
/// `analytics_burndown`: the shared daily series, in count or estimate mode.
///
/// The text is `tix analytics burndown`'s stdout exactly — both call
/// [`cmd_analytics::burndown_report`] — and the structure is the document that text was
/// rendered from: the scope, period, snapshot binding, mode, unit and formula version,
/// the ordered daily points with a `null` for every value the record cannot support, the
/// coverage tally and the findings. Read-only, and refused in the CLI's own words when
/// the fold answers no day at all.
fn analytics_burndown(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(value) = reject_hosted_source(args) {
return Ok(value);
}
let request = analytics_args(args)?;
match cmd_analytics::burndown_report(config, store, &request) {
Ok(report) => Ok(success_result(
report.text,
analytics_document_value(&report.document),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
/// `analytics_projection`: the read-only end-date forecast at `as_of`.
///
/// An unanswerable forecast is an **answer**, not a refusal: the document's own
/// `status` (`insufficient_data`/`unknown`) and its `null` values say so, which is the
/// state the data model requires a surface to expose. The request-level refusals happen
/// before the fold, in the CLI's own words.
fn analytics_projection(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(value) = reject_hosted_source(args) {
return Ok(value);
}
let request = analytics_args(args)?;
let as_of = req_string(args, "as_of")?;
match cmd_analytics::projection_report(config, store, &request, &as_of) {
Ok(report) => Ok(success_result(
report.text,
analytics_document_value(&report.document),
)),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
/// `analytics_export`: WP04's deterministic chart document, as its own JSON.
///
/// The text is the document's pretty JSON — the bytes `tix analytics export` writes to
/// stdout — and the structure is that same document parsed as an object, so a client may
/// either store the bytes or read the fields. The CLI's `--output` path is deliberately
/// not an argument here: the document is returned rather than written, and this surface
/// opens no file. `mode` is required, exactly as the CLI requires `--mode`.
fn analytics_export(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
if let Some(value) = reject_hosted_source(args) {
return Ok(value);
}
let request = analytics_args(args)?;
let as_of = opt_string(args, "as_of")?;
match cmd_analytics::export_document(config, store, &request, as_of.as_deref()) {
Ok(document) => match document.to_json_pretty() {
Ok(json) => Ok(success_result(json, analytics_document_value(&document))),
Err(error) => Ok(error_result(crate::error::render_error(&error))),
},
Err(error) => Ok(error_result(crate::error::render_error(&error))),
}
}
fn dispatch(config: &Config, store: &Store, name: &str, arguments: &Value) -> Option<ToolOutcome> {
let outcome = match name {
"create_issue" => create_issue(config, store, arguments),
"list_issues" => list_issues(config, store, arguments),
"get_issue" => get_issue(config, store, arguments),
"board_status" => board_status(config, store, arguments),
// ---- WP07 / T031–T033: the project tools ------------------------------
//
// Every arm in the same change as its descriptor: a descriptor without an
// arm is advertised and then fails every call, and only
// `every_declared_tool_name_dispatches` can catch it — which it can only
// do because both sides move together.
"list_projects" => list_projects(config, store, arguments),
"get_project" => get_project(config, store, arguments),
"create_project" => create_project(config, store, arguments),
"update_project" => update_project(config, store, arguments),
"delete_project" => delete_project(config, store, arguments),
"project_membership" => project_membership(config, store, arguments),
"project_health" => project_health(config, store, arguments),
"project_associate_issues" => project_associate_issues(config, store, arguments),
"project_dissociate_issues" => project_dissociate_issues(config, store, arguments),
// WP07 / T035: the epic tools. All ten arms in the same change as the
// descriptors, because a descriptor without an arm is advertised and then
// fails every call — the failure `every_declared_tool_name_dispatches`
// exists to catch, and the two sides can only be caught by it if they move
// together.
"list_epics" => list_epics(config, store, arguments),
"get_epic" => get_epic(config, store, arguments),
"create_epic" => create_epic(config, store, arguments),
"update_epic" => update_epic(config, store, arguments),
"delete_epic" => delete_epic(config, store, arguments),
"epic_hierarchy" => epic_hierarchy(config, store, arguments),
"epic_membership" => epic_membership(config, store, arguments),
"epic_health" => epic_health(config, store, arguments),
"epic_associate_issues" => epic_associate_issues(config, store, arguments),
"epic_dissociate_issues" => epic_dissociate_issues(config, store, arguments),
"update_issue" => update_issue(config, store, arguments),
"move_issue" => move_issue(config, store, arguments),
"link_issue" => link_issue(config, store, arguments),
"list_relationships" => list_relationships(store, arguments),
"edit_relationship" => edit_relationship(store, arguments),
"remove_relationship" => remove_relationship(store, arguments),
"issue_chain" => issue_chain(store, arguments),
"issue_blocked_state" => issue_blocked_state(store, arguments),
// WP07 / T030. Registered *and* dispatched in the same change, because a
// descriptor without a dispatch arm is advertised and then fails every
// call — the failure mode `tools_list_returns_every_registered_tool`
// was written to prevent.
"list_children" => list_children(store, arguments),
"get_parent" => get_parent(store, arguments),
"get_rollup" => get_rollup(store, arguments),
// ---- WP08 / T038: the batch tools ------------------------------------
//
// All three arms here, and the registration sweep below is what proved they
// were needed: adding the descriptors without them compiles cleanly and the
// only symptom is a "declared but not dispatched" error on the first call.
"bulk_preview" => bulk_run(store, arguments, false),
"bulk_apply" => bulk_run(store, arguments, true),
"bulk_capabilities" => bulk_capabilities(config, store, arguments),
// ---- WP08 / T040: the triage tools ------------------------------------
//
// All four arms here, not three: the registration sweep
// (`every_declared_tool_name_dispatches`) is what catches a descriptor
// without an arm, and it only catches it because both sides are in this
// one change.
"triage_list" => triage_list(config, store, arguments),
"triage_status" => triage_status(config, store, arguments),
"triage_preview" => triage_run(config, store, arguments, false),
"triage_apply" => triage_run(config, store, arguments, true),
// ---- WP06 / T029–T031: the saved-view tools ---------------------------
//
// All nine arms here, in the same change as the descriptors: the
// registration sweep (`every_declared_tool_name_dispatches`) is what
// catches a descriptor without an arm, and it can only catch it because
// both sides move together.
"view_list" => view_list(config, store, arguments),
"view_show" => view_show(config, store, arguments),
"view_create" => view_create(config, store, arguments),
"view_update" => view_update(config, store, arguments),
"view_duplicate" => view_duplicate(config, store, arguments),
"view_delete" => view_delete(config, store, arguments),
"view_share" => view_share(config, store, arguments),
"view_apply" => view_apply(config, store, arguments),
"view_status" => view_status(config, store, arguments),
// ---- WP06 / T031: the template registry ------------------------------
//
// All six arms in the same change as the descriptors, because a
// descriptor without an arm is advertised and then fails every call —
// the failure `every_declared_tool_dispatches` exists to catch.
"list_templates" => list_templates(config, store, arguments),
"get_template" => get_template(config, store, arguments),
"create_template" => create_template(config, store, arguments),
"update_template" => update_template(config, store, arguments),
"duplicate_template" => duplicate_template(config, store, arguments),
"delete_template" => delete_template(config, store, arguments),
// ---- WP08 / T038-T042: the recurring tools ----------------------------
//
// All ten arms in the same change as the descriptors, because a descriptor
// without an arm is advertised and then fails every call — the failure
// `every_declared_tool_dispatches` exists to catch, and the two sides can
// only be caught by it if they move together.
"list_recurring_series" => list_recurring_series(config, store, arguments),
"get_recurring_series" => get_recurring_series(config, store, arguments),
"detect_recurring_due" => detect_recurring_due(config, store, arguments),
"generate_recurring_occurrence" => generate_recurring_occurrence(config, store, arguments),
"pause_recurring_series" => pause_recurring_series(config, store, arguments),
"resume_recurring_series" => resume_recurring_series(config, store, arguments),
"end_recurring_series" => end_recurring_series(config, store, arguments),
"skip_recurring_occurrence" => skip_recurring_occurrence(config, store, arguments),
"defer_recurring_occurrence" => defer_recurring_occurrence(config, store, arguments),
"ignore_recurring_occurrence" => ignore_recurring_occurrence(config, store, arguments),
// ---- WP08 (mission `repeating-cycle-schedules-01M3C7W1`): the cycle-schedule
// tools ---------------------------------------------------------------
//
// All fifteen arms in the same change as the descriptors — one per
// `tix cycle schedule <verb>` plus the snapshot read — because a descriptor
// without an arm is advertised and then fails every call, the failure
// `every_declared_tool_name_dispatches` exists to catch, and it can only
// catch it because the two sides move together. The list is deliberately
// exhaustive against `CycleScheduleCommand`: list/show/create/edit/delete/
// detect/generate, then the seven lifecycle verbs, then the snapshots.
"cycle_schedule_list" => cycle_schedule_list(config, store, arguments),
"cycle_schedule_show" => cycle_schedule_show(config, store, arguments),
"cycle_schedule_create" => cycle_schedule_create(config, store, arguments),
"cycle_schedule_edit" => cycle_schedule_edit(config, store, arguments),
"cycle_schedule_delete" => cycle_schedule_delete(config, store, arguments),
"cycle_schedule_detect" => cycle_schedule_detect(config, store, arguments),
"cycle_schedule_generate" => cycle_schedule_generate(config, store, arguments),
"cycle_schedule_close" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Close)
}
"cycle_schedule_reopen" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Reopen)
}
"cycle_schedule_extend" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Extend)
}
"cycle_schedule_skip" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Skip)
}
"cycle_schedule_pause" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Pause)
}
"cycle_schedule_resume" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Resume)
}
"cycle_schedule_adjust_future" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::AdjustFuture)
}
"cycle_schedule_ignore" => {
cycle_schedule_lifecycle(store, arguments, CycleLifecycleAction::Ignore)
}
"cycle_snapshot_list" => cycle_snapshot_list(config, store, arguments),
// ---- WP07 (mission `cycle-capacity-planning-01M3C85C`): the capacity tools --
//
// All four arms in the same change as the descriptors, because a descriptor
// without an arm is advertised and then fails every call — the failure
// `every_declared_tool_name_dispatches` exists to catch, and it can only catch
// it because both sides move together.
"capacity_list" => capacity_list(config, store, arguments),
"capacity_show" => capacity_show(config, store, arguments),
"capacity_calculate" => capacity_calculate(config, store, arguments),
"capacity_snapshot" => capacity_snapshot(config, store, arguments),
"delete_issue" => delete_issue(store, arguments),
"search_issues" => search_issues(config, store, arguments),
"comment_on_issue" => comment_on_issue(config, store, arguments),
// OpenSpec change `issue-mentions`. Both arms call the `src/commands/mentions.rs`
// core, so the MCP surface and `tix mentions` cannot drift.
"mentions_list" => mentions_list(config, store, arguments),
"mentions_mark_read" => mentions_mark_read(config, store, arguments),
"export_issues" => export_issues(config, store, arguments),
"import_issues" => import_issues(config, store, arguments),
"lint_issues" => lint_issues(config, store, arguments),
"health_scope" => health_scope(config, store, arguments),
"health_rollup" => health_rollup(config, store, arguments),
"health_history" => health_history(config, store, arguments),
"health_record" => health_record(config, store, arguments),
// ---- WP06 / T027–T031: the initiative and roadmap tools --------------
//
// All nine arms in the same change as the descriptors: a descriptor
// without an arm is advertised and then fails every call, and only
// `every_declared_tool_name_dispatches` can catch it — which it can only
// do because both sides move together.
"list_initiatives" => list_initiatives(config, store, arguments),
"get_initiative" => get_initiative(config, store, arguments),
"create_initiative" => create_initiative(config, store, arguments),
"update_initiative" => update_initiative(config, store, arguments),
"initiative_associate" => initiative_associate(config, store, arguments),
"initiative_dereference" => initiative_dereference(config, store, arguments),
"delete_initiative" => delete_initiative(config, store, arguments),
"roadmap_list" => roadmap_list(config, store, arguments),
"roadmap_show" => roadmap_show(config, store, arguments),
"roadmap_summary" => roadmap_summary(config, store, arguments),
// ---- WP06 (mission `per-issue-time-tracking-01M3C9A4`): the time tools ------
//
// All six arms in the same change as the descriptors, because a descriptor
// without an arm is advertised and then answers every call with "unknown tool"
// — the failure `every_declared_tool_name_dispatches` exists to catch, and it
// can only catch it because both sides move together.
"time_show" => time_show(config, store, arguments),
"time_history" => time_history(config, store, arguments),
"time_total" => time_total(config, store, arguments),
"time_add" => time_add(config, store, arguments),
"time_edit" => time_edit(config, store, arguments),
"time_delete" => time_delete(config, store, arguments),
// ---- WP07 (mission `burndown-scope-analytics-01M3C9J1`): the analytics tools --
//
// All four arms in the same change as the descriptors, because a descriptor
// without an arm is advertised and then fails every call — the failure
// `every_declared_tool_name_dispatches` exists to catch, and it can only catch it
// because the two sides move together.
"analytics_metadata" => analytics_metadata(config, store, arguments),
"analytics_burndown" => analytics_burndown(config, store, arguments),
"analytics_projection" => analytics_projection(config, store, arguments),
"analytics_export" => analytics_export(config, store, arguments),
_ => return None,
};
Some(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use std::fs;
use tempfile::TempDir;
/// A test config with auto-migration off, so tests that use bare/legacy
/// fixtures stay about their own concern.
fn test_config() -> Config {
let mut config = Config::default();
config.layout.move_on_change = false;
config
}
/// A repo whose board **declares a `category_map`**, so transition policy
/// applies (WP07 T033).
///
/// The default `make_repo` board declares none, and on such a board the seam
/// imposes **no** transition policy at all (WP03's opt-in) — so every parity
/// assertion written against it would pass vacuously. That is not hypothetical:
/// an earlier draft of these cases used it and proved nothing.
fn mapped_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config = Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec![
"triage".into(),
"wip".into(),
"review".into(),
"shipped".into(),
"abandoned".into(),
],
done_column: Some("shipped".to_string()),
category_map: indexmap::IndexMap::from([
("triage".to_string(), "backlog".to_string()),
("wip".to_string(), "active".to_string()),
("review".to_string(), "active".to_string()),
("shipped".to_string(), "completed".to_string()),
("abandoned".to_string(), "canceled".to_string()),
]),
..Default::default()
},
)]),
},
..test_config()
};
let config_path = tmp.path().join("tix.toml");
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn make_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = Config {
issues_dir: dir.clone(),
..test_config()
};
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
fn write_issue(store: &Store, number: u32, title: &str, status: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
contents,
)
.unwrap();
}
fn invoke(config: &Config, store: &Store, name: &str, args: Value) -> Value {
call(config, store, protocol::Id::Number(1), name, &args)
.expect("protocol-level calls in unit tests must not fail")
}
/// T033 step 2: **every declared tool name dispatches.**
///
/// `dispatch` answers `None` both for a name it does not know and for a name it
/// knows but does not handle — the registry and the `match` can drift apart, and
/// a tool in that gap is a server that answers every call to it with "unknown
/// tool". An agent discovers that at runtime; this fails at test time.
///
/// A **fresh** store with no issues, so a handler that legitimately needs one
/// reports a missing issue rather than being skipped. The assertion is only on
/// `Some(..)` — what the handler *did* is not this test's business, and
/// demanding success here would make a tool that correctly refuses an unknown
/// selector look unregistered.
#[test]
fn every_declared_tool_name_dispatches() {
let (_tmp, config, store) = make_repo();
for tool in list() {
let name = tool["name"].as_str().expect("a tool name");
let outcome = dispatch(&config, &store, name, &json!({}));
assert!(
outcome.is_some(),
"`{name}` is declared in the registry but `dispatch` returns None for \
it, so every call would answer \"unknown tool\". Either add the arm \
or drop the declaration."
);
}
}
/// The converse: `dispatch` must answer `None` for a name nobody declared. Without
/// this, a sweep above could be satisfied by a `dispatch` that returns `Some`
/// for everything.
#[test]
fn dispatch_rejects_a_name_nobody_declared() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "definitely_not_a_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
}
#[test]
fn every_declared_tool_has_a_valid_schema() {
for tool in list() {
let schema = &tool["inputSchema"];
assert_eq!(schema["type"], "object", "{}", tool["name"]);
assert_eq!(schema["additionalProperties"], false, "{}", tool["name"]);
assert!(
tool["description"].as_str().is_some_and(|d| !d.is_empty()),
"{}",
tool["name"]
);
}
}
/// The registration sweep T023 asks for, and the reason this file no longer
/// maintains a hand-written list of tool names.
///
/// A `match` over tool names can silently drift from the registry: a tool
/// declared but not dispatched is a server that answers every call to it
/// with an error, and until now the only signal was an `unreachable!`
/// **panic** on first use — discovered by an agent, not by a test. Asking
/// `dispatch` directly turns that into a build-time-visible failure, and
/// deriving the names from `registry()` means a newly declared tool is
/// covered the moment it is added.
/// `docs/mcp.md` states the tool count in three places, and it was wrong in
/// all three — 13, over a table with 18 rows — for as long as the six
/// relationship tools existed. `tools_list_returns_every_registered_tool` had
/// its count assertion *removed* during that change, so nothing noticed.
///
/// The count is a documentation claim about a registry, so it is checked
/// against the registry. Derived prose cannot be kept in step by hand.
#[test]
fn the_documented_tool_count_matches_the_registry() {
let registered = registry().len();
let doc = include_str!("../../docs/mcp.md");
// Every place the doc states the count, found by pattern rather than by
// hand. The three are: "the agent gains N tools", "## The N tools", and
// "call the N tools".
let mut stated: Vec<(usize, &str)> = Vec::new();
for line in doc.lines() {
let after = |marker: &str| -> Option<usize> {
line.split(marker)
.nth(1)?
.split_whitespace()
.next()?
.parse::<usize>()
.ok()
};
for marker in ["gains ", "## The ", "call the "] {
if let Some(n) = after(marker) {
stated.push((n, line.trim()));
}
}
}
assert_eq!(
stated.len(),
3,
"the doc states the count in three places and all three must be found; \
found {stated:?}"
);
for (n, line) in stated {
assert_eq!(
n, registered,
"docs/mcp.md says {n} tools (`{line}`); the registry has {registered}"
);
}
// And the table beside the count lists exactly the registry's tools.
//
// Compared as *names*, not as a row count: the section also contains the
// migration before/after table, and — more to the point — a count can be
// right while the table is wrong. Listing the same 18 tools under the wrong
// heading is the failure that matters.
let listed: Vec<&str> = doc
.lines()
.filter_map(|l| l.strip_prefix("| `"))
.filter_map(|l| l.split('`').next())
.filter(|name| registry().iter().any(|d| d.name == *name))
.collect();
let mut expected: Vec<&str> = registry().iter().map(|d| d.name).collect();
expected.sort_unstable();
let mut got = listed.clone();
got.sort_unstable();
got.dedup();
assert_eq!(
got, expected,
"the tool table must list every registered tool exactly once; \
missing or extra entries show here"
);
// **Order is deliberately not compared.** The table is grouped by workflow
// — create, read, move, relate, delete, search, sync, lint — which reads
// better than the registry's declaration order, and pinning the latter
// would make a deliberate regrouping look like a defect.
assert_eq!(
listed.len(),
expected.len(),
"one row per registered tool, with no duplicates"
);
}
#[test]
fn every_declared_tool_dispatches() {
let (_tmp, config, store) = make_repo();
let mut undispatched = Vec::new();
for tool in registry() {
if dispatch(&config, &store, tool.name, &json!({})).is_none() {
undispatched.push(tool.name);
}
}
assert!(
undispatched.is_empty(),
"declared but not dispatched: {undispatched:?}"
);
}
/// A name the registry does not declare is rejected by `call` before
/// dispatch, and dispatch answers `None` for anything undeclared too — so
/// the two layers agree and neither panics.
#[test]
fn an_undeclared_tool_name_is_a_clean_error_not_a_panic() {
let (_tmp, config, store) = make_repo();
assert!(
dispatch(&config, &store, "no_such_tool", &json!({})).is_none(),
"an undeclared name must not dispatch"
);
let result = call(
&config,
&store,
protocol::Id::Number(1),
"no_such_tool",
&json!({}),
);
assert!(result.is_err(), "an undeclared name is a protocol error");
}
#[test]
fn arg_helpers_reject_wrong_types() {
let args = json!({ "title": 42, "dry_run": "yes", "issue": true });
assert!(req_string(&args, "title").is_err());
assert!(opt_string(&args, "title").is_err());
assert!(opt_bool(&args, "dry_run").is_err());
assert!(selector(&args, "issue").is_err());
assert_eq!(selector(&json!({ "issue": 123 }), "issue").unwrap(), "123");
assert_eq!(
selector(&json!({ "issue": "n 1" }), "issue").unwrap(),
"n 1"
);
}
#[test]
fn null_optionals_are_treated_as_absent() {
assert_eq!(opt_string(&json!({ "x": null }), "x").unwrap(), None);
assert_eq!(opt_bool(&json!({ "x": null }), "x").unwrap(), None);
assert!(req_string(&json!({ "x": null }), "x").is_err());
}
#[test]
fn call_type_mismatch_is_invalid_params() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(1),
"create_issue",
&json!({ "title": 5 }),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::INVALID_PARAMS);
assert_eq!(err.id, protocol::Id::Number(1));
}
#[test]
fn call_unknown_tool_is_method_not_found() {
let (_tmp, config, store) = make_repo();
let err = call(
&config,
&store,
protocol::Id::Number(2),
"bogus",
&json!({}),
)
.unwrap_err();
assert_eq!(err.error.code, protocol::METHOD_NOT_FOUND);
}
#[test]
fn create_issue_writes_file_and_returns_structured_result() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Build the tool registry", "labels": "mcp, core", "priority": "high" }),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["title"], "Build the tool registry");
assert_eq!(structured["labels"], json!(["mcp", "core"]));
assert_eq!(structured["priority"], "high");
let filename = structured["filename"].as_str().unwrap();
assert!(store.issues_dir.join(filename).exists());
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.starts_with("Created ")
);
}
#[test]
fn delete_issue_requires_confirm_and_files_survive_refusal() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let refused = invoke(&config, &store, "delete_issue", json!({ "issue": "1" }));
assert_eq!(refused["isError"], true);
assert!(store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
let done = invoke(
&config,
&store,
"delete_issue",
json!({ "issue": 1, "confirm": true }),
);
assert_eq!(done["isError"], false);
assert_eq!(
done["structuredContent"]["deleted"],
"alice/2026-01-01-alpha.md"
);
assert!(!store.issues_dir.join("alice/2026-01-01-alpha.md").exists());
}
#[test]
fn update_issue_with_no_fields_is_a_tool_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "update_issue", json!({ "issue": "1" }));
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update")
);
}
#[test]
fn create_issue_accepts_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-create-1.md"),
"---\nname: v1.3\n---\n",
)
.unwrap();
let result = invoke(
&config,
&store,
"create_issue",
json!({
"title": "Planned",
"assignee": "",
"cycles": "Cycle 1",
"milestones": "v1.3",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.3"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn update_issue_sets_cycles_milestones_reviewer_and_dates() {
let (_tmp, config, store) = make_repo();
let mut config = config;
config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let milestone_dir =
crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new())
.milestones_dir();
std::fs::create_dir_all(&milestone_dir).unwrap();
std::fs::write(
milestone_dir.join("2026-09-21-mcp-update-1.md"),
"---\nname: v1.2\n---\n",
)
.unwrap();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Alpha", "assignee": "" }),
);
let num = created["structuredContent"]["number"].as_u64().unwrap();
let result = invoke(
&config,
&store,
"update_issue",
json!({
"issue": num,
"cycles": "Cycle 1",
"milestones": "v1.2",
"reviewer": "Alice",
"start_date": "2026-09-07",
"due_date": "2026-09-18"
}),
);
assert_eq!(result["isError"], false);
let structured = &result["structuredContent"];
assert_eq!(structured["cycles"], json!(["Cycle 1"]));
assert_eq!(structured["milestones"], json!(["v1.2"]));
assert_eq!(structured["reviewer"], "Alice");
assert_eq!(structured["start_date"], "2026-09-07");
assert_eq!(structured["due_date"], "2026-09-18");
}
#[test]
fn create_issue_rejects_unknown_planning_refs_with_cli_text() {
let (_tmp, config, store) = make_repo();
let result = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Broken", "cycles": "Nope", "milestones": "Missing" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid input: unknown cycle `Nope` — valid cycles: none configured"),
"got: {text}"
);
}
#[test]
fn move_issue_unknown_column_reports_the_shared_named_diagnostic() {
// WP03: was `move_issue_unknown_column_mirrors_cli_error_text`, and it
// asserted an exact string that was **MCP's own** — so the test's name said
// "mirrors the CLI" while pinning the copy that research R8 identified as
// the bypass, and the CLI's text was a second, separately-maintained
// spelling of one condition. Contract §6 wants one name for one condition.
//
// The two things worth pinning are now: the named kind (which is what every
// surface shares) and the valid-column list (the information the old
// message carried and which a user needs to recover from). The exact prose
// is not the contract.
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "nope" }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("(unknown_column)"),
"the shared diagnostic name must reach the client: {text}"
);
assert!(
text.contains("valid columns: todo, in progress, in review, done"),
"the valid-column list is what the user needs in order to recover: {text}"
);
}
#[test]
fn lint_clean_store_reports_ok() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(&config, &store, "lint_issues", json!({}));
assert_eq!(result["isError"], false);
// WP06: the summary line is now "Lint OK with N warnings" on a board that
// relies on the safe default, because `unmapped_column_default` is a
// Warning and is reported (T026 step 3). The assertion that matters is that
// the store is OK and the exit is not an error — so it checks the
// `isError` flag and the "Lint OK" prefix, not the exact wording, which now
// depends on a configuration detail this test is not about.
// WP06: the default board has no `category_map`, so `tix lint` reports
// `unmapped_column_default` for three of its columns — as **warnings**. The
// store is therefore OK and the tool is not an error, and the text says
// exactly what the CLI says for the same store.
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.starts_with("Lint OK with 1 warning: 1 issues checked."),
"the CLI's own wording, so the two surfaces agree: {text}"
);
}
#[test]
fn export_without_gitlab_config_is_a_structured_error() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Alpha", "todo");
let result = invoke(
&config,
&store,
"export_issues",
json!({ "target": "gitlab", "dry_run": true }),
);
assert_eq!(result["isError"], true);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(text.contains("no `[export.gitlab]` section"), "got: {text}");
}
// ---- WP05: the typed relationship tools (T021, T022) --------------
//
// One accessor, three shapes. The tests below never destructure a result:
// they read `err(&r)`, `text(&r)` and `sc(&r)`, so adding a field to the
// response shape cannot silently shift a positional assertion.
/// The `isError` flag.
fn err(r: &Value) -> bool {
r["isError"].as_bool().unwrap_or(false)
}
/// The human-readable text — where the diagnostic an agent actually reads is.
fn text(r: &Value) -> String {
r["content"][0]["text"].as_str().unwrap().to_string()
}
/// The structured payload.
fn sc(r: &Value) -> &Value {
&r["structuredContent"]
}
/// Every issue file's bytes. An `isError` flag is not evidence that a
/// rejected call wrote nothing; a directory snapshot is.
fn snap(store: &Store) -> std::collections::BTreeMap<String, String> {
store
.list()
.unwrap()
.into_iter()
.map(|i| {
(
i.filename.clone(),
fs::read_to_string(store.issues_dir.join(&i.filename)).unwrap(),
)
})
.collect()
}
fn three_issues() -> (TempDir, Config, Store) {
let (tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
write_issue(&store, 2, "B", "todo");
write_issue(&store, 3, "C", "todo");
(tmp, config, store)
}
#[test]
fn t021_a_missing_kind_is_rejected_and_names_the_argument() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(&config, &store, "link_issue", json!({ "a": "1", "b": "2" }));
assert!(err(&r), "a missing kind must be rejected: {}", text(&r));
let t = text(&r);
// The diagnostic *kind* and *field* are the service's, identical to the
// CLI's; only the guidance names the argument this surface uses, which
// is what `surface-parity.md` §2 allows as presentation.
assert!(t.contains("unknown_kind"), "{t}");
assert!(t.contains("`kind`"), "names the argument: {t}");
for spelling in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(t.contains(spelling), "lists `{spelling}`: {t}");
}
assert_eq!(snap(&store), before, "a rejected create writes nothing");
}
#[test]
fn t021_an_unknown_kind_rejects_with_the_clis_exact_text() {
// One parser on both surfaces, so the two cannot drift.
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "depends-on" }),
);
assert!(err(&r));
let expected = crate::error::render_error(
&crate::commands::link::parse_kind("depends-on").unwrap_err(),
);
assert_eq!(
text(&r),
expected,
"MCP and the CLI must say the same thing"
);
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_create_returns_id_kind_target_and_display() {
let (_tmp, config, store) = three_issues();
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
assert!(!err(&r), "{}", text(&r));
let rel = &sc(&r)["relationship"];
assert!(
rel["id"].as_str().is_some_and(|s| s.starts_with("rel_")),
"{rel}"
);
assert_eq!(rel["kind"], "blocks");
assert_eq!(rel["from"], 1);
assert_eq!(rel["to"], 2);
assert_eq!(rel["display"], "blocks #2 B");
assert_eq!(sc(&r)["changed"], true);
}
#[test]
fn t021_a_self_link_is_rejected_with_the_clis_diagnostic() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "A", "todo");
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "1", "kind": "blocks" }),
);
assert!(err(&r));
assert!(text(&r).contains("self_link"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_a_missing_target_is_rejected_and_nothing_is_written() {
let (_tmp, config, store) = three_issues();
let before = snap(&store);
let r = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "999", "kind": "blocks" }),
);
assert!(err(&r), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_edit_preserves_the_id_and_remove_is_idempotent() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let edited = invoke(
&config,
&store,
"edit_relationship",
json!({ "id": id, "to": "3" }),
);
assert!(!err(&edited), "{}", text(&edited));
assert_eq!(sc(&edited)["relationship"]["id"], id.as_str());
assert_eq!(sc(&edited)["relationship"]["to"], 3);
let removed = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&removed), "{}", text(&removed));
assert_eq!(sc(&removed)["changed"], true);
let after_first = snap(&store);
// A second remove is a success that writes nothing — safe to retry.
let again = invoke(&config, &store, "remove_relationship", json!({ "id": id }));
assert!(!err(&again), "an absent id is a no-op, not a failure");
assert_eq!(sc(&again)["changed"], false);
assert_eq!(snap(&store), after_first, "no second write");
}
#[test]
fn t021_an_edit_with_nothing_to_change_is_refused() {
let (_tmp, config, store) = three_issues();
let created = invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let id = sc(&created)["relationship"]["id"]
.as_str()
.unwrap()
.to_string();
let before = snap(&store);
let r = invoke(&config, &store, "edit_relationship", json!({ "id": id }));
assert!(err(&r), "the identity edit must be refused: {}", text(&r));
assert!(text(&r).contains("nothing to change"), "{}", text(&r));
assert_eq!(snap(&store), before);
}
#[test]
fn t021_list_is_ordered_by_kind_then_target_and_filters_exactly() {
let (_tmp, config, store) = make_repo();
for (n, t) in [(1, "A"), (2, "B"), (3, "C"), (4, "D")] {
write_issue(&store, n, t, "todo");
}
for (target, kind) in [("2", "blocks"), ("3", "duplicates"), ("4", "related")] {
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": target, "kind": kind }),
);
}
let all = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
let kinds: Vec<&str> = sc(&all)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(
kinds,
vec!["blocks", "duplicates", "related"],
"{}",
text(&all)
);
// `blocked_by` filters on the CANONICAL blocking edge; no stored edge
// ever carries that kind, so a literal filter would match nothing.
let by_input = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1", "kind": "blocked_by" }),
);
let kinds: Vec<&str> = sc(&by_input)["relationships"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap())
.collect();
assert_eq!(kinds, vec!["blocks"], "must not match nothing");
// Byte-comparable across runs, which is what the parity assertion needs.
let again = invoke(
&config,
&store,
"list_relationships",
json!({ "issue": "1" }),
);
assert_eq!(text(&all), text(&again));
}
#[test]
fn t022_chain_returns_the_service_order_with_numbers_and_titles() {
let (_tmp, config, store) = three_issues();
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
invoke(
&config,
&store,
"link_issue",
json!({ "a": "2", "b": "3", "kind": "blocks" }),
);
let r = invoke(&config, &store, "issue_chain", json!({ "issue": "1" }));
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["truncated"], false);
let path = sc(&r)["paths"][0].as_array().unwrap().clone();
let numbers: Vec<u64> = path.iter().map(|n| n["number"].as_u64().unwrap()).collect();
assert_eq!(numbers, vec![1, 2, 3], "the service's order, unmodified");
// Numbers AND titles: an agent must be able to address the next node.
assert_eq!(path[2]["title"], "C");
}
#[test]
fn t022_blocked_state_reports_the_service_reason_verbatim() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert!(!err(&r), "{}", text(&r));
assert_eq!(sc(&r)["is_blocked"], true);
assert_eq!(sc(&r)["blockers"][0]["number"], 1);
// Byte-identical to the CLI warning, the TUI prompt, the board card and
// the detail view — all of which render `blocked_note`.
let expected = format!(
"blocked: {}",
crate::relationships::blocked_reason(&[crate::relationships::ChainNode::new(
1,
"Blocker".to_string(),
)])
);
assert_eq!(text(&r), expected);
assert_eq!(sc(&r)["reason"], expected.trim_start_matches("blocked: "));
}
#[test]
fn t022_only_related_is_not_blocked() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Other", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "related" }),
);
let r = invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": "2" }),
);
assert_eq!(sc(&r)["is_blocked"], false, "{}", text(&r));
assert_eq!(
sc(&r)["blockers"].as_array().unwrap().as_slice(),
[] as [serde_json::Value; 0]
);
assert_eq!(text(&r), "not blocked");
}
#[test]
fn t022_the_read_tools_are_genuinely_read_only() {
let (_tmp, config, store) = make_repo();
write_issue(&store, 1, "Blocker", "todo");
write_issue(&store, 2, "Subject", "todo");
invoke(
&config,
&store,
"link_issue",
json!({ "a": "1", "b": "2", "kind": "blocks" }),
);
let before = snap(&store);
for issue in ["1", "2"] {
invoke(&config, &store, "issue_chain", json!({ "issue": issue }));
invoke(
&config,
&store,
"issue_blocked_state",
json!({ "issue": issue }),
);
invoke(
&config,
&store,
"list_relationships",
json!({ "issue": issue }),
);
}
assert_eq!(
snap(&store),
before,
"a read tool must not write a file or append History"
);
}
// ---------------------------------------------------------------------------
// WP07 T033 — MCP parity with the CLI, and the override that was missing
// ---------------------------------------------------------------------------
/// The persisted `status` of issue 1, unquoted.
fn persisted_status(store: &Store) -> String {
let entry = fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.expect("issue file");
fs::read_to_string(entry)
.unwrap()
.lines()
.find_map(|l| l.strip_prefix("status: "))
.unwrap()
.trim()
.trim_matches('"')
.to_string()
}
fn whole_store_bytes(store: &Store) -> Vec<(std::path::PathBuf, Vec<u8>)> {
let mut out: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
for entry in fs::read_dir(&store.issues_dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
for child in fs::read_dir(&path).unwrap() {
let child = child.unwrap().path();
out.push((child.clone(), fs::read(&child).unwrap()));
}
}
}
out.sort();
out
}
#[test]
fn t033_move_issue_can_leave_a_terminal_column_with_a_reason() {
// The gap WP03 recorded and WP04 could not close: `move_issue` passed `None` for
// the override, so on a board that declares a `category_map` an MCP client could
// not leave a terminal column **at all**. Now it can, and the reason is recorded
// in the same `TransitionDecision` the CLI writes.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
// Without a reason: refused, by the shared diagnostic, and nothing written.
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip" }),
);
assert_eq!(refused["isError"], true, "a terminal exit needs a reason");
let text = refused["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("invalid_transition"),
"the shared named diagnostic: {text}"
);
assert_eq!(whole_store_bytes(&store), before, "and it wrote nothing");
// With one: applied, and recorded.
let ok = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": "reopening: wrong epic" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
let issue = fs::read_to_string(
fs::read_dir(store.issues_dir.join("alice"))
.unwrap()
.map(|e| e.unwrap().path())
.find(|p| p.to_string_lossy().contains("alpha"))
.unwrap(),
)
.unwrap();
assert!(
issue.contains("override: reopening: wrong epic"),
"and the reason is in the audit trail: {issue}"
);
}
#[test]
fn t033_update_issue_status_can_leave_a_terminal_column_with_a_reason() {
// The same through the *other* tool, because `update_issue --status` is a move
// and must offer the same escape (contract §2, `cli-surface.md` §2).
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip" }),
);
assert_eq!(refused["isError"], true);
assert!(
refused["content"][0]["text"]
.as_str()
.unwrap()
.contains("invalid_transition"),
"the same diagnostic as `move_issue`"
);
assert_eq!(whole_store_bytes(&store), before);
let ok = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "status": "wip", "reason": "reopening" }),
);
assert_eq!(ok["isError"], false, "{}", ok["content"][0]["text"]);
assert_eq!(persisted_status(&store), "wip");
}
#[test]
fn t033_a_blank_reason_is_not_a_reason_over_mcp_either() {
// The same rule the CLI applies, asserted here so MCP cannot be the one surface
// that accepts a whitespace-only override.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "shipped");
let before = whole_store_bytes(&store);
let result = invoke(
&config,
&store,
"move_issue",
json!({ "issue": "1", "column": "wip", "reason": " " }),
);
assert_eq!(result["isError"], true);
assert_eq!(whole_store_bytes(&store), before);
}
#[test]
fn t033_reason_alone_is_not_an_update() {
// `reason` qualifies a transition; it is not an editable field. A call carrying
// only a reason must be "no fields to update", not a silent success that
// discards it.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
let result = invoke(
&config,
&store,
"update_issue",
json!({ "issue": "1", "reason": "because" }),
);
assert_eq!(result["isError"], true);
assert!(
result["content"][0]["text"]
.as_str()
.unwrap()
.contains("no fields to update"),
"got: {}",
result["content"][0]["text"]
);
}
#[test]
fn t033_reads_carry_the_category_key_and_label() {
// T030 step 4 / FR-004. The **key** as well as the label: a client filtering or
// grouping needs the stable name.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "triage");
for tool in ["get_issue", "list_issues"] {
// `list_issues` needs `all`: its default scope is *your* issues in the
// board's entry column (#1711), and this fixture's file carries no
// assignee. The category key is what this test is about.
let args = if tool == "get_issue" {
json!({ "issue": "1" })
} else {
json!({ "all": true })
};
let result = invoke(&config, &store, tool, args);
assert_eq!(
result["isError"], false,
"{tool}: {}",
result["content"][0]["text"]
);
// `structuredContent` carries the object; `content[0].text` is the issue file
// verbatim for `get_issue` and a human sentence for `list_issues`, so reading
// the text as JSON fails. An earlier draft of this test did exactly that and
// reported `category: Null` for a category that was present.
let structured = &result["structuredContent"];
let issue = if tool == "get_issue" {
structured.clone()
} else {
structured["issues"][0].clone()
};
assert_eq!(
issue["category"], "backlog",
"{tool} must carry the category key"
);
assert_eq!(
issue["category_label"], "Backlog",
"{tool} must carry the service's label"
);
}
}
#[test]
fn t033_an_unmapped_column_reads_as_null_not_a_default() {
// A configuration error the client can *see*, not a silent default. `tix list
// --show category` spells the same case `(unmapped)`.
let (_tmp, config, store) = mapped_repo();
write_issue(&store, 1, "Alpha", "not-a-column");
let result = invoke(&config, &store, "get_issue", json!({ "issue": "1" }));
let issue = result["structuredContent"].clone();
assert_eq!(issue["category"], Value::Null);
assert_eq!(issue["category_label"], "(unmapped)");
}
#[test]
fn t033_the_writes_carry_the_category_too() {
// The category rides on the shared `issue_value`, so every write result has it as
// well as every read. Asserted on the result, so a future handler that stops
// using the shared value is caught here rather than by a client noticing a
// missing field.
let (_tmp, config, store) = mapped_repo();
let created = invoke(
&config,
&store,
"create_issue",
json!({ "title": "Beta", "status": "wip" }),
);
let value = created["structuredContent"].clone();
assert_eq!(value["category"], "active", "created: {value}");
assert_eq!(value["category_label"], "In progress");
}
// -----------------------------------------------------------------------
// WP08 / T038–T042 — the triage tools
// -----------------------------------------------------------------------
/// The `[triage]` section, on the same board [`mapped_repo`] declares.
const TRIAGE: &str = "\n[triage]\nentry_status = \"triage\"\ndeclined_status = \"abandoned\"\n";
/// A repo with an inbox: the board `mapped_repo` declares, plus `[triage]`.
fn triage_repo() -> (TempDir, Config, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let text = format!(
"issues_dir = \"{}\"\n\n[kanban.default]\ncolumns = [\"triage\", \"wip\", \
\"review\", \"shipped\", \"abandoned\"]\ndone_column = \"shipped\"\n\
category_map = {{ triage = \"backlog\", wip = \"active\", review = \"active\", \
shipped = \"completed\", abandoned = \"canceled\" }}\n{TRIAGE}\n\
[layout]\nmove_on_change = false\n",
dir.display()
);
fs::write(&config_path, text).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config: config.clone(),
};
(tmp, config, store)
}
/// One issue in the queue, written the way `tix` writes one.
fn write_triage_issue(store: &Store, number: u32, title: &str, entered_at: Option<&str>) {
let mut text = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: triage\nlabels: []\n\
priority: medium\ncreated: 2026-01-01\nupdated: 2026-01-01\n"
);
if let Some(at) = entered_at {
text.push_str(&format!("entered_at: {at}\nsource: test\n"));
}
text.push_str("---\n\nBody.");
fs::write(
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md")),
text,
)
.unwrap();
}
fn triage_issue_path(store: &Store, number: u32) -> std::path::PathBuf {
store
.issues_dir
.join(format!("2026-01-01-issue-{number}.md"))
}
#[test]
fn t038_the_triage_schemas_are_strict_and_only_the_apply_requires_confirm() {
let by_name = |name: &str| -> &ToolDescriptor {
registry()
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` is not registered"))
};
for name in [
"triage_list",
"triage_status",
"triage_preview",
"triage_apply",
] {
let tool = by_name(name);
assert_eq!(tool.input_schema["additionalProperties"], json!(false));
assert_eq!(tool.input_schema["type"], json!("object"));
}
assert_eq!(
by_name("triage_apply").input_schema["required"],
json!(["decision", "confirm"]),
"a write tool whose confirmation is optional is a write tool clients call by accident"
);
assert_eq!(
by_name("triage_preview").input_schema["required"],
json!(["decision"])
);
assert!(
by_name("triage_preview").input_schema["properties"]
.get("confirm")
.is_none(),
"the preview does not even advertise a confirmation"
);
assert_eq!(
by_name("triage_list").input_schema["properties"]["age"]["type"],
json!("boolean")
);
}
#[test]
fn t039_a_triage_preview_changes_nothing_and_names_every_item() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
write_triage_issue(&store, 2, "Bravo", None);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"triage_preview",
json!({"select": "1,2", "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["applied"], json!(false));
assert_eq!(
sc(&out)["items"].as_array().map(Vec::len),
Some(2),
"one entry per selected issue"
);
assert_eq!(
sc(&out)["items"][0]["history"][0]["kind"],
json!("triage_declined")
);
assert_eq!(
before,
whole_store_bytes(&store),
"a preview is read-only: no status, no History, no file"
);
}
#[test]
fn t039_a_missing_reason_is_a_refused_item_naming_the_contracts_kind() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let out = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "decline", "actor": "tester"}),
);
assert!(!err(&out), "a refusal per item is a plan, not an error");
let item = sc(&out)["items"][0].clone();
assert_eq!(item["status"], "rejected");
assert!(
item["diagnostic"]["message"]
.as_str()
.unwrap_or_default()
.contains("missing_reason"),
"{item}"
);
}
#[test]
fn t039_a_triage_apply_needs_the_confirmation_and_then_writes_one_event() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
let refused = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "decline", "reason": "no budget", "actor": "tester"}),
);
assert!(err(&refused), "an apply without `confirm` is refused");
assert!(
text(&refused).contains("confirm is required"),
"{}",
text(&refused)
);
assert_eq!(before, whole_store_bytes(&store), "and writes nothing");
let applied = invoke(
&config,
&store,
"triage_apply",
json!({
"issue": 1, "decision": "decline", "reason": "no budget",
"actor": "tester", "confirm": true,
}),
);
assert!(!err(&applied), "{}", text(&applied));
assert_eq!(sc(&applied)["applied"], json!(1));
let written = fs::read_to_string(triage_issue_path(&store, 1)).unwrap();
assert!(
written.lines().any(|l| l.trim() == "status: abandoned"),
"the configured declined column, not a column this handler chose:\n{written}"
);
assert_eq!(
written.matches("triage_declined").count(),
1,
"exactly one History event:\n{written}"
);
}
#[test]
fn t040_every_triage_tool_is_dispatched_with_a_minimal_call() {
// The sweep above proves every *declared* name dispatches; this proves the
// four new ones do something useful with the smallest legal request, which is
// the state a client is in the first time it calls them.
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
for (name, args) in [
("triage_list", json!({})),
("triage_status", json!({})),
(
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
),
] {
let out = invoke(&config, &store, name, args);
assert!(!err(&out), "`{name}`: {}", text(&out));
}
let applied = invoke(
&config,
&store,
"triage_apply",
json!({"issue": 1, "decision": "defer", "reason": "later", "confirm": true}),
);
assert!(!err(&applied), "triage_apply: {}", text(&applied));
}
#[test]
fn t039_a_target_the_decision_cannot_use_is_refused_before_the_plan() {
let (_tmp, config, store) = triage_repo();
write_triage_issue(&store, 1, "Alpha", Some("2020-01-01T00:00:00Z"));
let before = whole_store_bytes(&store);
for (decision, args) in [
("decline", json!({"to": "abandoned"})),
("defer", json!({"to": "abandoned"})),
("accept", json!({"of": "1"})),
] {
let mut request = json!({
"issue": 1, "decision": decision, "reason": "x", "actor": "tester",
"confirm": true,
});
if let Some(object) = args.as_object() {
for (key, value) in object {
request[key] = value.clone();
}
}
let out = invoke(&config, &store, "triage_apply", request);
assert!(
err(&out),
"`{decision}` with {args} must be refused, not silently ignored: {}",
text(&out)
);
}
assert_eq!(
before,
whole_store_bytes(&store),
"and a refused request writes nothing"
);
}
#[test]
fn t041_an_invalid_section_reports_the_validators_message_not_the_missing_one() {
// `inbox::scope` has two failure modes and they are different answers to
// different problems: "no `[triage]` section" (`Ok(None)`, the case above) and
// "a section the board cannot satisfy" (`Err`, a config error the loader would
// normally have caught). A caller can hand the store a config the loader would
// have refused — `import_with_adapter`'s sibling situation in WP07 — so the MCP
// must report the validator's own message rather than flatten both into the
// missing-section sentence.
let (_tmp, config, store) = triage_repo();
let mut invalid = config.clone();
invalid
.triage
.as_mut()
.expect("the fixture configures a section")
.entry_status = Some("no-such-column".to_string());
let store = Store {
issues_dir: store.issues_dir.clone(),
config: invalid.clone(),
};
let cli_err = cmd_triage::render_list(&store, false, false)
.expect_err("a column the board lacks is refused");
let cli_error = crate::error::render_error(&cli_err);
assert!(
cli_error.contains("no-such-column"),
"the validator names the offending column: {cli_error}"
);
assert!(
!cli_error.ends_with(NO_INBOX),
"a different problem, a different sentence"
);
let out = invoke(&invalid, &store, "triage_list", json!({}));
assert!(err(&out), "`triage_list` refuses an invalid section");
assert_eq!(
text(&out),
cli_error,
"the validator's message, not the missing-section one"
);
}
#[test]
fn t041_the_no_inbox_refusal_is_the_clis_own_sentence() {
// The MCP module carries its own copy of the refusal (the CLI's `scope_of` is
// private to `commands::triage`), so this pins the copy to its source: a
// project with no `[triage]` section must produce the CLI's sentence on both
// surfaces, and this is the assertion that notices if one of them is reworded.
let (_tmp, config, store) = make_repo();
let cli_error = cmd_triage::render_list(&store, false, false)
.expect_err("a project with no `[triage]` section has no inbox")
.to_string();
assert!(
cli_error.ends_with(NO_INBOX),
"the CLI says `{cli_error}`, and the MCP constant agrees only if it ends with it"
);
for name in ["triage_list", "triage_status"] {
let out = invoke(&config, &store, name, json!({}));
assert!(err(&out), "`{name}` must refuse");
assert_eq!(
text(&out),
cli_error,
"`{name}` carries the CLI's sentence verbatim, not a paraphrase"
);
}
let decision = invoke(
&config,
&store,
"triage_preview",
json!({"issue": 1, "decision": "defer", "reason": "later"}),
);
assert!(
err(&decision),
"and so does a decision through an inbox that does not exist"
);
}
// -----------------------------------------------------------------------
// WP07 / T031–T034: the project tools
// -----------------------------------------------------------------------
/// A repo whose **persisted** registry declares `(id, name)`, reloaded from
/// disk.
///
/// Written to `tix.toml`, not merely held in memory, because the project
/// handlers re-read the registry per call (a project this session created must
/// be visible to the next read) — exactly as the CLI reads it from the file.
fn project_repo(projects: &[(&str, &str)]) -> (TempDir, Store) {
let (tmp, _config, store) = make_repo();
let mut config = store.config.clone();
for (id, name) in projects {
config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..Default::default()
},
);
}
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
(tmp, store)
}
/// An issue file with optional project associations, assignee and due date.
///
/// The shape is the one `tests/fixtures/projects/*.md` uses, so a file this
/// helper writes is a file the model loads without a migration.
fn write_project_issue(
store: &Store,
number: u32,
title: &str,
status: &str,
projects: &[&str],
assignee: Option<&str>,
due: Option<&str>,
) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !projects.is_empty() {
frontmatter.push_str("projects:\n");
for id in projects {
frontmatter.push_str(&format!("- {id}\n"));
}
}
if let Some(assignee) = assignee {
frontmatter.push_str(&format!("assignee: \"{assignee}\"\n"));
}
if let Some(due) = due {
frontmatter.push_str(&format!("due_date: {due}\n"));
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
/// An **archived** issue file associated with `projects`.
///
/// Archived is derived from the leading dot of the filename
/// (`Store::collect_issue_files`), and `Store::list` never reads one while
/// `Store::list_all` does — which is the snapshot difference the project reads
/// have to mirror.
fn write_archived_project_issue(store: &Store, number: u32, title: &str, projects: &[&str]) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !projects.is_empty() {
frontmatter.push_str("projects:\n");
for id in projects {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store
.issues_dir
.join(format!("alice/.2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
/// The `project_associated` events on one issue, in file order.
fn association_events(store: &Store, number: u32) -> Vec<crate::model::HistoryEventKind> {
store
.resolve_issue(&number.to_string())
.unwrap()
.history
.events()
.iter()
.map(|event| event.kind)
.filter(|kind| *kind == crate::model::HistoryEventKind::ProjectAssociated)
.collect()
}
#[test]
fn t031_create_issue_associates_a_multi_value_project_set() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Associated", "projects": "p-a,p-b"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a", "p-b"]));
// A name resolves to the same project, and a duplicate collapses to one
// association — the CLI's `resolve_projects` semantics.
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Named", "projects": "Platform,p-a"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
// Each association is one History entry, on the created issue's file.
let number = sc(&out)["number"].as_u64().unwrap() as u32;
assert_eq!(association_events(&store, number).len(), 1);
}
#[test]
fn t031_an_unknown_project_refuses_the_whole_create_and_writes_nothing() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Half", "projects": "p-a,bogus"}),
);
assert!(err(&out), "an unknown reference refuses the create");
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refused create writes nothing");
}
#[test]
fn t031_projects_beside_a_template_are_refused_with_the_clis_words() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"template": "t-1", "projects": "p-a"}),
);
assert!(err(&out));
assert!(
text(&out).contains("--projects cannot be combined with --template yet"),
"{}",
text(&out)
);
assert_eq!(snap(&store), before, "refused before anything is written");
}
#[test]
fn t031_update_issue_adds_removes_and_clears_projects() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["p-a"]}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
// Idempotent: the second set changes nothing and says so.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["p-a"]}),
);
assert!(!err(&out));
assert!(
text(&out).contains("(no association change)"),
"{}",
text(&out)
);
assert_eq!(sc(&out)["projects"], json!(["p-a"]));
assert_eq!(
association_events(&store, 1).len(),
1,
"a no-op association appends no second History entry"
);
// Each entry is one occurrence: a name resolves to the same id, and the
// already-present id in the same call is that occurrence's no-op.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": ["Docs", "p-b", "p-a"]}),
);
assert_eq!(sc(&out)["projects"], json!(["p-a", "p-b"]));
// Remove drops only the association; the issue survives.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "remove_project": ["p-a"]}),
);
assert_eq!(sc(&out)["projects"], json!(["p-b"]));
assert!(store.resolve_issue("1").is_ok());
// A blank `projects` clears every association.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "projects": ""}),
);
assert!(!err(&out), "{}", text(&out));
assert!(sc(&out)["projects"].is_null(), "{}", text(&out));
assert!(
store.resolve_issue("1").is_ok(),
"the issue is never deleted"
);
}
#[test]
fn t031_a_project_entry_is_taken_verbatim_like_a_cli_occurrence() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
// A padded or blank entry is a reference that does not resolve, exactly as
// `--add-project " p-a "` / `--add-project ""` on the CLI: trimming or
// dropping it here would accept a call the CLI refuses.
for entry in [" p-a ", "p-a ", ""] {
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": [entry]}),
);
assert!(err(&out), "`{entry}` must be refused");
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refusal writes nothing");
}
// An empty array is "no occurrences", so it is not a change.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": []}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
}
#[test]
fn t031_a_non_blank_projects_value_is_refused_like_the_cli() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "projects": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("only clears"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t031_an_unknown_add_project_refuses_the_whole_update_byte_identically() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
// The field edit must **not** be applied either: the association inputs are
// validated before the write, exactly as `edit::run` orders them.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "title": "Renamed", "add_project": ["bogus"]}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(
snap(&store),
before,
"a refused update leaves every file byte-identical"
);
}
#[test]
fn t031_update_issue_reports_no_fields_to_update_without_project_args() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(&store.config, &store, "update_issue", json!({"issue": 1}));
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
// An empty project array is not a change either.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_project": []}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to update"), "{}", text(&out));
// And a string where the schema says array is a protocol error, not a
// silently-accepted CSV list.
let protocol_error = call(
&store.config,
&store,
super::protocol::Id::Number(1),
"update_issue",
&json!({"issue": 1, "add_project": "p-a"}),
);
assert!(
protocol_error.is_err(),
"`add_project` is an array; a string must be a -32602, not a CSV list"
);
}
#[test]
fn t032_create_project_returns_the_allocated_id_and_refuses_a_duplicate_name() {
let (_tmp, store) = project_repo(&[]);
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "Platform", "status": "active"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["id"].as_str().unwrap().to_string();
assert!(
crate::config::ProjectId::new(&id).is_ok(),
"the registry allocated `{id}`, which must be a valid stable id"
);
assert_eq!(sc(&out)["name"], "Platform");
assert_eq!(sc(&out)["status"], "active");
assert_eq!(sc(&out)["member_count"], 0);
// Persisted, and visible to the next call (the registry is re-read).
let listed = invoke(&store.config, &store, "list_projects", json!({}));
assert_eq!(sc(&listed)["projects"][0]["id"], id);
// The name is unique, case-folded; the refusal names the kind.
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "platform"}),
);
assert!(err(&out));
assert!(text(&out).contains("duplicate_name"), "{}", text(&out));
// An inverted date pair is refused by the shared date rule.
let out = invoke(
&store.config,
&store,
"create_project",
json!({"name": "Dated", "start_date": "2026-02-01", "due_date": "2026-01-01"}),
);
assert!(err(&out));
assert!(text(&out).contains("invalid_dates"), "{}", text(&out));
}
#[test]
fn t032_update_project_renames_the_label_and_keeps_the_id() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
let out = invoke(
&store.config,
&store,
"update_project",
json!({"project": "p-a", "name": "Platform hardening"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "p-a", "the id survives a rename");
assert_eq!(sc(&out)["name"], "Platform hardening");
// The members are re-derived on read, so a registry-only edit cannot report
// a member count of zero.
assert_eq!(sc(&out)["member_count"], 1);
// The association is intact and still resolves through the new name.
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "Platform hardening"}),
);
assert!(!err(&membership), "{}", text(&membership));
assert_eq!(sc(&membership)["member_count"], 1);
// No fields at all is a refusal, not a silent success.
let out = invoke(
&store.config,
&store,
"update_project",
json!({"project": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("no fields to change"), "{}", text(&out));
}
#[test]
fn t032_list_projects_returns_ids_and_derived_member_counts() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "One", "todo", &["p-a", "p-b"], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-a"], None, None);
let out = invoke(&store.config, &store, "list_projects", json!({}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["projects"][0]["id"], "p-a");
assert_eq!(sc(&out)["projects"][0]["member_count"], 2);
assert_eq!(sc(&out)["projects"][1]["id"], "p-b");
assert_eq!(sc(&out)["projects"][1]["member_count"], 1);
// The text mirror is the CLI's `id<TAB>name<TAB>member count` row set.
assert!(text(&out).contains("p-a\tPlatform\t2"), "{}", text(&out));
}
#[test]
fn t032_the_project_reads_use_the_snapshot_their_cli_read_uses() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
// Written out of order, so the ordering is the service's, not the read's.
write_project_issue(&store, 7, "Seven", "todo", &["p-a"], None, None);
write_project_issue(&store, 3, "Three", "todo", &["p-a"], None, None);
// An **archived** member. The two snapshots are the CLI's own:
// `tix project list` / `tix project show` enumerate every issue file
// (`Store::list_all`), while `tix show`'s project block and
// `tix list --show progress` read the active set (`Store::list`).
write_archived_project_issue(&store, 5, "Archived", &["p-a"]);
// `get_project` mirrors `tix project show`: every issue file.
let shown = invoke(
&store.config,
&store,
"get_project",
json!({"project": "Platform"}),
);
assert!(!err(&shown), "{}", text(&shown));
assert_eq!(sc(&shown)["id"], "p-a");
assert_eq!(sc(&shown)["member_count"], 3);
assert_eq!(sc(&shown)["members"][0]["issue"], 3);
assert_eq!(sc(&shown)["members"][1]["issue"], 5);
assert_eq!(sc(&shown)["members"][2]["issue"], 7);
// `project_membership`, `project_health` and the `list_issues` filter
// mirror the figure-bearing reads: the active set, so the archived member
// is not counted by any of them (and the filter and the membership read
// cannot disagree).
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert!(!err(&membership), "{}", text(&membership));
assert_eq!(sc(&membership)["project_id"], "p-a");
assert_eq!(sc(&membership)["member_count"], 2);
assert_eq!(sc(&membership)["members"][0]["issue"], 3);
assert_eq!(sc(&membership)["members"][1]["issue"], 7);
assert_eq!(sc(&membership)["members"][0], sc(&shown)["members"][0]);
assert_eq!(sc(&membership)["members"][1], sc(&shown)["members"][2]);
// Deterministic: a repeated read is identical.
let again = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert_eq!(sc(&again), sc(&membership));
let health = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&health), "{}", text(&health));
assert_eq!(sc(&health)["total"], 2, "the active set, not every file");
let filtered = invoke(
&store.config,
&store,
"list_issues",
// `all`, because the listing's default scope is *your* issues in the
// board's entry column (#1711) and this fixture's files carry no
// assignee — a fact about the fixture, not about the project filter
// under test.
json!({"project": "p-a", "all": true}),
);
assert!(!err(&filtered), "{}", text(&filtered));
let mut numbers: Vec<u64> = sc(&filtered)["issues"]
.as_array()
.unwrap()
.iter()
.map(|issue| issue["number"].as_u64().unwrap())
.collect();
numbers.sort_unstable();
assert_eq!(
numbers,
vec![3, 7],
"the filter matches the membership read"
);
}
#[test]
fn t032_project_health_returns_the_raw_counters() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
// One done member, one overdue + unassigned, one assigned and in progress.
write_project_issue(&store, 1, "Done", "done", &["p-a"], Some("alice"), None);
write_project_issue(
&store,
2,
"Late",
"todo",
&["p-a"],
None,
Some("2020-01-01"),
);
write_project_issue(&store, 3, "Assigned", "todo", &["p-a"], Some("bob"), None);
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "Platform"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["project_id"], "p-a");
assert_eq!(sc(&out)["done"], 1);
assert_eq!(sc(&out)["total"], 3);
assert_eq!(sc(&out)["overdue"], 1);
assert_eq!(sc(&out)["blocked"], 0);
assert_eq!(sc(&out)["unassigned"], 1);
assert_eq!(sc(&out)["missing_references"], json!([]));
}
#[test]
fn t032_project_health_equals_the_shared_read_path_by_value() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "Done", "done", &["p-a"], Some("alice"), None);
write_project_issue(
&store,
2,
"Late",
"todo",
&["p-a"],
None,
Some("2020-01-01"),
);
// The shared read path: `commands::view::project_reports` is what `tix show`,
// `tix list --show progress` and the TUI read, so this compares MCP's value
// with **the** value rather than with a second computation (C-006, NFR-003).
let issues = store.list_all().unwrap();
let (_index, reports) = crate::commands::view::project_reports(&store, &issues).unwrap();
let expected = reports
.get(&crate::config::ProjectId::new("p-a").unwrap())
.expect("the fixture registers `p-a`");
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["done"], expected.progress.done);
assert_eq!(sc(&out)["total"], expected.progress.total);
assert_eq!(sc(&out)["overdue"], expected.health.overdue);
assert_eq!(sc(&out)["blocked"], expected.health.blocked);
assert_eq!(sc(&out)["unassigned"], expected.health.unassigned);
assert_eq!(
sc(&out)["missing_references"].as_array().unwrap().len(),
expected.health.missing_references.len()
);
// The text mirror is the shared renderer, not a second wording of the
// same figures.
assert_eq!(
text(&out),
format!(
"{} {}/{}{}",
crate::projects::project_display(&expected.project_id, &expected.name),
expected.progress.done,
expected.progress.total,
crate::commands::view::health_suffix(expected),
)
);
}
#[test]
fn t032_project_health_reports_a_members_missing_reference() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(
&store,
1,
"Dangling",
"todo",
&["p-a", "p-gone"],
Some("alice"),
None,
);
let out = invoke(
&store.config,
&store,
"project_health",
json!({"project": "p-a"}),
);
assert!(!err(&out), "{}", text(&out));
let missing = sc(&out)["missing_references"].as_array().unwrap().clone();
assert_eq!(missing.len(), 1, "{missing:?}");
assert_eq!(missing[0]["issue"], 1);
assert_eq!(missing[0]["project_id"], "p-gone");
assert!(
missing[0]["reason"]
.as_str()
.unwrap()
.contains("not in the registry"),
"the service's own reason: {}",
missing[0]["reason"]
);
// The same finding `validate_references` reports, by value (C-005).
let issues = store.list_all().unwrap();
let service = crate::projects::validate_references(&store.config, &issues);
assert_eq!(service.len(), 1);
assert_eq!(service[0].project_id, "p-gone");
assert_eq!(service[0].reason, missing[0]["reason"]);
}
#[test]
fn t032_delete_project_reports_the_affected_issues_and_never_deletes_one() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-b"], None, None);
let out = invoke(
&store.config,
&store,
"delete_project",
json!({"project": "Platform", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "p-a");
assert_eq!(sc(&out)["name"], "Platform");
let affected = sc(&out)["affected"].as_array().unwrap();
assert_eq!(affected.len(), 1);
assert_eq!(affected[0]["issue"], 1);
assert!(affected[0]["display"].as_str().unwrap().starts_with("#1 "));
// The issue survives, keeps its reference, and it now reads as dangling.
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, Some(vec!["p-a".to_string()]));
let listed = invoke(&store.config, &store, "list_projects", json!({}));
let ids: Vec<&str> = sc(&listed)["projects"]
.as_array()
.unwrap()
.iter()
.filter_map(|project| project["id"].as_str())
.collect();
assert_eq!(ids, vec!["p-b"], "only the registry entry was removed");
}
#[test]
fn t032_delete_project_without_confirm_is_refused_and_removes_nothing() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let out = invoke(
&store.config,
&store,
"delete_project",
json!({"project": "p-a"}),
);
assert!(err(&out));
assert!(text(&out).contains("confirm"), "{}", text(&out));
let listed = invoke(&store.config, &store, "list_projects", json!({}));
assert_eq!(sc(&listed)["projects"].as_array().unwrap().len(), 1);
}
#[test]
fn t032_the_project_read_tools_are_read_only() {
let (tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a"], None, None);
let files_before = snap(&store);
let registry_before = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
for (name, args) in [
("list_projects", json!({})),
("get_project", json!({"project": "p-a"})),
("project_membership", json!({"project": "p-a"})),
("project_health", json!({"project": "p-a"})),
] {
let out = invoke(&store.config, &store, name, args);
assert!(!err(&out), "{name}: {}", text(&out));
}
assert_eq!(
snap(&store),
files_before,
"a read tool writes no issue file"
);
assert_eq!(
fs::read_to_string(tmp.path().join("tix.toml")).unwrap(),
registry_before,
"and does not rewrite the registry"
);
// No History entry either: the read tools append nothing.
assert_eq!(
association_events(&store, 1),
[] as [crate::model::HistoryEventKind; 0]
);
}
#[test]
fn t032_associate_issues_reports_each_selector_independently() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &[], None, None);
write_project_issue(&store, 2, "Two", "todo", &["p-a"], None, None);
let out = invoke(
&store.config,
&store,
"project_associate_issues",
json!({"project": "Platform", "issues": "1,2,99"}),
);
assert!(!err(&out), "{}", text(&out));
let outcomes = sc(&out)["outcomes"].as_array().unwrap();
assert_eq!(
outcomes.len(),
3,
"one outcome per selector, in input order"
);
assert_eq!(outcomes[0]["selector"], "1");
assert_eq!(outcomes[0]["changed"], true);
assert_eq!(
outcomes[1]["changed"], false,
"already associated is a no-op"
);
assert!(
outcomes[2]["error"].as_str().is_some(),
"an unresolvable selector is that entry's error, not the run's"
);
assert_eq!(sc(&out)["changed"], 1);
assert_eq!(sc(&out)["failed"], 1);
assert_eq!(
store.resolve_issue("1").unwrap().projects,
Some(vec!["p-a".to_string()]),
"the accepted selector inside a partly-failing batch was still written"
);
// An unknown **project** refuses the whole call before any write.
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"project_associate_issues",
json!({"project": "bogus", "issues": "1"}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t032_dissociate_issues_uses_a_live_id_or_the_verbatim_reference() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
write_project_issue(&store, 1, "One", "todo", &["p-a", "p-stale"], None, None);
// A live name resolves to its id, exactly as `tix project remove` does.
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "Platform", "issues": "1"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(
store.resolve_issue("1").unwrap().projects,
Some(vec!["p-stale".to_string()])
);
// A reference that resolves to nothing is used verbatim — the repair path.
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "p-stale", "issues": "1"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(store.resolve_issue("1").unwrap().projects, None);
// An absent association is an idempotent no-op.
let out = invoke(
&store.config,
&store,
"project_dissociate_issues",
json!({"project": "p-stale", "issues": "1"}),
);
assert!(!err(&out));
assert_eq!(sc(&out)["changed"], 0);
assert!(
store.resolve_issue("1").is_ok(),
"no issue is ever deleted (C-004)"
);
}
#[test]
fn t034_list_issues_filters_by_the_services_membership_set() {
let (_tmp, store) = project_repo(&[("p-a", "Platform"), ("p-b", "Docs")]);
write_project_issue(&store, 1, "In", "todo", &["p-a"], None, None);
write_project_issue(&store, 2, "Out", "todo", &["p-b"], None, None);
let out = invoke(
&store.config,
&store,
"list_issues",
// `all` for the same reason as `t032`: the fixture's issues carry no
// assignee, and the default scope is *your* issues (#1711).
json!({"project": "Platform", "all": true}),
);
assert!(!err(&out), "{}", text(&out));
let issues = sc(&out)["issues"].as_array().unwrap();
assert_eq!(issues.len(), 1);
assert_eq!(issues[0]["number"], 1);
// The matching set is exactly what `project_membership` reports.
let membership = invoke(
&store.config,
&store,
"project_membership",
json!({"project": "p-a"}),
);
assert_eq!(sc(&membership)["member_count"], 1);
assert_eq!(sc(&membership)["members"][0]["issue"], 1);
// An unknown project is refused with the service's own diagnostic.
let out = invoke(
&store.config,
&store,
"list_issues",
json!({"project": "bogus"}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_project"), "{}", text(&out));
}
#[test]
fn t033_every_project_tool_is_declared_and_dispatched() {
let (_tmp, store) = project_repo(&[("p-a", "Platform")]);
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"list_projects",
"get_project",
"create_project",
"update_project",
"delete_project",
"project_membership",
"project_health",
"project_associate_issues",
"project_dissociate_issues",
] {
assert!(
declared.contains(&name),
"`{name}` must be declared in the registry: {declared:?}"
);
// The converse of the sweep above: a tool that is dispatched but not
// declared is invisible to it, and this is where the project surface
// gets both halves checked.
assert!(
dispatch(&store.config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
// -----------------------------------------------------------------------
// WP07 / T033–T034: the epic tools
// -----------------------------------------------------------------------
/// A repo whose **persisted** registry declares `(id, name, weight, parent)`
/// epics, reloaded from disk.
///
/// Written to `tix.toml`, not merely held in memory, because the epic handlers
/// re-read the registry per call (an epic this session created must be visible
/// to the next read) — exactly as the CLI reads it from the file.
fn epic_repo(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
let (tmp, _config, store) = make_repo();
let mut config = store.config.clone();
for (id, name, weight, parent) in epics {
config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
weight: *weight,
parent: parent.map(|p| p.to_string()),
..Default::default()
},
);
}
let path = tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
(tmp, store)
}
/// An issue file with optional epic references.
fn write_epic_issue(store: &Store, number: u32, title: &str, status: &str, epics: &[&str]) {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n"
);
if !epics.is_empty() {
frontmatter.push_str("epics:\n");
for id in epics {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n\nbody\n");
let slug = crate::slug::slugify(title);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
frontmatter,
)
.unwrap();
}
/// The epic-reference events on one issue, in file order.
fn epic_events(store: &Store, number: u32) -> Vec<crate::model::HistoryEventKind> {
store
.resolve_issue(&number.to_string())
.unwrap()
.history
.events()
.iter()
.map(|event| event.kind)
.filter(|kind| {
matches!(
kind,
crate::model::HistoryEventKind::EpicReferenced
| crate::model::HistoryEventKind::EpicDereferenced
)
})
.collect()
}
#[test]
fn t033_create_issue_references_a_multi_value_epic_set() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Referenced", "epics": "e-aaa111,e-bbb222"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111", "e-bbb222"]));
// A name resolves to the same epic, and a duplicate collapses.
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Named", "epics": "Alpha,e-aaa111"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
// Each reference is one History entry, on the created issue's file.
let number = sc(&out)["number"].as_u64().unwrap() as u32;
assert_eq!(epic_events(&store, number).len(), 1);
}
#[test]
fn t033_an_unknown_epic_refuses_the_whole_create_and_writes_nothing() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Half", "epics": "e-aaa111,bogus"}),
);
assert!(err(&out), "an unknown reference refuses the create");
assert!(text(&out).contains("unknown_epic"), "{}", text(&out));
assert_eq!(snap(&store), before, "a refused create writes nothing");
}
#[test]
fn t033_update_issue_adds_removes_and_clears_epics() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
write_issue(&store, 1, "Work", "todo");
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["e-aaa111"]}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
// Idempotent: the second set changes nothing and appends no History entry.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["e-aaa111"]}),
);
assert!(!err(&out));
assert!(
text(&out).contains("(no association change)"),
"{}",
text(&out)
);
assert_eq!(epic_events(&store, 1).len(), 1);
// Each entry is one occurrence: a name resolves to the same id.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "add_epic": ["Beta", "e-bbb222", "e-aaa111"]}),
);
assert_eq!(sc(&out)["epics"], json!(["e-aaa111", "e-bbb222"]));
// Remove drops only the reference; the issue survives.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "remove_epic": ["e-aaa111"]}),
);
assert_eq!(sc(&out)["epics"], json!(["e-bbb222"]));
assert!(store.resolve_issue("1").is_ok());
// A blank `epics` clears every reference.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "epics": ""}),
);
assert!(!err(&out), "{}", text(&out));
assert!(sc(&out)["epics"].is_null(), "{}", text(&out));
assert!(
store.resolve_issue("1").is_ok(),
"the issue is never deleted"
);
}
#[test]
fn t033_an_unknown_add_epic_refuses_the_whole_update_byte_identically() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
// The field edit must **not** be applied either: the reference inputs are
// validated before the write, exactly as `edit::run` orders them.
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "title": "Renamed", "add_epic": ["bogus"]}),
);
assert!(err(&out));
assert!(text(&out).contains("unknown_epic"), "{}", text(&out));
assert_eq!(
snap(&store),
before,
"a refused update leaves every file byte-identical"
);
}
#[test]
fn t033_a_non_blank_epics_value_is_refused_like_the_cli() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_issue(&store, 1, "Work", "todo");
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"update_issue",
json!({"issue": 1, "epics": "e-bbb222"}),
);
assert!(err(&out));
assert!(text(&out).contains("only clears"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t033_epics_beside_a_template_are_refused_with_the_clis_words() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"template": "t-1", "epics": "e-aaa111"}),
);
assert!(err(&out));
assert!(
text(&out).contains("--epics cannot be combined with --template yet"),
"{}",
text(&out)
);
assert_eq!(snap(&store), before, "refused before anything is written");
}
#[test]
fn t034_create_epic_returns_the_allocated_id() {
let (_tmp, store) = epic_repo(&[]);
let out = invoke(
&store.config,
&store,
"create_epic",
json!({"name": "Platform", "weight": 3, "projects": "p-1"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["id"].as_str().unwrap().to_string();
assert!(id.starts_with("e-"), "the registry allocated `{id}`");
assert!(text(&out).contains("Created"));
// It is persisted and resolves by id and by name, with the raw fields.
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert_eq!(sc(&listed)["epics"][0]["id"], json!(id));
assert_eq!(sc(&listed)["epics"][0]["weight"], 3);
let shown = invoke(
&store.config,
&store,
"get_epic",
json!({"epic": "Platform"}),
);
assert!(!err(&shown), "{}", text(&shown));
assert_eq!(sc(&shown)["id"], json!(id));
assert!(text(&shown).contains("name: Platform"), "{}", text(&shown));
}
#[test]
fn t034_list_epics_returns_ids_child_and_member_counts() {
let (_tmp, store) = epic_repo(&[
("e-root01", "Root", None, None),
("e-mid002", "Mid", None, Some("e-root01")),
]);
write_epic_issue(&store, 1, "One", "todo", &["e-root01"]);
write_epic_issue(&store, 2, "Two", "todo", &["e-root01", "e-mid002"]);
let out = invoke(&store.config, &store, "list_epics", json!({}));
assert!(!err(&out), "{}", text(&out));
let epics = sc(&out)["epics"].as_array().unwrap();
assert_eq!(epics.len(), 2);
// Declaration order, id always present.
assert_eq!(epics[0]["id"], "e-root01");
assert_eq!(epics[0]["member_count"], 2);
assert_eq!(epics[0]["child_count"], 1);
assert_eq!(epics[1]["id"], "e-mid002");
assert_eq!(epics[1]["member_count"], 1);
assert_eq!(epics[1]["child_count"], 0);
// The text is the CLI's tab-separated rows.
let listing = text(&out);
let rows: Vec<&str> = listing.lines().collect();
assert_eq!(rows[0], "e-root01\tRoot\t2\t1");
}
#[test]
fn t034_epic_hierarchy_navigates_a_three_deep_chain() {
let (_tmp, store) = epic_repo(&[
("e-root01", "Root", None, None),
("e-mid002", "Mid", None, Some("e-root01")),
("e-leaf03", "Leaf", None, Some("e-mid002")),
]);
let out = invoke(
&store.config,
&store,
"epic_hierarchy",
json!({"epic": "e-leaf03"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic"], "e-leaf03");
assert_eq!(sc(&out)["parent"]["id"], "e-mid002");
assert_eq!(sc(&out)["depth"], 2);
let ancestors: Vec<&str> = sc(&out)["ancestors"]
.as_array()
.unwrap()
.iter()
.map(|a| a["id"].as_str().unwrap())
.collect();
assert_eq!(ancestors, vec!["e-root01", "e-mid002"], "root-first");
assert_eq!(sc(&out)["children"].as_array().unwrap().len(), 0);
assert!(sc(&out)["unresolved"].is_null());
// The text is the CLI's own navigation rendering.
assert!(text(&out).contains("depth: 2"), "{}", text(&out));
}
#[test]
fn t034_epic_health_returns_the_expected_counters() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", Some(5), None)]);
write_epic_issue(&store, 1, "Done", "done", &["e-aaa111"]);
write_epic_issue(&store, 2, "Open", "todo", &["e-aaa111"]);
let out = invoke(
&store.config,
&store,
"epic_health",
json!({"epic": "Alpha"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic_id"], "e-aaa111");
assert_eq!(sc(&out)["done"], 1);
assert_eq!(sc(&out)["total"], 2);
assert_eq!(sc(&out)["progress"], json!({"done": 1, "total": 2}));
assert_eq!(sc(&out)["child_count"], 0);
assert_eq!(sc(&out)["blocked"], 0);
assert_eq!(sc(&out)["weighted_progress"]["own_weight"], 5);
assert_eq!(sc(&out)["missing_references"].as_array().unwrap().len(), 0);
// Read-only: nothing was written.
assert_eq!(epic_events(&store, 1).len(), 0);
}
#[test]
fn t034_missing_references_are_reported_exactly_as_the_service_does() {
// The issue-held half: a reference to an epic that no longer exists is
// diagnosed by `list_epics`, naming the holder, the kind and the target.
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111", "e-gone99"]);
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert!(!err(&listed), "{}", text(&listed));
let missing = sc(&listed)["missing_references"].as_array().unwrap();
assert_eq!(missing.len(), 1);
assert_eq!(missing[0]["holder"], "issue #1");
assert_eq!(missing[0]["kind"], "epic");
assert_eq!(missing[0]["diagnostic_kind"], "missing_epic_reference");
assert_eq!(missing[0]["target"], "e-gone99");
// The epic-held half: a dangling project id, milestone and cycle are
// diagnosed by `epic_health` from the service's own rollup.
let (_tmp, store) = epic_repo(&[("e-bbb222", "Beta", None, None)]);
let mut config = store.config.clone();
if let Some(record) = config.epics.get_mut("e-bbb222") {
record.projects = vec!["p-gone42".to_string()];
record.milestone = Some("Q9".to_string());
record.cycle = Some("Never".to_string());
}
let path = _tmp.path().join("tix.toml");
config.save(&path).unwrap();
let config = Config::load_with_warning(&path, |_| {}).unwrap();
let store = Store {
issues_dir: store.issues_dir.clone(),
config,
};
let out = invoke(
&store.config,
&store,
"epic_health",
json!({"epic": "e-bbb222"}),
);
assert!(!err(&out), "{}", text(&out));
let mut kinds: Vec<&str> = sc(&out)["missing_references"]
.as_array()
.unwrap()
.iter()
.map(|m| m["diagnostic_kind"].as_str().unwrap())
.collect();
kinds.sort_unstable();
assert_eq!(
kinds,
vec![
"missing_cycle_reference",
"missing_milestone_reference",
"missing_project_reference"
]
);
}
#[test]
fn t034_epic_membership_lists_the_ordered_members() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
write_epic_issue(&store, 2, "Two", "todo", &["e-aaa111"]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111"]);
let out = invoke(
&store.config,
&store,
"epic_membership",
json!({"epic": "Alpha"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["epic_id"], "e-aaa111");
assert_eq!(sc(&out)["member_count"], 2);
let members: Vec<u64> = sc(&out)["members"]
.as_array()
.unwrap()
.iter()
.map(|m| m["issue"].as_u64().unwrap())
.collect();
assert_eq!(members, vec![1, 2], "ordered by issue number");
}
#[test]
fn t034_delete_epic_reports_the_impact_and_deletes_neither() {
let (_tmp, store) = epic_repo(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, Some("e-aaa111")),
]);
write_epic_issue(&store, 1, "One", "todo", &["e-aaa111"]);
let before_issue = snap(&store);
let out = invoke(
&store.config,
&store,
"delete_epic",
json!({"epic": "Alpha", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["id"], "e-aaa111");
// The affected issue and the child epic are reported, not deleted.
let affected: Vec<u64> = sc(&out)["affected"]
.as_array()
.unwrap()
.iter()
.map(|m| m["issue"].as_u64().unwrap())
.collect();
assert_eq!(affected, vec![1]);
assert_eq!(sc(&out)["child_epics"][0]["id"], "e-bbb222");
assert!(
text(&out).contains("Note: 1 issue still references this epic"),
"{}",
text(&out)
);
// Nothing was deleted: the issue is unchanged and keeps the reference.
assert_eq!(snap(&store), before_issue);
let still = invoke(&store.config, &store, "list_epics", json!({}));
let ids: Vec<&str> = sc(&still)["epics"]
.as_array()
.unwrap()
.iter()
.map(|e| e["id"].as_str().unwrap())
.collect();
assert_eq!(ids, vec!["e-bbb222"], "only the registry entry was removed");
// The child epic keeps its recorded parent, which reads as unresolved.
let child = invoke(
&store.config,
&store,
"epic_hierarchy",
json!({"epic": "e-bbb222"}),
);
assert!(!err(&child), "{}", text(&child));
assert_eq!(sc(&child)["unresolved"]["recorded_parent"], "e-aaa111");
}
#[test]
fn t034_an_epic_created_in_the_session_resolves_immediately() {
// The registry was re-read per call: a `get_epic` or `list_epics` that used
// the server's bootstrapped config would read an epic this session created
// as `unknown_epic` while every other epic handler resolved it.
let (_tmp, store) = epic_repo(&[]);
let created = invoke(
&store.config,
&store,
"create_epic",
json!({"name": "Session"}),
);
assert!(!err(&created), "{}", text(&created));
let id = sc(&created)["id"].as_str().unwrap().to_string();
let shown = invoke(&store.config, &store, "get_epic", json!({"epic": id}));
assert!(!err(&shown), "{}", text(&shown));
let listed = invoke(&store.config, &store, "list_epics", json!({}));
assert_eq!(sc(&listed)["epics"].as_array().unwrap().len(), 1);
}
#[test]
fn t034_epic_delete_without_confirm_is_refused_and_writes_nothing() {
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let before = snap(&store);
let out = invoke(
&store.config,
&store,
"delete_epic",
json!({"epic": "Alpha"}),
);
assert!(err(&out), "a delete without `confirm: true` is refused");
assert!(text(&out).contains("confirm"), "{}", text(&out));
assert_eq!(snap(&store), before);
}
#[test]
fn t035_every_epic_tool_is_declared_and_dispatched() {
let (_tmp, store) = epic_repo(&[]);
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"list_epics",
"get_epic",
"create_epic",
"update_epic",
"delete_epic",
"epic_hierarchy",
"epic_membership",
"epic_health",
"epic_associate_issues",
"epic_dissociate_issues",
] {
assert!(
declared.contains(&name),
"`{name}` must be declared: {declared:?}"
);
// The converse of the sweep: a tool that is dispatched but not declared
// is invisible to it.
assert!(
dispatch(&store.config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
#[test]
fn t034_a_task_labelled_issue_stays_an_ordinary_issue() {
// C-001: no task entity. A `task` label plus a reference is a label and a
// reference, and no response carries a task key.
let (_tmp, store) = epic_repo(&[("e-aaa111", "Alpha", None, None)]);
let out = invoke(
&store.config,
&store,
"create_issue",
json!({"title": "Taskish", "labels": "task", "epics": "e-aaa111"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["labels"], json!(["task"]));
assert_eq!(sc(&out)["epics"], json!(["e-aaa111"]));
assert!(sc(&out).get("task").is_none(), "no task entity is exposed");
assert!(sc(&out).get("work_item").is_none());
}
// -----------------------------------------------------------------------
// WP06 (mission `per-issue-time-tracking-01M3C9A4`) — the time tools
// -----------------------------------------------------------------------
/// One issue carrying a `time_entries:` block, written as bytes.
///
/// Bytes rather than the writer, so a fixture can hold what a hand edit can —
/// including a malformed entry and a preserved key that collides with a typed
/// field. `resolve_issue` finds it by its frontmatter number.
fn write_time_issue(store: &Store, number: u32, entries: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{entries}---\n\nbody"
);
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
fs::write(
store
.issues_dir
.join(format!("alice/2026-01-01-time-{number}.md")),
contents,
)
.unwrap();
}
/// One `time_entries:` item.
fn time_entry_item(id: &str, minutes: i64, date: Option<&str>, author: &str) -> String {
let mut out = format!("- id: {id}\n duration_minutes: {minutes}\n");
if let Some(date) = date {
out.push_str(&format!(" date: \"{date}\"\n"));
}
out.push_str(&format!(" author: \"{author}\"\n"));
out
}
/// A `time_entries:` block holding the given items.
fn time_entries_block(items: &[String]) -> String {
format!("time_entries:\n{}", items.concat())
}
#[test]
fn t029_the_time_schemas_are_strict_and_only_the_delete_requires_confirm() {
let by_name = |name: &str| -> &ToolDescriptor {
registry()
.iter()
.find(|t| t.name == name)
.unwrap_or_else(|| panic!("`{name}` is not registered"))
};
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
let tool = by_name(name);
assert_eq!(tool.input_schema["type"], json!("object"), "{name}");
assert_eq!(
tool.input_schema["additionalProperties"],
json!(false),
"{name}"
);
let verb = name.trim_start_matches("time_").replace('_', " ");
assert!(
tool.description.contains(&format!("`tix time {verb}")),
"`{name}` must name the CLI verb it mirrors"
);
}
assert_eq!(
by_name("time_delete").input_schema["required"],
json!(["issue", "entry", "confirm"]),
"a destructive tool whose confirmation is optional is a tool clients call by accident"
);
assert_eq!(
by_name("time_add").input_schema["required"],
json!(["issue", "minutes", "author"])
);
assert_eq!(
by_name("time_edit").input_schema["required"],
json!(["issue", "entry"]),
"an edit names only the fields it changes, so no field is required"
);
for name in ["time_show", "time_history"] {
assert_eq!(by_name(name).input_schema["required"], json!(["issue"]));
}
assert!(
by_name("time_total").input_schema.get("required").is_none(),
"a total with no argument is the whole board's total"
);
assert_eq!(
by_name("time_total").input_schema["properties"]["by"]["enum"],
json!(["all", "issue", "assignee", "date"])
);
// The closed model: no schema offers a timer, a timezone or a money field.
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
let properties = by_name(name).input_schema["properties"]
.as_object()
.cloned()
.unwrap_or_default();
for forbidden in [
"timer", "start", "stop", "timezone", "tz", "overlap", "billing", "rate",
"currency", "amount", "invoice", "payroll",
] {
assert!(
!properties.contains_key(forbidden),
"`{name}` must not declare `{forbidden}` (C-009)"
);
}
}
}
#[test]
fn t029_an_undeclared_time_argument_is_refused_by_name() {
let (_tmp, config, store) = make_repo();
for (tool, args, key) in [
(
"time_add",
json!({"issue": 1, "minutes": 5, "author": "ada", "timer": true}),
"timer",
),
(
"time_edit",
json!({"issue": 1, "entry": "e-1", "timezone": "Europe/Berlin"}),
"timezone",
),
("time_total", json!({"billing": "hourly"}), "billing"),
("time_show", json!({"issue": 1, "start": "now"}), "start"),
(
"time_delete",
json!({"issue": 1, "entry": "e-1", "confirm": true, "rate": 30}),
"rate",
),
(
"time_history",
json!({"issue": 1, "invoice": "x"}),
"invoice",
),
] {
let out = invoke(&config, &store, tool, args);
assert!(err(&out), "`{tool}` must refuse an undeclared argument");
assert!(
text(&out).contains(&format!("`{key}`")),
"`{tool}` must name the offending key: {}",
text(&out)
);
assert!(
text(&out).contains("no timer"),
"the refusal must say what the model is: {}",
text(&out)
);
}
}
#[test]
fn t029_a_recorded_zero_is_not_an_absent_value() {
assert_eq!(time_minutes_value(None).unwrap(), Value::Null);
assert_eq!(
time_minutes_value(Some(0)).unwrap(),
json!(0),
"a zero that was recorded is a number, not an absence"
);
assert_eq!(time_minutes_value(Some(90)).unwrap(), json!(90));
// A sum no JSON number can carry is refused rather than rounded, wrapped or
// reported absent.
let message = time_minutes_value(Some(i128::MAX)).unwrap_err();
assert!(message.contains(&i128::MAX.to_string()), "{message}");
}
#[test]
fn t029_preserved_entry_metadata_is_nested_and_never_shadows_a_typed_field() {
let mut extensions = crate::time_tracking::TimeEntryExtensions::default();
extensions.insert("note", serde_yaml::Value::String("shadow".into()));
extensions.insert("custom", serde_yaml::Value::Bool(true));
let entry = TimeEntry {
id: "e-1".to_string(),
duration_minutes: Some(30),
date: Some("2026-03-01".to_string()),
author: "ada".to_string(),
note: Some("real".to_string()),
extensions,
};
let value = time_entry_value(3, &entry);
assert_eq!(value["note"], json!("real"));
assert_eq!(
value["extensions"]["note"],
json!("shadow"),
"a preserved key keeps its own place instead of shadowing the typed field"
);
assert_eq!(value["extensions"]["custom"], json!(true));
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"author",
"date",
"duration_minutes",
"extensions",
"id",
"issue",
"note",
],
"the entry object is exactly the declared fields plus a nested extensions map"
);
}
#[test]
fn t029_a_hand_written_document_that_collides_is_refused_by_name() {
let (_tmp, config, store) = make_repo();
// A hand edit that names `note` twice. The parser that owns the entry grammar
// refuses the duplicate field and names the entry and the field, so nothing is
// silently dropped and **no payload is built at all** — which is the other half
// of the preservation rule: a preserved key never shadows a projected one,
// because a document that would shadow one never becomes an entry
// ([`t029_preserved_entry_metadata_is_nested_and_never_shadows_a_typed_field`]
// pins the shape the payload uses for the keys that *are* preserved).
write_time_issue(
&store,
1,
"time_entries:\n- id: e-1\n duration_minutes: 30\n author: \"ada\"\n \
note: \"real\"\n note: \"shadow\"\n",
);
let before = whole_store_bytes(&store);
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(err(&out), "a colliding document is refused, not repaired");
assert!(
text(&out).contains("time_entries[0]") && text(&out).contains("note"),
"the refusal names the entry and the field: {}",
text(&out)
);
assert_eq!(
whole_store_bytes(&store),
before,
"a refused read writes nothing"
);
}
#[test]
fn t031_nothing_and_unknown_are_answers_and_neither_is_zero() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let out = invoke(&config, &store, "time_total", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
assert_eq!(sc(&out)["total_minutes"], Value::Null);
assert_eq!(sc(&out)["entry_count"], json!(0));
assert!(text(&out).contains("total_minutes: -"));
// Now an entry whose duration is unusable: entries *were* selected, so the
// answer is `unknown`, not `nothing`, and still not a zero.
write_time_issue(
&store,
2,
&time_entries_block(&[time_entry_item("e-bad", -5, Some("2026-03-01"), "ada")]),
);
let out = invoke(&config, &store, "time_total", json!({"issue": 2}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("unknown"));
assert_eq!(sc(&out)["total_minutes"], Value::Null);
assert_eq!(sc(&out)["malformed_count"], json!(1));
assert_eq!(sc(&out)["is_partial"], json!(true));
assert!(text(&out).contains("total_minutes: -"));
assert!(
sc(&out)["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|finding| finding["kind"] == json!("invalid_duration")
&& finding["entry_id"] == json!("e-bad")),
"the malformed entry is named: {}",
sc(&out)["diagnostics"]
);
// And the sibling issue's read is unaffected.
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
}
#[test]
fn t029_a_refused_scope_refuses_instead_of_reporting_a_total() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let before = whole_store_bytes(&store);
let out = invoke(&config, &store, "time_total", json!({"from": "not-a-date"}));
assert!(err(&out), "an invalid bound is a refusal, not a total");
assert!(
text(&out).contains("E-tix-time") && text(&out).contains("kind: invalid_date"),
"{}",
text(&out)
);
assert!(
text(&out).contains("help:"),
"a refusal carries a remedy: {}",
text(&out)
);
assert_eq!(
whole_store_bytes(&store),
before,
"a refused read writes nothing"
);
let out = invoke(&config, &store, "time_total", json!({"by": "bogus"}));
assert!(err(&out));
assert!(
text(&out).contains("unknown --by dimension") && text(&out).contains("`assignee`"),
"{}",
text(&out)
);
}
#[test]
fn t032_the_time_tools_are_declared_and_dispatched() {
let (_tmp, config, store) = make_repo();
let declared_tools = list();
let declared: Vec<&str> = declared_tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
for name in [
"time_show",
"time_history",
"time_total",
"time_add",
"time_edit",
"time_delete",
] {
assert!(declared.contains(&name), "`{name}` must be declared");
assert!(
dispatch(&config, &store, name, &json!({})).is_some(),
"`{name}` must have a dispatch arm, not merely a descriptor"
);
}
}
#[test]
fn t030_a_time_mutation_refuses_a_blank_reason_and_a_negative_duration() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[time_entry_item("e-1", 30, None, "ada")]),
);
let before = whole_store_bytes(&store);
// The reason gate belongs to the service, and the MCP surface passes the blank
// through rather than owning a second copy of the rule.
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": "e-1", "minutes": 45}),
);
assert!(err(&out));
assert!(text(&out).contains("requires `--reason`"), "{}", text(&out));
// A negative duration is refused by the shared entry validator, by field name.
let out = invoke(
&config,
&store,
"time_add",
json!({"issue": 1, "minutes": -5, "author": "ada"}),
);
assert!(err(&out));
assert!(
text(&out).contains("duration_minutes"),
"the shared validator's own field name: {}",
text(&out)
);
// A contradictory edit is refused too: `clap` refuses the pair on the CLI.
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": "e-1", "date": "2026-03-01", "clear_date": true, "reason": "x"}),
);
assert!(err(&out));
assert!(text(&out).contains("clear_date"), "{}", text(&out));
assert_eq!(
whole_store_bytes(&store),
before,
"none of the three refusals may write"
);
}
#[test]
fn t030_a_time_delete_without_confirmation_refuses_before_reading_and_writes_nothing() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[time_entry_item("e-1", 30, None, "ada")]),
);
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": "e-1", "reason": "no"}),
);
assert!(err(&out));
assert!(
text(&out).contains(cmd_time::TIME_DELETE_CONFIRMATION),
"the CLI's own sentence: {}",
text(&out)
);
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": "e-1", "reason": "no", "confirm": false}),
);
assert!(err(&out), "a false confirmation is not a confirmation");
assert_eq!(
whole_store_bytes(&store),
before,
"an unconfirmed delete must not touch the store"
);
}
#[test]
fn t030_a_time_mutation_carries_the_services_outcome_and_appends_one_event() {
let (_tmp, config, store) = make_repo();
write_time_issue(&store, 1, "");
let out = invoke(
&config,
&store,
"time_add",
json!({"issue": 1, "minutes": 90, "author": "ada", "date": "2026-03-01", "note": "kickoff"}),
);
assert!(!err(&out), "{}", text(&out));
let id = sc(&out)["entry_id"].as_str().unwrap().to_string();
assert!(id.starts_with("e-"), "{id}");
assert_eq!(sc(&out)["status"], json!("created"));
assert_eq!(sc(&out)["changed"], json!(true));
assert_eq!(sc(&out)["before"], Value::Null);
assert_eq!(sc(&out)["after"]["duration_minutes"], json!(90));
assert_eq!(
sc(&out)["history"]["kind"],
json!("time_entry_created"),
"exactly one attributable event, returned with the outcome"
);
assert!(text(&out).starts_with(&format!(
"Added {id} to #1: 90m on 2026-03-01 by ada: kickoff"
)));
// The read agrees with the write, through the shared snapshot.
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["entries"][0]["id"], json!(id));
assert_eq!(sc(&out)["total_minutes"], json!(90));
assert_eq!(sc(&out)["state"], json!("recorded"));
// A correction names only what changes and keeps the prior value.
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": id, "minutes": 45, "reason": "overcounted"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("updated"));
assert_eq!(sc(&out)["before"]["duration_minutes"], json!(90));
assert_eq!(sc(&out)["after"]["duration_minutes"], json!(45));
assert_eq!(sc(&out)["after"]["date"], json!("2026-03-01"));
assert_eq!(sc(&out)["history"]["kind"], json!("time_entry_updated"));
// An edit that asks for what is already there writes nothing and says so.
let before = whole_store_bytes(&store);
let out = invoke(
&config,
&store,
"time_edit",
json!({"issue": 1, "entry": id, "minutes": 45, "reason": "again"}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("unchanged"));
assert_eq!(sc(&out)["changed"], json!(false));
assert_eq!(sc(&out)["history"], Value::Null);
assert_eq!(whole_store_bytes(&store), before, "a no-op writes nothing");
// The deletion leaves the value in History and is auditable there.
let out = invoke(
&config,
&store,
"time_delete",
json!({"issue": 1, "entry": id, "reason": "duplicate", "confirm": true}),
);
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["status"], json!("deleted"));
assert_eq!(sc(&out)["after"], Value::Null);
assert_eq!(sc(&out)["history"]["kind"], json!("time_entry_deleted"));
let out = invoke(&config, &store, "time_history", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["current"], json!(0));
assert_eq!(sc(&out)["deleted_count"], json!(1));
assert_eq!(sc(&out)["corrected_count"], json!(1));
assert_eq!(sc(&out)["issues"][0]["deletions"][0]["entry_id"], json!(id));
// And the entry is gone from the current projection only.
let out = invoke(&config, &store, "time_show", json!({"issue": 1}));
assert!(!err(&out), "{}", text(&out));
assert_eq!(sc(&out)["state"], json!("nothing"));
assert_eq!(sc(&out)["entries"], json!([]));
}
#[test]
fn t031_a_breakdown_answers_from_the_shared_groups() {
let (_tmp, config, store) = make_repo();
write_time_issue(
&store,
1,
&time_entries_block(&[
time_entry_item("e-1", 90, Some("2026-03-01"), "ada"),
time_entry_item("e-2", 30, None, "bob"),
]),
);
write_time_issue(
&store,
2,
&time_entries_block(&[time_entry_item("e-3", 15, Some("2026-03-02"), "ada")]),
);
let out = invoke(&config, &store, "time_total", json!({"by": "assignee"}));
assert!(!err(&out), "{}", text(&out));
let groups = sc(&out)["groups"].as_array().unwrap();
let labels: Vec<&str> = groups
.iter()
.map(|group| group["group"].as_str().unwrap())
.collect();
assert_eq!(labels, ["@ada", "@bob"]);
let ada = &groups[0];
assert_eq!(ada["total_minutes"], json!(105));
assert_eq!(
ada["scope"],
json!("all"),
"a group carries the scope's own filter; its identity is the group label"
);
// The scope's own total is the sum of its groups.
assert_eq!(sc(&out)["total_minutes"], json!(135));
// A scope with no period places an undated entry *in* the period, so it is
// counted and the undated count is zero: `undated_count` reports what a
// period could not place, which is a fact only a bounded scope has.
assert_eq!(sc(&out)["undated_count"], json!(0));
assert_eq!(groups[1]["undated_count"], json!(0));
// The block the text prints for the scope comes first, then one per group.
assert!(
text(&out).starts_with("group: all\nscope: all\nstate: recorded\ntotal_minutes: 135")
);
assert!(text(&out).contains("\n\ngroup: @ada\n"));
}
}