tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
# tix MCP server

`tix mcp` exposes the tix issue tracker to AI coding agents over the [Model
Context Protocol](https://modelcontextprotocol.io) (MCP). It is a **stdio
subprocess server**: the harness spawns `tix mcp`, exchanges newline-delimited
JSON-RPC 2.0 messages over `stdin`/`stdout`, and the agent gains 118 tools to
create, read, update, move, link, delete, search, comment on, export/import and
lint issues, manage saved views, projects, epics, initiatives and the roadmap,
and run batch/triage operations, plus the stable-ID template registry, which it
can list, manage and create from, the repeating-cycle schedules and their
lifecycle, the shared capacity calculation, snapshots and metadata, the
per-issue time entries, totals and audit view, and the burndown/scope analytics
series, forecast and chart document — the same operations as the CLI, backed by
the same command cores.

- Transport   : MCP stdio, JSON-RPC 2.0, one message per line (UTF-8)
- Feature     : `tools/*` (tools/list, tools/call), `ping`, `initialize`
- Repo scope  : the working directory of the spawned process (see
  [Repository resolution]#repository-resolution)
- Current protocol versions: `2025-06-18` (newest), `2025-03-26`, `2024-11-05`

---

## How it works

### Lifecycle

1. The harness spawns `tix mcp` from the repository root.
2. The server performs a strict bootstrap: it walks **upward** from the cwd to
   find a `tix.toml` (the same discovery as `tix` with no subcommand). No
   `tix.toml` ⇒ the process writes `no tix.toml found — run "tix init" first`
   to **stderr** and exits `1` **before reading any protocol bytes**.
3. The client sends `initialize` with its `protocolVersion`. The server echoes
   the version if it is supported, otherwise negotiates down to `2025-06-18`.
   The response advertises `capabilities.tools.listChanged = false` and
   `serverInfo = { name: "tix", version: "<tix version>" }`.
4. The client sends `notifications/initialized` (no response is produced).
5. Normal operation: `tools/list` and `tools/call`.
6. The server runs until `stdin` closes (EOF), then exits `0`.

### Channel discipline

- **stdout**: only protocol messages, one JSON line each. Nothing else is ever
  written to stdout (no notices, no logs, no prompts).
- **stderr**: human diagnostics only (e.g. the no-`tix.toml` bootstrap error).
- **stdin**: only protocol messages. The server never prompts on stdin; every
  tool call must supply everything it needs as arguments.

### Result shapes

A successful `tools/call` returns:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{ "type": "text", "text": "<human/agent-readable rendering>" }],
    "structuredContent": { "...": "machine-readable data" },
    "isError": false
  }
}
```

The `content` text mirrors the corresponding CLI command's output exactly
(bit-parity with `tix list`/`tix view`/`tix status`/`tix search`/`tix lint`),
so a human and an agent read identical facts.

**Domain failures** (issue not found, ambiguous selector, unknown column/bad
priority, missing `glab`/`linear-cli`, delete without `confirm`) are **not**
JSON-RPC errors. They return a tool result with `isError: true`, the CLI's exact
error wording in `content`, and no `structuredContent`. The connection stays
alive — a failing tool never kills the server.

### JSON-RPC errors

The five frozen error codes are reserved for protocol-level failures:

| Code   | Meaning               | When                                                   |
|--------|-----------------------|--------------------------------------------------------|
| -32700 | Parse error           | Undecodable JSON / bad framing line (`id: null`)       |
| -32600 | Invalid request       | Valid JSON but not a JSON-RPC message/method shape     |
| -32601 | Method not found      | Unknown method or unknown tool name in `tools/call`    |
| -32602 | Invalid params        | `tools/call` missing `name`, or type-invalid arguments |
| -32603 | Internal error        | Unexpected internal failure (panic guard)              |

### Concurrency

The server is single-threaded and sequential: one request → one response, in
order. Mutation results are reported only after the underlying write succeeds
(no "side-effect-then-error" lie). Clients may still pipeline.

---

## The 118 tools

| Tool             | CLI counterpart      | Purpose                                            |
|------------------|----------------------|----------------------------------------------------|
| `create_issue`   | `tix create`         | Create an issue (`title` required, no prompt)      |
| `list_issues`    | `tix list`           | Board table, optional `status` filter              |
| `get_issue`      | `tix view`           | Full issue (frontmatter + body) for a selector     |
| `board_status`   | `tix status`         | Column counts + total (`--json` = the same object)  |
| `update_issue`   | `tix edit` (flags)   | Merge fields; at least one field required          |
| `move_issue`     | `tix move`           | Move to a configured column; never prompts; judged by the same transition **and WIP-limit** policy as the CLI |
| `link_issue`     | `tix link --type`    | Create a **typed** relationship; `kind` required   |
| `list_relationships` | `tix link list`  | Relationships for one issue or the whole board     |
| `edit_relationship`  | `tix link edit`  | Retarget/re-kind by id; the id survives the edit   |
| `remove_relationship`| `tix link remove`| Remove by id; an absent id is a no-op, not an error|
| `issue_chain`    | `tix chain`          | Transitive `blocks` chain, service order + truncated|
| `issue_blocked_state` | (derived read)   | `is_blocked`, the blockers, and the exact reason   |
| `list_children`   | `tix parent children` | An issue's **direct** children, in issue order |
| `get_parent`      | `tix parent show` (parent line) | The resolved parent, or the retained reference plus the service's reason |
| `get_rollup`      | `tix parent show` (figures) | Direct-children count, completions, and the progress value, as numbers |
| `bulk_preview`    | `tix bulk …` (no `--apply`) | Plan a batch and return it **without writing**. Read-only. |
| `bulk_apply`      | `tix bulk … --apply`  | Confirm and apply a batch; **requires `confirm: true`** |
| `bulk_capabilities`| (none — metadata)     | What a batch can do in this build: operations, the patch whitelist, the outcome vocabulary, the selection grammar |
| `triage_list`    | `tix triage list`    | The inbox queue, with the queue age, or its counts with `count: true`. Read-only |
| `triage_status`  | `tix triage status`  | The counts, the accepted/declined/duplicated figures, and the age histogram. Read-only |
| `triage_preview` | `tix triage batch` (no `--apply`) | Plan one decision over one issue or a selection **without writing** |
| `triage_apply`   | `tix triage batch --apply` | Apply it; **requires `confirm: true`** |
| `delete_issue`   | `tix delete --yes`   | Delete; **requires `confirm: true`**               |
| `search_issues`  | `tix search`         | Case-insensitive substring over full markdown      |
| `comment_on_issue`| (model writer)      | Append a comment to an issue                       |
| `mentions_list`  | `tix mentions list`  | The `@mentions` pending for the local user, across issues |
| `mentions_mark_read` | `tix mentions read` | Mark one mention read by id; the only mutating mention answer |
| `export_issues`  | `tix export`         | Mirror to GitLab/Linear via `glab`/`linear-cli`    |
| `import_issues`  | `tix import`         | Pull open remote issues (idempotent by remote id)  |
| `lint_issues`    | `tix lint`           | Validate frontmatter in every issue file           |
| `view_list`      | `tix view list`      | Saved views in declaration order; plan, scope, diagnostics |
| `view_show`      | `tix view show`      | One saved view: stable id, scope, normalized plan, diagnostics |
| `view_create`    | `tix view create`    | Save a new view; the stable id is generated         |
| `view_update`    | `tix view update`    | Replace the named fields, keeping the stable id     |
| `view_duplicate` | `tix view duplicate` | Copy a view under a **new** stable id               |
| `view_delete`    | `tix view delete`    | Remove the **definition** only; requires `confirm: true` |
| `view_share`     | `tix view share`     | Replace scope metadata (repository/project/user)    |
| `view_apply`     | `tix view apply`     | Apply read-only: plan, issues, board summary, active marker |
| `view_status`    | `tix view status`    | Saved vs live plan divergence, field by field       |
| `list_templates` | `tix template list`  | One entry per stored template: stable ID, name, scope, defaults, prompts, references, preserved extensions. Read-only |
| `get_template`   | `tix template show`  | One template's full metadata, by stable ID or unique display name. Read-only |
| `create_template`| `tix template create`| Create a template under a new stable ID; the whole definition is validated before the atomic save |
| `update_template`| `tix template update`| Replace the supplied parts; the stable ID never changes, and a same-content update is a `no-op` |
| `duplicate_template` | `tix template duplicate` | Copy to a new stable ID (`<source>-copy` by default), leaving the source untouched |
| `delete_template`| `tix template delete`| Delete a **definition**; **requires `confirm: true`**, and never touches issues created from it |
| `list_recurring_series` | `tix recurring list` | Every series in stable-ID order: id, name, state, cursor, skipped count, rule. Read-only |
| `get_recurring_series` | `tix recurring show` | One series' full state: rule, anchor, cursor, skipped, occurrences, diagnostics. Read-only |
| `detect_recurring_due` | `tix recurring detect` | The due warnings (with their stable occurrence keys) and why a series is *not* due. Read-only |
| `generate_recurring_occurrence` | `tix recurring generate --yes` | Create the due occurrence; **requires `confirm: true`**; idempotent |
| `pause_recurring_series` | `tix recurring pause --yes` | Suspend generation; **requires `confirm: true`**; idempotent |
| `resume_recurring_series` | `tix recurring resume --yes` | Resume a paused series; **requires `confirm: true`**; idempotent |
| `end_recurring_series` | `tix recurring end --yes` | End a series; terminal for generation; **requires `confirm: true`** |
| `skip_recurring_occurrence` | `tix recurring skip --yes` | Record a date as skipped, so it is never generated; **requires `confirm: true`** |
| `defer_recurring_occurrence` | `tix recurring defer --yes` | Move the cursor past a date without skipping it; **requires `confirm: true`** |
| `ignore_recurring_occurrence` | `tix recurring ignore --yes` | Stop offering a date (same schedule effect as `skip`); **requires `confirm: true`** |
| `cycle_schedule_list` | `tix cycle schedule list` | Every schedule in declaration order: id, name, state, cadence, duration, anchor, look-ahead, derived next sequence, instance count. Read-only |
| `cycle_schedule_show` | `tix cycle schedule show` | One schedule's full state: rule, cursor, instances, diagnostics, by stable id or unique name. Read-only |
| `cycle_schedule_create` | `tix cycle schedule create --confirm` | Define a schedule; validated before the atomic save. **Requires `confirm: true`**; an id is never reused — a live id is `exists`, and one a stored record **or** a snapshot still claims is `id_spent`, naming the holder |
| `cycle_schedule_edit` | `tix cycle schedule edit --confirm` | Change the named fields, keeping the stable id and the cursor. **Requires `confirm: true`** |
| `cycle_schedule_delete` | `tix cycle schedule delete --confirm` | Remove the **registry entry only**; instances and snapshots are kept and then read as a missing schedule. **Requires `confirm: true`** |
| `cycle_schedule_detect` | `tix cycle schedule detect` | The `ended`/`due`/`look-ahead` warnings with their instance ids, dates and accepted actions, plus field-attributed `failure` warnings. Read-only |
| `cycle_schedule_generate` | `tix cycle schedule generate --confirm` | Create the future instances; **requires `confirm: true`**; idempotent (a taken key is a `no-op`) |
| `cycle_schedule_close` | `tix cycle schedule close --confirm` | Capture the immutable snapshot and close an instance; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_reopen` | `tix cycle schedule reopen --confirm` | Reopen a closed instance: restore `active` and append one History event naming the prior snapshot; the snapshot's bytes are untouched. **Requires `confirm: true`** and a `reason`; idempotent (an open instance is a `no-op`) |
| `cycle_schedule_extend` | `tix cycle schedule extend --confirm` | Push an instance's due date out by one cadence period; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_skip` | `tix cycle schedule skip --confirm` | Record a sequence as skipped, so it is never regenerated; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_pause` | `tix cycle schedule pause --confirm` | Pause the schedule and its non-terminal instances; **requires `confirm: true`**; idempotent |
| `cycle_schedule_resume` | `tix cycle schedule resume --confirm` | Resume a paused schedule and its instances; **requires `confirm: true`**; idempotent |
| `cycle_schedule_adjust_future` | `tix cycle schedule adjust-future --confirm` | Re-derive every future instance's dates and the cursor; **requires `confirm: true`** and a `reason` |
| `cycle_schedule_ignore` | `tix cycle schedule ignore --confirm` | Close a warning without deciding anything — nothing is written; **requires `confirm: true`** |
| `cycle_snapshot_list` | (snapshot view) | The immutable closed-cycle snapshots under `docs/cycle_snapshots/`: identity, historical dates, audit context, preserved metadata. Read-only |
| `capacity_list`  | `tix capacity list`  | Every configured capacity entry: the handle id, scope kind, reference, resolved display, ceiling, unit and enabled flag. Read-only; an absent ceiling/unit is `null`, never `0` |
| `capacity_show`  | `tix capacity show`  | One configured entry, by a cycle name **or** its stable id, a team label, or an assignee identity — the two spellings fold to one scope. Read-only |
| `capacity_calculate` | `tix capacity calculate` | The shared calculation for one scope: state, ceiling/unit, committed/completed/remaining/overflow, utilization, the issue counts, and every diagnostic with its reason and remedy. Read-only; absent values are `null`, never `0` |
| `capacity_snapshot` | `tix capacity snapshot` | A closed cycle's immutable capacity evidence (identity, values, findings), plus the cycle record's close/reopen History summaries. Read-only; a snapshot with no capacity payload is `evidence: none` — a value, never an error |
| `health_scope`   | `tix health show`    | One scope's health: state, layer, policy version, derived rollup with its contributors, audit entries, and every diagnostic with its remedy. Name exactly one of `issue` or `scope` |
| `health_rollup`  | `tix health rollup`  | A scope's derived health with its contributors and policy version. Read-only |
| `health_history` | `tix health history` | The append-only health History entries about a scope. Read-only |
| `health_record`  | `tix health set` / `tix health override` | Record an issue's state, or layer an override over its derived value. One History entry carries actor/date/rationale, written with the value or not at all. **`override: true` requires `confirm: true`** |
| `list_projects`  | `tix project list`   | Projects in declaration order: id, name and derived member count. Read-only |
| `get_project`    | `tix project show`   | One project's record and its ordered members, by stable id (a name resolves to the same project). Read-only |
| `create_project` | `tix project create` | Create a project; the registry allocates the **stable id**, which is returned. Validates the name and the dates |
| `update_project` | `tix project edit`   | Edit a project's fields; a `name` change is a label change only, so the id and every association survive. A blank field clears it |
| `delete_project` | `tix project delete --yes` | Remove the **registry entry only**, returning the affected issues; never deletes an issue. **Requires `confirm: true`** |
| `project_membership` | (derived read)    | A project's ordered members and `member_count`, by value. Read-only |
| `project_health` | (derived read)       | A project's raw figures — `done`, `total`, `overdue`, `blocked`, `unassigned`, `missing_references`. Read-only |
| `project_associate_issues` | `tix project add` / `bulk-add` | Associate each of several issues with a project, each independently |
| `project_dissociate_issues` | `tix project remove` | Dissociate each of several issues from a project. Drops only the association; never deletes an issue |
| `list_epics`     | `tix epic list`      | Epics in declaration order: id, name, derived member count and child count. Read-only |
| `get_epic`       | `tix epic show`      | One epic's record, its raw typed figures, its children and its ordered members, by stable id (a name resolves to the same epic). Read-only |
| `create_epic`    | `tix epic create`    | Create an epic; the registry allocates the **stable id**, which is returned. Validates the name, the dates and the weight |
| `update_epic`    | `tix epic edit`      | Edit an epic's fields; a `name` change is a label change only, so the id and every reference survive. A blank field clears it; `parent` is cycle-validated before any write |
| `delete_epic`    | `tix epic delete --yes` | Remove the **registry entry only**, returning the affected issues **and** child epics; never deletes either. **Requires `confirm: true`** |
| `epic_hierarchy` | `tix epic navigate`  | An epic's raw hierarchy — parent, children, ancestors, depth, and the unresolved record. Read-only |
| `epic_membership` | (derived read)      | An epic's ordered members and `member_count`, by value. Read-only |
| `epic_health`    | (derived read)       | An epic's raw figures — `done`, `total`, `child_count`, `weighted_progress`, `blocked`, `missing_references`. Read-only |
| `epic_associate_issues` | `tix epic add` / `bulk-add` | Reference each of several issues from an epic, each independently |
| `epic_dissociate_issues` | `tix epic remove` | Remove an epic reference from each of several issues. Drops only the reference; never deletes an issue |
| `list_initiatives` | `tix initiative list` | Initiatives in declaration order: id, name, target date and status. The id is a column of its own. Read-only |
| `get_initiative` | `tix initiative show` | One initiative's record, its references with their resolution, its source-attributed summary and its diagnostics, by stable id (a name resolves to the same initiative). Read-only |
| `create_initiative` | `tix initiative create` | Create an initiative; the registry allocates the **stable id**, which is returned. Validates the name, priority and reference shapes through the atomic boundary |
| `update_initiative` | `tix initiative edit` | Edit an initiative's fields; a `name` change is a label change only, so the id and every association survive. A blank field clears it |
| `initiative_associate` | `tix initiative associate` | Associate an initiative with exactly one project, epic or milestone; the kind is explicit. An already-recorded association is an idempotent `changed: false` |
| `initiative_dereference` | `tix initiative dereference` | Drop one explicit association; an absent association is an idempotent `changed: false`. Never touches a source entity |
| `delete_initiative` | `tix initiative delete --yes` | Remove the **registry entry only**, returning the references it held; nothing cascades and no project, epic, milestone or issue is deleted or modified. **Requires `confirm: true`** |
| `roadmap_list` | `tix roadmap list` | The roadmap in order (target date ascending, then stable id) over the normalized `RoadmapFilter`; each row carries metadata, references with resolution, the source-attributed summary and diagnostics. Undated is explicit. Read-only |
| `roadmap_show` | `tix roadmap show` | One initiative's roadmap row: metadata, references with resolution, source-attributed summary and diagnostics, by stable id or name. Read-only |
| `roadmap_summary` | `tix roadmap show` (summary) | One initiative's raw typed summary: the contributions (provenance, the owning service's own value, risk, shared policy version), the explicit state, the source entities and policy versions, and the diagnostics. No blended figure. Read-only |
| `time_show`      | `tix time show`      | One issue's current time entries and its total: each entry's stable `id`, minutes, date, author, note and preserved `extensions` (nested, never flattened), plus the total's explicit state. Read-only; an absent value is `null`/`-`, never `0` |
| `time_history`   | `tix time history`   | What History retains for one issue: the current-entry count, every prior value a deletion left behind with who recorded it and when, and the correction count. Read-only |
| `time_total`     | `tix time total`     | The shared total for one scope — issue, assignee (the entry's author), inclusive `from`/`to`, `audit`, and an optional `by` breakdown — with the counts that say whether the sum is the whole story. Read-only |
| `time_add`       | `tix time add`       | Record effort in whole minutes, with an optional `YYYY-MM-DD` date, the required author and an optional note; the stable entry id is minted by the service. One attributable History event |
| `time_edit`      | `tix time edit`      | Correct one entry, naming only the fields that change (`date`/`clear_date` and `note`/`clear_note` are alternatives). Requires a `reason`; the prior value stays in History |
| `time_delete`    | `tix time delete`    | Remove one entry from the **current** entries only, leaving its value and its event in History. Requires a `reason` and **`confirm: true`** |
| `analytics_metadata` | (none — metadata) | The closed vocabularies, formulas and binding rules: scope kinds, modes with their formula version/precision/unit rule, the default board's declared units, coverage markers, projection statuses, the export format/schema, and every diagnostic kind with its remedy. Read-only |
| `analytics_burndown` | `tix analytics burndown` | The daily count/estimate series: scope, period, snapshot binding, mode, unit, formula version, the ordered daily points (`scope_added` vs `completions` kept apart) with each day's coverage and a `null` for every value the record cannot support, the coverage tally, and the findings. Read-only; an unenumerable period is refused, never zero-filled |
| `analytics_projection` | `tix analytics projection` | The read-only end-date forecast at `as_of`: its typed `status`, the planning `end_date`, the `remaining` work and `observed_rate`, and `projected_completion`/`meets_end_date` or `null`. An unanswerable forecast is an answer, not a refusal. Read-only |
| `analytics_export` | `tix analytics export` | The deterministic chart document for one scope, as its own pretty JSON: the format marker and schema version, the scope with its period and snapshot provenance, mode/unit/precision, the series with coverage, the optional projection, the unknown/coverage-limited day counts and the findings. `mode` is **required**. Read-only; `--output` is the CLI's own writer and is not reachable here |

Conventions:

- Every `inputSchema` is JSON-Schema draft-07, `additionalProperties: false`.
- Issue selectors accept an integer (`#123`), an 8-character issue id
  (`A1B2C3D4`), or a filename/title fragment, matching the CLI's precedence:
  number → issue id → unique filename fragment → unique title fragment.
- `labels` and `files` are comma-separated strings (parsed like the CLI).
  `create_issue` and `update_issue` also take `related_issues` — a
  comma-separated list of free-form references (a local number/selector, a
  relative/absolute path to another issue file, or a repository URL
  `host/path#id`), stored verbatim and returned as a `related_issues` array on
  every issue payload. `lint_issues` takes `foreign` (default `true`) to also
  lint the related repositories reachable by path, mirroring `tix lint
  --no-foreign`. `create_issue`/`update_issue` also take `type` (a single issue
  type, checked by lint against `custom_types`).
  **`links` is gone from `create_issue` and `update_issue`** — see the breaking
  change below. Create relationships with `link_issue`.
- Project arguments: `projects` (on `create_issue`, and on `update_issue` to clear)
  and `project` (on `list_issues` and the project tools) name a project by its
  **stable id**, with a name accepted as a convenience that resolves to the same
  project. `projects` is a comma-separated list (the `--projects` shape);
  `add_project` / `remove_project` on `update_issue` are **arrays**, one entry per
  CLI occurrence, taken verbatim. `projects` **sets** the full set on
  `create_issue` and **only clears** (blank) on `update_issue`; `add_project` and
  `remove_project` are how an update changes the set. See
  [Projects]#projects-association-membership-and-health.
- Epic arguments: `epics` (on `create_issue`, and on `update_issue` to clear) and
  `epic` (on `list_issues` and the epic tools) name an epic by its **stable id**,
  with a name accepted as a convenience that resolves to the same epic. `epics` is a
  comma-separated list (the `--epics` shape); `add_epic` / `remove_epic` on
  `update_issue` are **arrays**, one entry per CLI occurrence, taken verbatim.
  `epics` **sets** the full reference set on `create_issue` and **only clears**
  (blank) on `update_issue`; `add_epic` and `remove_epic` are how an update changes
  the set. See [Epics]#epics-references-hierarchy-and-progress.
- There is **no task entity** (C-001): a task-labelled issue is an ordinary issue
  that references an epic. No `task`, `work_item` or `epic_task` argument, tool or
  key exists.
- `link_issue` takes two selectors `a`/`b` and a **required `kind`**, one of
  `blocks | blocked_by | duplicates | related`. The vocabulary is closed: an
  unknown kind is rejected, never coerced.
- `move_issue` also accepts `yes`, mirroring `tix move --yes`. Moving toward the
  board's configured completion column with open blockers is **refused** without
  it, and records the acknowledgement in the issue's History.
- `move_issue` and `update_issue` both accept `reason`, mirroring
  `tix move --reason` and `tix edit --reason`. It is required to move an issue
  **out of** a terminal category (`completed` or `canceled`) on a board that
  declares a `category_map`, and the reason is recorded in the issue's History.
  A blank value is not a reason and is refused. A board with no `category_map` has
  no transition policy to override. `reason` is not an editable field: on
  `update_issue` it qualifies a `status` transition and nothing else, so a call
  carrying only a `reason` is "no fields to update".
- `status` on `update_issue` 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 route around the policy. `""` clears the field and is a
  plain field write, since there is no target column to judge.
- Every issue object returned — from `list_issues`, `get_issue`, `search_issues`,
  and the result of a write — carries `category` (the stable key) and
  `category_label` (the service's display string). `category` is `null` for a
  column the board does not configure, and `category_label` is then `"(unmapped)"`:
  a configuration error the client can see, not a silent default.
- `priority` accepts only `low | medium | high`.
- `move_issue` records a commit only when supply `commit`; moving out of the
  done column clears it. There is **no TTY prompt** anywhere.
- `update_issue`/`delete_issue`/`create_issue` never open `$EDITOR`, the TUI,
  or read stdin.
- `create_issue` also takes `template` and `scope` (only with `template`): the
  stable template ID and its explicit scope. The template's defaults and prompts
  are merged with the fields by the same pure resolver the CLI uses, and the
  resolved request goes through the same shared create core, so both surfaces
  create the same issue and refuse with the same wording. `title` is required
  unless a `template` is named, and the selected ID and scope come back on the
  created issue as `template: { id, scope }`.

### Breaking change: relationships are typed

`link_issue` previously linked two issues **symmetrically** with no kind. Both
spellings of an untype link are gone:

| Before | After |
|---|---|
| `link_issue { a, b }` | `link_issue { a, b, kind }` — `kind` is **required** |
| `create_issue { links: "1,2" }` | *(argument removed)* one `link_issue { a, b, kind }` per entry |
| `update_issue { links: "3" }` | *(argument removed)* one `remove_relationship { id }` per existing edge, then one `link_issue` per new one |

The argument is now **rejected**, not ignored: passing it returns
`isError: true` naming `link_issue` and `remove_relationship`. It was silently
dropped for one release, which meant a caller got `isError: false`, believed the
relationships were stored, and found an empty `tix link list` with nothing in
between to explain it.

Note that `update_issue { links }` did **not** mean "add this one relationship".
It *replaced* the whole set, so the migration is a `remove_relationship` per
existing edge plus a `link_issue` per new one — `edit_relationship` is the wrong
tool here, because it retargets or re-kinds one *existing* relationship by id and
cannot create one.

There is **no default kind and no fallback**, so an untyped relationship is not
reachable by omission. Migrate by adding an explicit `kind`:

- an association that was merely "related" → `kind: "related"`
- a dependency where A must finish before B → `kind: "blocks"` on `a: A, b: B`
  (or `kind: "blocked_by"` on `a: B, b: A`; both store the same edge)

`blocked_by` is an **input-only** spelling that normalizes into the canonical
`blocks` edge, so no stored relationship ever carries it. A reciprocal pair
(`A blocks B` then `B blocks A`) is rejected as a cycle.

The same change applies to the CLI: `tix link <a> <b>` without `--type` is
refused, and `tix create`/`tix edit` no longer take `--links`.

### Reading relationships

`list_relationships` returns **every relationship touching an issue**, including
ones stored on the other endpoint — a relationship is stored once, in its
canonical source's frontmatter, so an issue's own `relationships` field is
partial. Rows are ordered by `(kind, target)`, so the output is byte-comparable
across runs and against `tix link list`.

Structured relationship rows expose `from` and `to` as canonical issue ids.
Their `display` field and the tools' rendered text retain the human `#number`
aliases, so machine consumers compare identity while people read aliases.

`--kind blocked_by` (the MCP `kind` argument) filters on the **canonical**
blocking edge, i.e. edges whose stored form is `blocks`.

`issue_blocked_state` returns the same `reason` string the CLI move warning, the
TUI confirmation prompt, the board card and the detail view all print — it is
produced by one shared function, so the surfaces cannot word it differently.

### Reading and writing the issue hierarchy

`create_issue` and `update_issue` take a `parent` argument, a **selector** — a
number, a canonical id, a filename fragment, or a unique title fragment, as
everywhere a selector is taken. It goes through the same hierarchy service the
CLI does, so the validation and the resulting History entry are identical:

- `parent: "3"` sets the parent to `#3`.
- `parent: "A1B2C3D4"` selects the same issue directly when number aliases
  collide. The stored `parent:` value is the canonical id.
- `parent: ""` **clears** it, naming no issue. Omitting the argument leaves it
  alone, which is a different thing: an absent argument is not "set none".
- A self-parent, a cycle, or a selector that names nothing is refused, with the
  service's own diagnostic, and the file is left byte-identical.

`list_children`, `get_parent` and `get_rollup` read the same three derived
values `tix parent children` and `tix parent show` print. Each returns **both**
the CLI's exact text and the structured values:

- `list_children` — direct children only, never grandchildren, ordered by issue
  number, each as `(id, number, display)`.
- `get_parent` — the resolved parent's canonical id in `parent`, its number alias
  in `parent_number`, or `null`. When the recorded parent no longer exists,
  `recorded_parent` keeps the canonical reference (it survives so it can be
  repaired), `recorded_parent_number` is its legacy alias when known, and
  `unresolved.reason` carries the service's wording, so a client cannot spell
  that condition its own way.
- `get_rollup` — `direct_children`, `completed_children` and `progress` **as
  numbers**, alongside `completion_basis`, which says what population the figures
  describe. The counts are numbers rather than only rendered text so a client can
  compare them without parsing prose; "3/5 over direct children" and "3/5 over
  the subtree" are different numbers wearing the same clothes.

---

## Triage: the inbox and its decisions

The triage inbox is the review queue that unreviewed work sits in before it
joins the normal workflow. It is **opt-in**: a `[triage]` section names the
normal board columns that make up the queue.

```toml
[triage]
entry_status = "triage"      # required: the column that places an issue in the inbox
declined_status = "wontfix"  # required: the column a decline moves to (must map to `canceled`)
# accepted_status = "todo"   # optional: the default target for an accept
# deferred_status = "triage" # optional: defaults to entry_status
```

A project with no `[triage]` section has no inbox, and every triage tool refuses
with the CLI's own sentence for the command it corresponds to — `triage_list` and
`triage_status` with `tix triage list`/`status`'s, a decision with `tix triage
batch`'s. Both are compared against the CLI's output in the test suite rather than
intended, and an *invalid* `[triage]` section reports the validator's message
naming the offending column rather than being flattened into the missing one.

**Reading.** `triage_list` and `triage_status` return the CLI's rendered text
verbatim in `content[0].text` (`tix triage list`, `tix triage status`) plus the
same snapshot as structured values: `counts` (`total`, `awaiting`, `deferred`,
`accepted`, `declined`, `duplicated`, `unknown_age`, and the `age` histogram) and
`entries` (per issue: `number`, `title`, `status`, `age_days`, `entered_at`,
`source`, `last_decision`). `age: true` sorts oldest first and an entry with no
readable entry stamp sorts **last** and reports `age_days: null` — an unknown age
is never reported as `0` or `today`. `count: true` returns the counts without the
rows, in both the text and the structure. Both tools are read-only: no file, no
History entry.

`last_decision` has three values: the decision object when the newest triage
`## History` line parses, `null` when the issue has no triage decision, and the
string `"unreadable"` when a decision line exists but its detail does not parse.
The third is a diagnostic, not a synonym for the second — "nobody has decided
this" and "this file carries a decision I cannot read" are different answers.

**Deciding.** `triage_preview` and `triage_apply` take the same arguments and go
through the same plan:

| Argument   | Meaning |
|---|---|
| `issue`    | One issue: a number, a filename fragment, or a unique title fragment. |
| `select`   | Comma-separated selectors. Exactly one of `issue`, `select`, `filter` may be named. |
| `filter`   | A `tix list --filter` query, e.g. `status:triage`. |
| `decision` | `accept` \| `decline` \| `duplicate` \| `defer`. Required. |
| `reason`   | Recorded on the History event. Required for `decline`, `duplicate` and `defer`; a missing one is a **refused item** naming `missing_reason`, not an error. |
| `actor`    | Who decided. Defaults to the git identity. An empty string is refused as `missing_actor` rather than silently replaced. |
| `to`       | The accept target column, validated by the board's own transition policy. With no `to` the configured `accepted_status` is used; with neither, the item is refused rather than guessed. Naming `to` on a decision that has no target column is **refused, not ignored** — as `tix triage batch --decision decline --to …` refuses it — so a client cannot believe it named a column that was silently dropped. |
| `of`       | The duplicate target selector. Creates a typed `duplicates` relation; **files are never merged**. Sending `of` on a decision that has no duplicate target is refused the same way `to` is. |
| `confirm`  | Required on `triage_apply` and must be `true`. Never present on `triage_preview`. |

A single issue and a selection are the same tool because they are the same plan:
one issue is a selection of one, and both report the same per-item statuses
(`applied`, `no-op`, `rejected`, `failed`) that `bulk_preview`/`bulk_apply`
report. `triage_preview` writes nothing at all — the plan, the proposed field
changes and the `history` event it would append are the whole response.

`triage_apply` processes each selected issue independently and does **not** roll
back the ones that succeeded: the response's `applied`, `no_op`, `rejected` and
`failed` counts, and one `outcomes` row per selected issue, are the report. A
decline moves the issue to the configured declined column and records the reason;
it never deletes or archives anything.

**Errors.** A malformed argument is a JSON-RPC `-32602` (as everywhere else on
this surface). A well-formed request that cannot be carried out is a tool result
with `isError: true` and the CLI's own wording — an unknown decision, a selector
that matches nothing or matches twice, `of` on a decision that has no target, a
`[triage]` project with no section. A refusal that belongs to *one* selected item
is not an error at all: it is that item's `rejected` status and its `diagnostic`,
so one bad item leaves the rest previewable and applicable.

**Boundaries.** The remote tools are unchanged: `import_issues` and
`export_issues` keep their meanings, and the local triage policy for imported
issues (`tix import --triage-policy`) is a CLI flag, not an MCP argument. There is
no MCP tool that deletes, archives or merges anything.

---

## Saved views

A saved view is a **named, repository-owned query definition** with a stable
identity: a filter/sort/group/board-visibility plan, explicit scope metadata, and
preserved unknown fields. It lives in the repository's own `tix.toml`, under
`[views.<id>]`. There is no database, no hosted view service, and no second query
language: `filter` is the same token query `tix list --filter` accepts, resolved
by the same grammar, and a plan is stored in the same shape the CLI's `tix view`
writes.

Nine tools mirror `tix view`:

- `view_list` — the registry in declaration order, each entry with its normalized
  plan, scope, resolution, preserved extensions, and diagnostics. Read-only.
- `view_show` — one view by stable id, or by display name when it is unambiguous.
- `view_create` — save a new view from the existing grammar; the stable id
  (`v-…`) is generated and never reused, and `name` is only a display label.
- `view_update` — replace the named fields, keeping the stable id (identity
  survives a rename). Merge semantics: an unnamed field is carried over, so a
  rename cannot drop the plan. A same-content update is a `no-op` and issues no
  commit.
- `view_duplicate` — copy under a new stable id, carrying scope, plan, and
  preserved extensions verbatim.
- `view_delete` — remove the **definition only**, and only with `confirm: true`.
  It never deletes an issue, comment, or relationship.
- `view_share` — replace scope metadata (repository, project, or user).
- `view_apply` — apply read-only: the normalized plan, the applied issue numbers
  in board order, a board summary, the active marker, and diagnostics.
- `view_status` — the saved plan against a live plan assembled from the plan
  arguments, field by field.

**Structured outcomes.** A mutation answers with a `status` — `accepted`,
`no-op`, `rejected`, or `failed` — an `operation`, the resulting record, and the
one `audit` event when a change was committed. A rejection is `isError: true`
with the CLI's exact wording *and* a structured `diagnostics` array
(`kind`, `field`, `message`, `remedy`, `view_id`), so a client can act on the
field rather than parse prose: an unsupported `sort` is `invalid_sort` at
`plan.sort`, an inconsistent scope is `scope_conflict` at `scope.project_id`. A
rejected operation writes no `tix.toml` and stores no view.

**Strict arguments.** Every view tool declares a closed schema
(`additionalProperties: false`) and enforces it: an undeclared key is refused
with a field diagnostic naming it, rather than silently ignored. Host identity,
permissions, hosted sharing, and executable query payloads are not saved-view
fields — a request carrying them is rejected (a view is repository data, C-004).

**Read-only apply.** `view_apply` borrows the corpus, the config, and the board
and returns derived copies; no issue, comment, relationship, or `tix.toml` byte
changes. The active marker it reports is informational and never overwrites live
filter state, and `view_status` reports divergence without reconciling anything.

**Scope is metadata.** `repository` is the default; `project` names a project id
this repository knows; `user` stores owner/shared-with labels. None of them is an
account or a permission, and no tool infers sharing from who is calling.

**Session freshness.** The server bootstraps one config at startup, but every
view call reloads `tix.toml` from the repository. That is what makes a
`view_create` visible to the next `view_list` in the same session, and it means
the MCP surface and `tix view` always read and write the one registry on disk.

---

## Templates: the stable-ID registry

Templates live in `tix.toml` under `[issue_templates.<id>]`, keyed by an
**immutable ID** with a mutable display `name`. The ID is the handle; a rename
changes only the label, so defaults, prompts, references and any preserved unknown
metadata survive it (C-002). These tools are the MCP spelling of `tix template …`
and read and write the same registry.

**Reading.** `list_templates` returns one entry per stored template, and
`get_template` returns one by stable ID (or by a display name that names exactly
one template — an ambiguous name is refused rather than guessed). Each entry is
`{ id, name, scope, defaults, prompts, references, extensions }`, and the
`content` text is the CLI's own `tix template list`/`show` output, byte for byte.

**Managing.** `create_template`, `update_template`, `duplicate_template` and
`delete_template` map one-for-one to the `tix template` subcommands. Every result
carries a structured outcome:

| `status` | Meaning |
|---|---|
| `accepted` | The registry changed and `tix.toml` was saved atomically. |
| `no-op` | A same-content update: nothing was written and no audit event exists. |
| `rejected` | The registry declined the change; nothing was written. `diagnostics` names the field. |
| `failed` | The definition was valid but the atomic save failed; `tix.toml` is unchanged. |

`accepted` and `no-op` are successful tool results; `rejected` and `failed` are
`isError: true` **and** carry the same structured outcome, so a client can branch
on `status` rather than parse the sentence. `diagnostics` is a list of
`{ kind, field, message }`: a malformed payload is `invalid-payload` with the exact
field (`defaults`, `prompts[1]`, `scope`), and a service refusal carries the
registry key it names (`issue_templates.<id>.<field>`).

**Definitions are data.** `defaults` accepts normal create fields only; `prompts`
are `{ key, kind, required?, default?, options_ref? }` with a closed `kind`
vocabulary (`text | select | multi-select | date`) and a closed `options_ref`
vocabulary; `references` are typed
(`label | assignee | file | cycle | milestone | relationship`). None of it is
executable, and an unknown key is refused rather than stored. Unknown metadata
already present in a template file round-trips untouched and is reported under
`extensions` — never interpreted as a command, prompt or issue field.

**Creating from a template.** `create_issue` with a `template` argument (and an
optional `scope`) resolves the template with the same pure resolver the CLI uses
and creates through the same shared core — there is no second writer. A resolution
failure is `isError: true` with `status: "rejected"`, one diagnostic per missing
required prompt or unknown reference, the CLI's own wording as the text, and **no
issue file**: a failed prompt never creates a partial issue. On success the created
issue carries `template: { id, scope }` as provenance. Without a `template`,
`create_issue` behaves exactly as it did before.

**Boundary.** No template tool adds a hosted service, a database, implicit scope
inheritance, executable template logic, or a second issue-creation path.

---

## Recurring: series, due warnings and lifecycle

A **series** is an issue whose `series` frontmatter states a rule; its
**occurrences** are issues generated from it. Identity is the immutable
`series.id`, never the display name, and every date is a canonical
`YYYY-MM-DD` key: an occurrence is `<series-id>@<YYYY-MM-DD>`. The ten tools
below are the MCP spelling of `tix recurring …`, over the same
resolver/detector/generator/lifecycle the CLI and the board use — MCP owns no
scheduler, no rule parser, no series writer and no second clock (C-008).

**Reading.** `list_recurring_series`, `get_recurring_series` and
`detect_recurring_due` are read-only, and detection is non-mutating by contract:
it creates no issue, appends no History entry and changes no series, so the
board's start/refresh warning and a `detect_recurring_due` call are the same
detector's answer. `series` is the stable ID, or a display name that names
exactly one series — an ambiguous name is refused rather than guessed.

**Dates are explicit.** `detect_recurring_due` takes `date` (`YYYY-MM-DD`) and an
optional `timezone`; omitted, the boundary resolves today, which is why passing
`date` is what makes an answer reproducible. `timezone` is carried on the context
(fixed offsets like `+01:00` and `UTC` are the useful values), exactly as
`tix recurring detect --timezone` carries it. A series **rule** that stores a named
IANA zone is the other case and is refused by the shared validator
(`invalid_series_rule`: only fixed UTC offsets are supported, because a
daylight-saving transition needs a tzdb to resolve). The mutation
tools that are about one date (`generate`, `skip`, `defer`, `ignore`) **require**
`date`; the series-level tools (`pause`, `resume`, `end`) refuse one instead of
ignoring it, so a caller that meant something else finds out.

**Mutations require `confirm: true`**, exactly as the CLI requires `--yes`. There
is one gate and it is the service's: an unconfirmed mutation is refused with the
`not_confirmed` diagnostic before the store is touched — the same refusal, word
for word, that `tix recurring …` without `--yes` prints — so adding a second
check here could only drift from it. `actor` is recorded in the History entry and
defaults to the boundary's identity (the git identity); `reason` is recorded
verbatim where one is given.

**Outcomes are shared**, so the same words mean the same thing on every surface:

| `status` | Meaning |
|---|---|
| `created` | The occurrence was created, linked and recorded in one transaction; `issue_number` and `filename` name it. |
| `applied` | The lifecycle action took effect and one History entry was appended; `history.kind` is `transition_accepted`. |
| `no-op` | The key was already taken, or the action was already in effect: nothing was written and no History entry was appended. |
| `rejected` | The service declined; nothing was written. `diagnostics` carries `{ kind, message }` per reason. |

`created`, `applied` and `no-op` are successful tool results. `rejected` is
`isError: true` **and** carries the same structured outcome beside the CLI's
rendered refusal as its text, so a client can branch on `status` rather than
parse the sentence. Repeated generation is idempotent: a second call for the same
key is a `no-op`, never a duplicate issue.

---

## Cycle schedules: repeating cycles and lifecycle

A **cycle schedule** is a `[cycle_schedules.<id>]` entry in `tix.toml`: a stable
`id` (the registry key, never reused) with a mutable display `name`, a cadence, a
duration, an anchor date, a fixed UTC offset and a look-ahead. The cycle instances
it generates are ordinary cycle records (`docs/cycles/*.md`) carrying
`schedule_id`/`sequence`/`state`, so they are visible to `tix cycle list` and to
every reference that reads a cycle name. The sixteen tools below are the MCP
spelling of `tix cycle schedule …`, over the same record, the same read model
(`ScheduleIndex`), the same detector, the same generator and the same lifecycle
the CLI and the TUI use — MCP owns no cadence arithmetic, no cursor, no snapshot
writer and no History grammar (C-008).

**Reading.** `cycle_schedule_list` and `cycle_schedule_show` are read-only, and
`cycle_schedule_detect` is non-mutating by contract: it creates no cycle, moves no
cursor, writes no file and appends no History, so the board's refresh warning and
a `cycle_schedule_detect` call are the same evaluator's answer. `schedule` is the
stable id, or a display name that names exactly one schedule — an ambiguous name is
refused rather than guessed. `cycle_snapshot_list` reads the immutable snapshots a
close captured; they are evidence, never recomputed from the live schedule.

**Dates are explicit.** `cycle_schedule_detect` takes `date` (`YYYY-MM-DD`) and an
optional `timezone`; omitted, the boundary resolves today (there is no clock inside
the domain), which is why passing `date` is what makes an answer reproducible. The
`timezone` rides on the shared evaluation context, and 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.
A lifecycle action records an explicit instant: `timestamp` defaults to the same
boundary clock the CLI resolves it from, and an explicit value makes the record
reproducible. Generation is anchored at the schedule's own dates, so `date` is the
as-of context it records, never something that derives an instance.

**Mutations require `confirm: true`**, exactly as `tix cycle schedule …` requires
`--confirm`: an unconfirmed mutation is refused with the `not_confirmed` diagnostic
and writes nothing (C-004). The `create`/`edit`/`delete` registry verbs are
validated as a whole before the atomic save, so a refused change leaves `tix.toml`
exactly as it was; a schedule `id` is never reused (`exists` for a live id, and
`id_spent` when a stored cycle record **or** a closed-cycle snapshot still claims
it — both holders are deliberately left on disk by a delete, so the refusal names
whichever one remains and the way out is to delete it too, C-003/C-007).

**Instance selectors.** The lifecycle tools name an instance with `cycle`
(`<schedule-id>:<sequence>`, e.g. `cs-sprint:000001`) or with `from` (its start
date); a `from` that matches no instance is a refusal, not a no-op. `reason` is
required by the actions that change what a cycle *is* (`close`, `reopen`, `extend`,
`skip`, `adjust-future`) and the service refuses a missing one by name. `reopen` is
`close`'s inverse but deliberately not its mirror: it restores the record to
`active` and appends one `cycle_reopened` event naming the snapshot the close
captured, and it writes **no** snapshot — a closed cycle's capacity evidence is
immutable (C-008/NFR-002), so the prior snapshot's bytes and its `snapshot_id` are
untouched and stay readable.

**Outcomes are shared**, so the same words mean the same thing on every surface:

| `status` | Meaning |
|---|---|
| `created` | A generation candidate became an instance; `cycle` names it, and the cursor advanced. |
| `applied` | The lifecycle action took effect and one History entry was appended; `snapshot_path` is present on a close, `dates` on a window move. |
| `no-op` | The postcondition already held — the instance was already closed, the key already taken, the state already current — so nothing was written. |
| `rejected` | The service declined; nothing was written. `diagnostics` carries `{ kind, field, message }` per reason, in the service's own vocabulary. |

`created`, `applied` and `no-op` are successful tool results. `rejected` is
`isError: true` **and** carries the same structured outcome beside the CLI's
rendered refusal as its text. Detection reports the evaluator's own warning kinds
(`ended`, `due`, `look-ahead`, `failure`) and, for each warning, the explicit
actions it accepts — the decisions a client may then take with a lifecycle tool.

---

## Capacity: scopes, calculations, and close evidence

Capacity is what a cycle, a team or an assignee is allowed to commit to, counted
in an estimate unit the repository already declares. The four tools below are the
MCP spelling of `tix capacity …`, over the **same shared library** the CLI adapter
calls — `CapacityScope` folds a scope reference, the one calculation entry point
derives the values, and the immutable snapshot reader reads the close evidence.
MCP owns no formula, no rounding policy, no conversion and no snapshot writer, so
a number here is a field of the same DTO the CLI prints (C-007/NFR-003).

**A scope is a name *or* its stable id.** `capacity_show` and `capacity_calculate`
fold the two spellings through the same adapter the CLI uses (`sprint:000001` and
`Weekly sprint 2026-01-05 000001` select one scope), which is the property a
generated instance's capacity depends on (C-003).

**Values, and what "absent" means.** Every derived field is present only when the
calculation could run. `state` says which case applies — `configured`,
`uncountable` (a ceiling is in force but the snapshot resolved no conversion for
its unit) or `unconfigured` (no ceiling is in force). A fresh CLI or MCP read always
builds the snapshot with every configured ceiling unit, so `uncountable` is the
defensive result only when a caller pairs an older snapshot with a later
configuration the snapshot did not see. In the text a missing value
is `-`; in the structure it is `null`. It is **never** `0`: an unconfigured scope
is not a scope with zero capacity, and a missing conversion is not a zero total
(C-004). `capacity_snapshot`'s `evidence` is `present` or `none`, the latter for a
snapshot written before this mission — a value, never an error.

**Diagnostics are shared and actionable.** Each finding carries a machine-readable
`kind` (`capacity_overflow`, `missing_conversion`, `unknown_scope`, …), the scope,
the numbers, the unit, the issues, and both a `reason` and a `remedy` (C-006/#1643).
The overflow report never moves, resizes or rewrites an issue or a cycle — it is a
report. The text mirrors the CLI's whole 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, so nothing is lost
without the flag.

**Snapshots are immutable evidence, and the History is the audit trail.** A close
captures the capacity payload once; `capacity_snapshot` returns that stored payload
(never a recomputation from the live schedule) together with the cycle record's
`history` summaries — the `cycle_closed` and `cycle_reopened` entries the lifecycle
appends, oldest first. A reopen appends one History event and leaves the prior
snapshot byte-identical (C-008/NFR-002). Those are the complete capacity-affecting
History kinds in this build.

**Boundary: the configuration tools are read-only.** `capacity_list` and
`capacity_show` read the `[capacity]` section; they never write it. Capacity
configuration is a validated `tix.toml` change with exactly one writer — the CLI's
`tix capacity set|delete` — so an MCP write spelling would be the second writer
C-008 prohibits. An agent configures through the same command a human does and
reads the result here. No capacity tool on any read path saves a config, writes a
record, or appends a History event.

---

## Work-in-progress limits

A board may cap what each column holds. The limits live in `tix.toml` under
`[kanban.<board>.wip]` (units, then per-column ceilings and a `warn`/`block`
policy) and are edited with `tix wip set|unset|unit`. Nothing here is
MCP-specific: `move_issue` asks the **one** policy service the CLI `tix move` and
the TUI's keyboard, context menu and drag all ask, so a limit cannot be walked
around by calling a different tool (C-006).

**Reading.** `board_status` renders the same table `tix status` does, including
each column's usage (`4/3 8/10d!`) when the board configures a limit. Its
`structuredContent` **is** the document `tix status --json` prints — both come
from one evaluation, so a script reading the CLI or a client reading the tool
sees the same columns, order and total.

**Moving.** Every `move_issue` result carries a `wip` object — `kind`, `from`,
`to`, the limit, before/after state, diagnostics, and the `history` entry when an
override was applied:

| `wip.kind` | What happened | May the client assume the move happened? |
|---|---|---|
| `allow` | within every limit, or the column has none | yes |
| `warn` | over a limit configured to warn | yes — the decision is reported, not enforced |
| `block` | over a limit configured to block, no override | **no** — nothing was written |
| `override` | over a blocking limit, carried by an authorized override | yes — one History entry was appended |
| `rejected` | the estimate could not be counted, or the override was incomplete or stale | **no** — nothing was written |

A `block` or a `rejected` decision is an `isError: true` result that **still**
carries the structured `wip` object, so a client can branch on `wip.kind` instead
of parsing the sentence. An override is not a flag: `override: true` alone leaves
the move refused, and so does `override` with a `reason` but no `yes` — the
confirmation is the caller's (`unconfirmed_override`). The override also binds the
limit it was authorized against, so a limit that changed in between is
`stale_override_limit` rather than a silent authorization.

An issue whose `estimate` cannot be counted in the limit's unit (not
`<number><unit>`, an undeclared unit, or a conversion that does not divide evenly)
**refuses** the move rather than counting as zero — `missing_conversion`,
`malformed_estimate` or `ambiguous_conversion`, naming the issue and the value.

**Without limits.** A board that configures none behaves exactly as before: the
move succeeds, `wip.kind` is `allow`, `wip.limit` is `null`, and no refusal, event
or message that mentions a limit can occur.

---

## Projects: association, membership and health

A project is a **delivery container**: a record in `tix.toml`'s `[projects.<id>]`
registry with a mutable display name, and an issue may belong to several. The
**identity is the stable id**; a name is a display label, so every project tool
resolves by id first and accepts a name as a convenience that resolves to the same
project — a rename therefore breaks no association. Every listing carries the id
beside the name, because without it a rename is indistinguishable from a new
project.

**Associating an issue.** `create_issue` takes `projects`, and `update_issue` takes
`projects`, `add_project` and `remove_project`, all comma-separated lists of ids or
names. The semantics are the CLI's, exactly:

| Argument | Semantics | CLI counterpart |
|---|---|---|
| `create_issue { projects }` | **Sets the full association set** for the new issue. Every reference is resolved through the project service *before* the issue is written, so an unknown one refuses the whole create with `unknown_project` and nothing is written; duplicates (including a name and its id) collapse to one association | `tix create --projects` |
| `update_issue { add_project }` | An **array**: each entry is one `--add-project` occurrence, taken **verbatim**. **Adds** that association. Idempotent: an association already present writes nothing and appends no History entry. A padded or blank entry is a reference that does not resolve — exactly as `--add-project " p-a "` / `--add-project ""` is refused by the CLI | `tix edit --add-project` |
| `update_issue { remove_project }` | An **array**, each entry taken verbatim. **Drops** each association, and only the association — never the issue. An entry that names a live project resolves to its id; one that resolves to nothing must be an id the issue already carries, which is the repair path for a stale reference | `tix edit --remove-project` |
| `update_issue { projects: "" }` | **Clears** every association. `projects` on an update *only clears*: a non-blank value is refused, because setting associations has one spelling (`add_project`), so a typo cannot silently replace the set | `tix edit --projects ""` |

`projects` is deliberately **not accepted beside `template`** on `create_issue`
(and is refused with the CLI's own wording), for the same reason the CLI refuses
the combination. Project references are resolved through `src/projects.rs`, so
identity, uniqueness and membership have one owner; the MCP layer re-derives none
of them. `projects` (creating or clearing) is a comma-separated list, matching the
`--projects` flag, while `add_project`/`remove_project` are arrays whose entries
stand for occurrences of the CLI's repeatable flags and reach the service
**verbatim** — so a padded or blank entry is refused on both surfaces rather than
quietly cleaned up here.

**The project tools.** `list_projects`, `get_project`, `create_project`,
`update_project` and `delete_project` mirror the `tix project` lifecycle verbs.
`create_project` returns the **allocated stable id**; `update_project` renames the
label without re-keying anything, and re-derives the member list so a registry-only
edit cannot report a member count of zero. `delete_project` removes the registry
entry **only** and returns the affected issues (the CLI's impact report) so an agent
can warn a user instead of orphaning work: the issues keep their references, which
then read as diagnosable `missing_project_reference` findings until repaired. No
issue is ever deleted, and there is no cascade. It requires `confirm: true`.

**Reading membership and health.** `project_membership` and `project_health` are
read-only: no file is written, no History entry is appended, and the registry is
untouched. Both return the **raw values** rather than a formatted summary, so two
surfaces can be compared by value:

- `project_membership`: `project_id`, `member_count`, and `members` — an ordered
  list (by issue number) of `{ issue, display }`, where `display` is the shared
  `#<number> <title>` label computed on read.
- `project_health`: `done`, `total`, `overdue`, `blocked`, `unassigned`, and
  `missing_references` (each with its issue, the recorded id and the service's
  reason). `done` counts members in the board's **configured** completion column;
  `overdue` and `blocked` are **consumed** from the derived reads that own them,
  never recomputed. The text mirror is the same line `tix show` and
  `tix list --show progress` render, from the one shared renderer.

The figures come from `crate::projects` — the one implementation — so CLI, TUI and
MCP report the same value for the same snapshot.

**Which snapshot each read uses**, because the CLI itself distinguishes two and a
read that picked the other one would report different numbers for the same
repository:

| Which reads | Issue set | Mirrors |
|---|---|---|
| the registry verbs — `list_projects`, `get_project` | every issue file, archived included | `tix project list` / `tix project show` |
| the figure reads — `project_membership`, `project_health`, `list_issues { project }` | the **active** set (archived issues excluded, as everywhere else in the listing and the detail view) | `tix show`'s project block and `tix list --show progress` |

So with an archived member, `get_project` lists it (as `tix project show` does)
while `project_membership` and `project_health` do not count it (as the figures
`tix show` prints do not).

`list_issues` takes `project` to restrict the listing to one project's members:
that matching set is the service's derived membership for the same active set, so
the filter and the membership read cannot disagree. Both resolve the project
against the registry **on disk**, re-read per call, so a project created (or
deleted) earlier in the same session resolves exactly as it does through
`list_projects` / `project_membership`.

`get_issue`'s text mirrors `tix view` including the `## Projects` block, so an
associated issue's document carries the same project lines (id, name, `done/total`,
overdue/blocked/unassigned, or the dangling reference's reason) that the CLI prints.

**Bulk association.** `project_associate_issues` and `project_dissociate_issues`
take a project and a comma-separated `issues` list, and report one outcome per
selector in input order. Each selector is processed independently: one rejection is
that entry's error and never aborts the rest, each accepted change appends exactly
one History entry, and an already-present (or already-absent) association is an
idempotent no-op. `project_associate_issues` resolves the project once up front, so
an unknown project refuses before any write, while `project_dissociate_issues`
falls back to the reference **verbatim** when it resolves to no live project — the
repair path for a stale stored id.

---

## Epics: references, hierarchy and progress

An epic is a **stable registry record**: an entry in `tix.toml`'s `[epics.<id>]`
table with a mutable display name, and an issue may reference several. The
**identity is the stable id**; a name is a display label, so every epic tool
resolves by id first and accepts a name as a convenience that resolves to the same
epic — a rename therefore breaks no reference. Every listing carries the id beside
the name, because without it a rename is indistinguishable from a new epic.

There is **no task entity** (C-001). A "task" is an ordinary issue that references
an epic, possibly carrying a `task` label. Referencing an epic does not change how
the issue itself is navigated, and no tool, argument or key named `task`,
`work_item` or `epic_task` exists anywhere in the surface.

**Referencing an epic from an issue.** `create_issue` takes `epics`, and
`update_issue` takes `epics`, `add_epic` and `remove_epic`. The semantics are the
CLI's (`tix create --epics`, `tix edit --add-epic/--remove-epic/--epics ""`),
exactly:

| Argument | Semantics | CLI counterpart |
|---|---|---|
| `create_issue { epics }` | **Sets the full reference set** for the new issue. Every reference is resolved through the epic service *before* the issue is written, so an unknown one refuses the whole create with `unknown_epic` and nothing is written; duplicates (including a name and its id) collapse to one reference | `tix create --epics` |
| `update_issue { add_epic }` | An **array**: each entry is one `--add-epic` occurrence, taken **verbatim**. **Adds** that reference. Idempotent: a reference already present writes nothing and appends no History entry. A padded or blank entry that resolves to nothing is refused with `unknown_epic`, exactly as the CLI refuses it | `tix edit --add-epic` |
| `update_issue { remove_epic }` | An **array**, each entry taken verbatim. **Drops** each reference, and only the reference — never the issue. An entry that names a live epic resolves to its id; one that resolves to nothing must be an id the issue already carries, which is the repair path for a stale reference | `tix edit --remove-epic` |
| `update_issue { epics: "" }` | **Clears** every reference. `epics` on an update *only clears*: a non-blank value is refused, because setting references has one spelling (`add_epic`), so a typo cannot silently replace the set | `tix edit --epics ""` |

`epics` is deliberately **not accepted beside `template`** on `create_issue` (and
is refused with the CLI's own wording). References are resolved through
`src/epics.rs` and written through the store's service-backed `reference_epic` /
`dereference_epic`, so identity, uniqueness and the History entry have one owner;
the MCP layer re-derives none of them.

**The epic tools.** `list_epics`, `get_epic`, `create_epic`, `update_epic` and
`delete_epic` mirror the `tix epic` lifecycle verbs. `create_epic` returns the
**allocated stable id**; `update_epic` renames the label without re-keying anything
and re-derives the members and children so a registry-only edit cannot report a
count of zero. A `parent` change is validated by the service's **iterative** epic
chain walk (self-parent and cycle) before any write, so a refusal leaves the
registry byte-unchanged. `delete_epic` removes the registry entry **only** and
returns the affected issues **and** the child epics (the CLI's impact report): the
issues keep their references, which then read as diagnosable `missing_epic_reference`
findings, and the child epics keep their recorded parent, which then reads as an
unresolved parent. Nothing is deleted or modified, and there is no cascade. It
requires `confirm: true`.

**Reading hierarchy, membership and progress.** `epic_hierarchy`, `epic_membership`
and `epic_health` are read-only: no file is written, no History entry is appended,
and the registry is untouched. All three return the **raw values** rather than a
formatted summary, so two surfaces can be compared by value (NFR-003):

- `epic_hierarchy`: `parent`, `children`, `ancestors` (root-first), `depth`, and
  the explicit `unresolved` record when a recorded parent no longer exists, in the
  service's order — plus the ordered `members` so an agent can open an ordinary
  issue directly.
- `epic_membership`: `epic_id`, `member_count`, and `members` — an ordered list (by
  issue number) of `{ issue, display }`, where `display` is the shared
  `#<number> <title>` label computed on read.
- `epic_health`: `done`, `total`, `child_count`, `progress`, `weighted_progress`
  (`own_weight`, `weighted_children`, `unweighted_children`, `done_weight`,
  `total_weight`, `percent`), `blocked` with its `blocked_members`, and
  `missing_references` (each with its holder, its reference **kind**, the `tix lint`
  diagnostic kind, the recorded target and the service's reason). `done` counts
  members in the board's **configured** completion column; `blocked` is
  **consumed** from the relationship service's derived read, never recomputed.

`get_epic` returns the record plus the same typed figures under `rollup`, and its
text is exactly `tix epic show`'s (`record_lines`), so an agent and a person read
the same characters.

**Which snapshot each read uses**, because the epic surface reads every issue file:

| Which reads | Issue set | Mirrors |
|---|---|---|
| the epic verbs — `list_epics`, `get_epic`, `epic_hierarchy`, `epic_membership`, `epic_health` | every issue file, archived included (the index), with the blocked state read over the active set | `tix epic list` / `show` / `navigate` |
| `list_issues { epic }` | the **active** set, as the listing itself is | the listing's own snapshot |

`list_issues` takes `epic` to restrict the listing to one epic's members: that
matching set is the service's derived membership restricted to the **active** set
the listing itself reads. `epic_membership` returns that same service membership
over **every** issue file (the epic verbs' snapshot, per the table above), so the
two agree except for archived members: an issue archived while it still references
an epic appears in the membership read but not in the filter, because the listing
never reads an archived issue. Both resolve the epic against the registry **on
disk**, re-read per call, so an epic created (or deleted) earlier in the same
session resolves exactly as it does through `list_epics`.

`list_epics` also returns the snapshot's **issue-held** unresolvable references in
`missing_references` (a reference to an epic that no longer exists, or an entry
that is not a well-formed epic id) as raw `{ holder, kind, diagnostic_kind,
target, reason }` entries. Together with the epic-held entries `epic_health`
returns, that covers all five reference kinds `resolve_references` diagnoses — the
same single pass every reference diagnostic is derived from, so no diagnostic is
re-derived here.

`get_issue`'s text mirrors `tix view` including the `## Epics` block, so an issue
that references an epic carries the same epic lines (id, name, progress, weighted
figure, parent, blocked members, or the dangling reference's reason) that the CLI
prints.

**Bulk reference.** `epic_associate_issues` and `epic_dissociate_issues` take an
epic and a comma-separated `issues` list, and report one outcome per selector in
input order. Each selector is processed independently: one rejection is that
entry's error and never aborts the rest, each accepted change appends exactly one
History entry, and an already-present (or already-absent) reference is an
idempotent no-op. `epic_associate_issues` resolves the epic once up front, so an
unknown epic refuses before any write, while `epic_dissociate_issues` falls back to
the reference **verbatim** when it resolves to no live epic — the repair path for a
stale stored id.

---

## Initiatives and the roadmap

An **initiative** is a registry record in `tix.toml` (`[initiatives.<id>]`): a
**stable id**, a mutable display name, optional outcome/owner/status/priority/
labels/target-date metadata, and explicit project/epic/milestone references. It is
a *projection over* the existing project, epic, milestone, planning and health
services — never a second metric authority, and never an implicit issue-ownership
change.

**Lifecycle.** `list_initiatives` and `get_initiative` are read-only (the text
mirrors `tix initiative list` / `tix initiative show`, and the id is always a
column of its own so a rename can never look like a new initiative);
`create_initiative` and `update_initiative` are the two field mutations,
`initiative_associate` and `initiative_dereference` record and drop one explicit
reference, and the confirm-guarded `delete_initiative` removes the registry entry
(and nothing else). Every mutation goes through the registry's **atomic
boundary**: a refusal leaves `tix.toml` byte-identical and appends no History.
`update_initiative { name }` changes the label only — the id, every association
and every derived value survive — and a blank optional field (`""`) clears it.

**References are shape-checked, never resolved.** A project id, an epic id or a
milestone name that is well-formed but names nothing is **stored** and stays
diagnosable; it is never silently dropped and never inferred from an owner or a
label. `initiative_associate` takes an explicit `kind`
(`project`/`epic`/`milestone`) and a `target`, and reports `changed`: an
already-recorded association (and a dereference of one that is not recorded) is an
idempotent no-op that writes nothing.

**Roadmap reads are a join, not a blend.** `roadmap_list` orders initiatives by
target date ascending then stable id (an **undated** initiative is the explicit
`undated` state and sorts last, never a blank or an invented period) and applies
the normalized filter. The filter arguments are the **same `RoadmapFilter` DTO**
the CLI builds, with the same field names — `period_from`, `period_to`, `owner`,
`status`, `labels`, `query` — so the two surfaces cannot drift into two query
languages. A malformed value (a non-`YYYY-MM-DD` date, an inverted period, a blank
owner/status/label) is refused with the filter's own named diagnostic *before* any
work is done; a well-formed value that names no initiative selects nothing (an
empty list, not an error). `roadmap_show` and `roadmap_summary` are the same
projection for one initiative; the text is the CLI's own `roadmap_row_lines`.

Each row (and each `roadmap_summary`) carries its metadata, its references with
their `resolution` (`resolved` / `missing` / `ambiguous`), the source-attributed
`summary`, and the ordered `diagnostics`. The summary is one **contribution** per
referenced source entity: the `<kind>:<id>` provenance, the owning service's own
typed value (`progress.value` is a project report or an epic rollup, exactly as
that service returned it), the shared health policy version, and the risk value.
There is **no blended percentage, score or average** anywhere — the only aggregate
is `knowable_contributions`, a count of contributions that report anything, and
`0` progress is a value, not an absence. A reference that does not resolve is
still a contribution (`resolution: "missing"`, no value, with the diagnostic that
says why), an initiative that references nothing has the explicit `empty` state,
and a state is never conflated with another. All roadmap and initiative reads are
**read-only**: no write and no History entry.

---

## Project and issue health

Health is **metadata**: the current value lives in an issue's frontmatter, every change
is an append-only History entry carrying an actor, a date and a rationale, and a scope's
derived value comes from what contributes to it under a versioned policy. It never
replaces a status category and never means "done".

**Reading.** `health_scope`, `health_rollup` and `health_history` all answer from the one
portable health document the CLI renders, and return the CLI's text verbatim beside the
structured fields — so an agent and a person reading `tix health show` see the same
characters. Each response carries the policy version and the vocabulary, because two
numbers are only comparable when they were derived under the same formula.

The four states are `unknown`, `on_track`, `needs_attention` and `at_risk`, and the
version is `health-v1`; "worst wins" over the last three, an empty scope is `unknown`,
and an **unresolvable reference** contributes `unknown` rather than a healthy value. A
legacy issue with no health history is an explicit `unknown` — never absent, never
healthy by default.

**Writing.** `health_record` goes through the same writer the CLI and the TUI use, so a
value recorded from any surface is the same value, in the same History entry. A missing
rationale, a state outside the vocabulary, or an override without `confirm: true` is
refused **having written nothing**. `board_status` takes an opt-in `health` flag and then
appends the same summary line `tix status --health` prints, with the same `health`
object in `structuredContent` that `tix status --json --health` prints; without it,
the response is byte-identical to what it was before health existed and no `health`
key is emitted.

**Remote targets.** Neither GitLab nor Linear is driven to carry a health field in this
build (no CLI verb is verified), so an export reports `unsupported_remote_field` naming
the field and the target — and the local value is never cleared by a remote that cannot
hold it.

## Time tracking

Time entries are **actual effort**, recorded against an issue. One entry is a
duration in whole minutes, an optional `YYYY-MM-DD` calendar date, an author and an
optional note, with a stable id; the current entries live in the issue's own
frontmatter and every correction and deletion appends one attributable History
event. That model is closed, and the schemas enforce it: there is **no timer**, no
start/stop pair, no timezone conversion, no overlap rule and no billing, payroll or
invoicing field. An argument that tries to be one (`timer`, `timezone`, `rate`, …)
is refused **by name** rather than ignored, because a client that believes it
started a timer is worse than a client that is told it cannot.

The six tools are the six `tix time` verbs, over the same shared DTOs the CLI
renders — so a number here and a number there are one number. (The tool *table* is
above, once, beside the CLI counterpart of each name; this is what each argument
means.)

- `time_show` (read) — `issue`, any selector. The current entries, then the issue's
  total.
- `time_history` (read) — `issue`. The current count, every deletion History
  retains, and the correction count.
- `time_total` (read) — `issue`, `assignee`, `from`, `to`, `audit`, `by`. The scope's
  total, and one block per group when `by` names a dimension.
- `time_add` (write) — `issue`, `minutes`, `author` required; `date`, `note`,
  `actor`, `timestamp`, `reason`.
- `time_edit` (write) — `issue`, `entry` required; `minutes`, `date` **or**
  `clear_date`, `note` **or** `clear_note`, `author`, `reason`, `actor`, `timestamp`.
- `time_delete` (write) — `issue`, `entry`, `confirm: true` required; `reason`,
  `actor`, `timestamp`.

**Absent is never zero.** A total carries an explicit `state`:

| `state` | Meaning |
|---|---|
| `recorded` | at least one entry was counted; `total_minutes` is its sum, and a genuine `0` is a recorded value |
| `nothing` | the scope selected no entry at all — a real answer, not an absence |
| `unknown` | the scope selected entries and no usable number came back (all malformed, or the period cannot place the valid ones because they carry no date) |

In the text a value the answer does not have is `-`, and in `structuredContent` it
is `null`; a recorded `0` is `0`. `malformed_count` and `undated_count` (with
`is_partial`) say whether the sum is the whole story, and every finding appears in
`diagnostics` with its `kind`, the `field`/`issue`/`entry_id` it belongs to, a
`reason` and a `remedy` — malformed entries are reported, never hidden, and never
block another issue's read.

Two states are **refusals, not answers**: an invalid filter (`invalid_filter`) and an
issue the snapshot does not hold (`unknown_issue`). They return `isError: true` with
the CLI's own rendered block — the same bytes `tix time total` writes to stderr, exit
code and all — because no entry was evaluated, so there is no total to report. An
unresolvable selector, an unknown `--by` dimension and a delete without
`confirm: true` refuse the same way, in the CLI's words.

**Writes.** Every mutation goes through the same shared service the CLI and the TUI
call: `time_add`/`time_edit`/`time_delete` own no writer, validate nothing
themselves, and touch no field but the entry and `updated` — never ownership, status
or estimate. A correction requires a `reason` and a deletion requires a `reason`
**and** `confirm: true`; both are enforced by the service (missing one is refused
with `missing_reason`, having written nothing) and the confirmation is checked before
the store is read. Each applied mutation appends **exactly one** History event, which
the response returns as `history`; a request that asks for exactly what is already
there is an explicit `unchanged` with `history: null` and no write.

## Burndown and scope analytics

The four tools mirror `tix analytics`, over the same shared series, coverage,
projection and chart-document services the CLI and the TUI read — so a number
here and a number there are one number, and MCP folds nothing of its own.

- `analytics_metadata` (read, **no CLI counterpart**) — no arguments. The closed
  vocabularies and rules a caller needs *before* it folds anything: `scope`
  kinds, every mode with its formula version, precision and unit rule, 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. Each entry is the shared
  vocabulary's own, so the answer cannot list a name the services do not
  produce.
- `analytics_burndown` (read) — `scope`, `id`; `mode`, `unit`, `from`, `to`.
  The daily series.
- `analytics_projection` (read) — `scope`, `id`, `as_of`; `mode`, `unit`,
  `from`, `to`.
- `analytics_export` (read) — `scope`, `id`, `mode`; `unit`, `from`, `to`,
  `as_of`.

**The text is the CLI's stdout, byte for byte.** `content` is what
`tix analytics burndown` / `projection` / `export` prints on stdout, and
`structuredContent` is the same chart document the text was rendered from — the
one typed shape every surface consumes. `analytics_export`'s text is that
document's own pretty JSON. The CLI's findings go to **stderr** in the CLI and
are carried in the document's `diagnostics` here, with the text's
`diagnostics: N` line counting the same findings.

**A value the record cannot support is never a `0`.** Every number is nullable:
an unobserved day has no `scope_added` and no `completions`, an unanswerable
level has no `remaining`, and an unprojected completion date and an undecided
`meets_end_date` are `null` (the text renders `-`). Beside them the day's
`coverage` says `known`, `unknown` or `coverage_limited`, the document's
`coverage` tally counts the days in each state, and the projection's `status`
says whether it could answer at all. A genuine `0` — an observed day with no
completion — is a real value and stays `0`; the coverage marker is what tells the
two apart.

**A refusal is not an answer.** A request that cannot be carried out at all —
an unknown `scope` kind or `mode`, a malformed date, `from` after `to`, estimate
mode with no `unit`, a forecast with no planning end date, `analytics_export`
without `mode` — returns `isError: true` with the CLI's own rendered block: the
same bytes `tix analytics …` writes to stderr, exit code and all, because no
report was produced. So does a fold that answers no day at all (an unenumerable
period, or a unit the project declares no conversion for) — an unreadable series
is refused rather than printed empty, while `analytics_export` still returns the
same document with its findings in band, exactly as `tix analytics export` does.

**Local only.** There is no hosted analytics service: a call naming one —
`source`, `target` or `remote` — is refused **by name** rather than ignored,
because a configured `gitlab`/`linear` target is a remote *export mapping* that
cannot carry a chart document (the document reports it itself, as an
`unsupported_remote_field` finding), not a source of burndown data. Nothing here
writes: no issue, cycle, milestone or snapshot is opened for writing, and the
CLI's own `--output` writer is not reachable as an argument.

## Prerequisites

- The `tix` binary. Build it (`cargo build --release`) and either install it on
  `PATH` or reference the absolute binary path in the harness config.
- A repository with a `tix.toml` (create one with `tix init`). The server fails
  fast if the cwd is not inside a repository.
- Map to the **right repository**: the server operates on the worktree it is
  launched in. With harnesses that spawn from the project root (Claude Code,
  Codex, opencode with `cwd` set), the server manages that project's issues.

## Smoke test (no harness)

```bash
cd /path/to/repo          # must contain tix.toml
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
  tix mcp
```

---

## Integration by harness

### Claude Code

Add to the project's `.mcp.json` (checked into the repo) or run
`claude mcp add tix -- tix mcp` for a personal, machine-scoped entry. Claude
Code spawns server processes with the project directory as cwd, so the server
picks up the repo automatically.

```json
{
  "mcpServers": {
    "tix": {
      "command": "/path/to/tix",
      "args": ["mcp"]
    }
  }
}
```

Restart (or `/mcp` reconnect) after adding. The agent can then call the 118 tools;
`create_issue`, `list_issues`, `get_issue` and `board_status` are the natural
first four an agent will use when asked to "track this as an issue".

### OpenAI Codex CLI

Two equivalent options:

```bash
# 1. CLI (writes the table for you)
codex mcp add tix -- /path/to/tix mcp
```

```toml
# 2. ~/.codex/config.toml (or project-scoped .codex/config.toml)
[mcp_servers.tix]
command = "/path/to/tix"
args = ["mcp"]
```

Notes specific to Codex:

- The table key is `mcp_servers` (underscore) — TOML, not JSON. A wrong key is
  silently ignored.
- The server launches from Codex's working directory. If you want a different
  repo, set `cwd = "/path/to/repo"`. For trusted project configs, add
  `cwd = "${workspace}"` semantics are not built in; pin the absolute path.
- Raise `startup_timeout_sec` only if needed — `tix` is a native binary, so the
  default 10 s is ample.
- Check with `codex mcp list`; inside the TUI, `/mcp` shows connected tools.

### opencode

Add a **local** MCP server to `opencode.json(c)` (project) or the global
`~/.config/opencode/opencode.json`:

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "tix": {
      "type": "local",
      "command": ["/path/to/tix", "mcp"],
      "enabled": true
    }
  }
}
```

Optional keys: `cwd` (working directory for the server process; relative paths
resolve from the workspace) and `environment` (extra env vars). If `tix` is on
`PATH`, `["tix", "mcp"]` suffices. Use it in prompts by referencing the server
name, e.g. "use the tix tool to file this as an issue". MCP tools are prefixed
`tix_*`; disable/enable via the `tools` config if desired.

### Pi (pi.dev / pi agent)

Pi reads the standard MCP shapes in addition to its own override files, so the
universal `.mcp.json` also works:

```json
{
  "mcpServers": {
    "tix": {
      "command": "/path/to/tix",
      "args": ["mcp"]
    }
  }
}
```

Where to put it (highest precedence first):

- **Project**: `.pi/mcp.json` — Pi-specific project override.
- **Project shared**: `.mcp.json` at the repo root — used immediately.
- **User global**: `~/.pi/agent/mcp.json`, or `~/.config/mcp/mcp.json`.

A stdio server entry may set `"transport": "stdio"`, `"lifecycle": "eager"`
(auto-start) or `"lazy"` (start via `/mcp:start`), and `"env"` for extra
environment variables. Prefer `"lifecycle": "eager"` if you always want the
tools available. Check connectivity with `/mcp`.

### Generic / spec-compliant clients

`tix mcp` is a standard MCP stdio server (newline-delimited JSON-RPC 2.0), so
any conforming client works. Use the same registration pattern:

```
server name  : tix
command      : <tix binary> mcp
```

---

## Repository resolution

`tix mcp` resolves the repository **once**, at startup, using upward
`tix.toml` discovery from the process cwd (`cli::repo_config` → `Config::project_root`):

- A `tix.toml` is found in a parent directory of cwd → that is the issue store.
- No `tix.toml` is reachable → exit `1` with `no tix.toml found — run "tix init"
  first` on stderr, and **no** bytes on stdout.

Implication: the server manages the repo it is spawned in. Harnesses that run
from the project root give each project its own issue store automatically; a
single global server config only makes sense for one repo at a time.

---

## Troubleshooting

| Symptom                            | Cause / fix                                                    |
|------------------------------------|----------------------------------------------------------------|
| Server exits `1`, stderr says `no tix.toml found` | cwd is not inside a repo, or the harness set a different `cwd`. Run `tix init` (or point `cwd` at the repo root). |
| No tools appear in the agent       | Wrong config file/shape — e.g. Codex wants TOML `[mcp_servers.tix]`, not JSON `mcpServers`; opencode wants `"type": "local"` under its own `mcp` key. |
| `-32602` error on a call           | Argument type mismatch (e.g. a boolean where a string is expected) or a missing `name` in `tools/call`. Consult the tool's `inputSchema`. |
| `isError: true` with CLI-like text | A domain failure (unknown selector/column/priority, missing external CLI, delete without `confirm`). The text tells you exactly what is wrong. |
| stdout looks polluted / non-JSON   | Some other process is writing to the server's stdout. tix itself never does. |

## Reference

- Wire protocol: `kitty-specs/tix-mcp-server-01M2G3FX/contracts/mcp-protocol.md`
- Tool definitions: `kitty-specs/tix-mcp-server-01M2G3FX/contracts/mcp-tools.md`
- Source: `src/mcp/{mod,protocol,tools}.rs`, bootstrap in `src/cli.rs` (`run_mcp`)
- Integration tests: `tests/mcp.rs`
- Frame the MCP board work in its tracked workflow: `docs/spec-kitty-integration.md`
  and `docs/openspec-integration.md` cover the `tix link`/`--files` mirroring convention
  and the `tix init-agents` stdout detection lines.