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
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
//! `tix init-agents`: write an AI-usage section into `AGENTS.md` so that
//! autonomous agents in this repo know the exact CLI syntax.
//!
//! The section is bounded by `<!-- tix:start -->` / `<!-- tix:end -->`
//! markers.  A second run replaces the content between the markers
//! (idempotent, C-009).  Unrelated content in `AGENTS.md` is never
//! touched.
//!
//! The section text is assembled from the templates in `src/templates/`
//! (`baseline.md` plus one `.md` per feature section), embedded at compile
//! time with [`include_str!`], so editing a section means editing its file.
//!
//! Governance / spec frameworks at the project root are detected by a single
//! deterministic existence check each (`Detected`): Spec Kitty via `.kittify/`,
//! OpenSpec via `openspec/`, and GitHub Spec Kit via `.specify/`. When a
//! framework is detected, a tracking section documenting how to mirror its
//! work onto the tix board is spliced into the generated block.

use std::path::{Path, PathBuf};

use super::atomic_write;
use crate::config::Config;
use crate::error::{Result, TixError};

/// Marker pair that delimit the tix section in `AGENTS.md`.
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";

/// Governance / spec frameworks detected at the project root.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Detected {
    /// Spec Kitty project home present (`.kittify/`).
    pub spec_kitty: bool,
    /// OpenSpec project root present (`openspec/`).
    pub openspec: bool,
    /// GitHub Spec Kit project root present (`.specify/`).
    pub spec_kit: bool,
}

impl Detected {
    /// No framework detected.
    pub const NONE: Self = Self {
        spec_kitty: false,
        openspec: false,
        spec_kit: false,
    };
}

/// Does `root` contain a Spec Kitty directory (`.kittify/`)?
fn has_spec_kitty(root: &Path) -> bool {
    std::fs::metadata(root.join(".kittify"))
        .map(|m| m.is_dir())
        .unwrap_or(false)
}

/// Does `root` contain an OpenSpec directory (`openspec/`)?
fn has_openspec(root: &Path) -> bool {
    std::fs::metadata(root.join("openspec"))
        .map(|m| m.is_dir())
        .unwrap_or(false)
}

/// Does `root` contain a GitHub Spec Kit project root (`.specify/`)?
///
/// `.specify/` is the directory Spec Kit itself treats as the project marker:
/// the `specify` CLI resolves the project root by searching upward for it
/// (`SPECIFY_INIT_DIR` documents the root as "the directory *containing*
/// `.specify/`"). No other tool claims the name.
fn has_spec_kit(root: &Path) -> bool {
    std::fs::metadata(root.join(".specify"))
        .map(|m| m.is_dir())
        .unwrap_or(false)
}

/// Detect the governance / spec frameworks present at `root`.
///
/// One existence check per framework (C-006/NFR-008); no marker probing,
/// no config reads, no network — the result is fully deterministic.
pub fn detect(root: &Path) -> Detected {
    Detected {
        spec_kitty: has_spec_kitty(root),
        openspec: has_openspec(root),
        spec_kit: has_spec_kit(root),
    }
}

/// One `Detected ...` line per detected framework, in fixed order (Spec
/// Kitty, OpenSpec, Spec Kit), mirroring the generated tracking sections.
/// Each line names the same marker directory the corresponding section
/// becomes verbose over (`via .kittify/` / `via openspec/` / `via
/// .specify/`), so stdout and the generated block stay in lockstep.
pub fn detection_report(detected: Detected) -> Vec<String> {
    let mut lines = Vec::new();
    if detected.spec_kitty {
        lines.push("Detected Spec Kitty (via .kittify/)".to_string());
    }
    if detected.openspec {
        lines.push("Detected OpenSpec (via openspec/)".to_string());
    }
    if detected.spec_kit {
        lines.push("Detected Spec Kit (via .specify/)".to_string());
    }
    lines
}

/// Run `tix init-agents`: locate `AGENTS.md` in `cwd`, create or update
/// the idempotent tix block.  Returns the path that was written.
/// Detected frameworks are announced to stdout before the status lines.
///
/// An outdated block is overwritten after a confirmation prompt on a TTY
/// (`yes` skips the prompt, and without a TTY there is nothing to ask, so a
/// non-interactive run overwrites as before).
pub fn run(cwd: &Path, yes: bool) -> Result<PathBuf> {
    let path = cwd.join("AGENTS.md");
    let detected = detect(cwd);
    for line in detection_report(detected) {
        println!("{line}");
    }
    let block = build_section(detected);

    if path.exists() {
        let content = std::fs::read_to_string(&path)?;
        let new = insert_block(&content, &block);
        if new == content {
            println!("AGENTS.md already up to date");
        } else if yes || overwrite_confirmed()? {
            atomic_write(&path, &new)?;
            println!("Updated {}", path.display());
        }
    } else {
        let new = format!("{}\n", block);
        atomic_write(&path, &new)?;
        println!("Created {}", path.display());
    }
    seed_beads_block(cwd)?;
    Ok(path)
}

/// Ask, once, whether an outdated `AGENTS.md` block should be overwritten.
///
/// On a TTY the user is asked and an empty/`y` answer overwrites; any other
/// answer leaves the block unchanged. Without a TTY there is nothing to ask,
/// so `true` is returned — `init-agents`'s job is to (re)generate the block,
/// and a script that runs it wants the fresh version.
fn overwrite_confirmed() -> Result<bool> {
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() {
        return Ok(true);
    }
    eprint!(
        "tix: your AGENTS.md has outdated instructions; want to overwrite it \
         with the newer version? [Y/n] "
    );
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .map_err(TixError::Io)?;
    let answer = line.trim();
    if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
        return Ok(true);
    }
    eprintln!("tix: AGENTS.md left unchanged — run \"tix init-agents --yes\" to overwrite it");
    Ok(false)
}

/// The markers of the beads block, appended only when the project has opted in.
const BEADS_START: &str = "<!-- tix:beads:start -->";
const BEADS_END: &str = "<!-- tix:beads:end -->";

/// Add a short beads block when this project's `[beads] mode` is not `off`.
///
/// The counterpart of the guidance `bd setup <agent>` writes: an agent that reads
/// `AGENTS.md` learns that a beads workspace is wired in here and — the part that
/// matters — that it must go through the `bd` CLI rather than opening the
/// database. Absent when the mode is `off`, so a project that never opted in gains
/// no block.
fn seed_beads_block(cwd: &Path) -> Result<()> {
    let Some(root) = Config::project_root(cwd) else {
        return Ok(());
    };
    let Ok(config) = Config::load(&root) else {
        return Ok(());
    };
    if config.beads.mode == crate::config::BeadsMode::Off {
        return Ok(());
    }
    let block = format!(
        include_str!("../templates/beads-block.md"),
        beads_start = BEADS_START,
        beads_end = BEADS_END,
        mode = config.beads.mode.as_str(),
    );
    let path = cwd.join("AGENTS.md");
    let existing = std::fs::read_to_string(&path).unwrap_or_default();
    let updated = crate::commands::upsert_block(&existing, &block, BEADS_START, BEADS_END);
    if updated != existing {
        atomic_write(&path, &updated)?;
    }
    Ok(())
}

/// The spec-kitty dogfooding section, rendered verbatim (FR-019) when
/// `.kittify/` is detected (see `contracts/agent-dogfooding.md`).
const DOGFOODING_SECTION: &str = include_str!("../templates/dogfooding.md");

/// The OpenSpec dogfooding section, rendered verbatim when `openspec/` is
/// detected. Structurally parallel to [`DOGFOODING_SECTION`], documenting
/// the `openspec` label convention for mirroring changes/tasks onto the tix
/// board (see `docs/openspec-integration.md`).
const OPENSPEC_SECTION: &str = include_str!("../templates/openspec.md");

/// The GitHub Spec Kit dogfooding section, rendered verbatim when
/// `.specify/` is detected. Structurally parallel to
/// [`DOGFOODING_SECTION`] and [`OPENSPEC_SECTION`], documenting the
/// `speckit` label convention for mirroring features/bugs/assessments onto
/// the tix board (see `docs/spec-kit-integration.md`).
///
/// The label is `speckit` (no hyphen) deliberately: `spec-kit` would be a
/// prefix of the existing `spec-kitty` label, so `grep spec-kit` would
/// match both frameworks' issues. `speckit` collides with neither.
const SPEC_KIT_SECTION: &str = include_str!("../templates/spec-kit.md");

/// The stable-ID template section of the generated `AGENTS.md` block.
///
/// Structurally parallel to the other sections: it names the management
/// commands, the template-aware create path, and the data-only/pre-write
/// boundary in the same bullet style, so the agent-facing block documents the
/// same surface `tix template` and `tix create --template` implement.
const TEMPLATES_SECTION: &str = include_str!("../templates/templates.md");

/// The first-class-projects section of the generated `AGENTS.md` block (WP09
/// T043).
///
/// Structurally parallel to the other feature sections: it names the identity
/// rule, the lifecycle commands, the association surface, and the filter/group/
/// sort spellings, so the agent-facing block documents the same surface
/// `tix project` implements. It is spliced into the **baseline** block (every
/// detection state), because the CLI surface it documents is present in every
/// project — which is what the subcommand-parity guard asserts.
const PROJECTS_SECTION: &str = include_str!("../templates/projects.md");

/// The epics section of the generated `AGENTS.md` block (WP09 T045).
///
/// Structurally parallel to [`PROJECTS_SECTION`]: it names the identity rule, the
/// lifecycle and navigation commands, the association surface, the filter/group/
/// sort spellings, and the tasks-are-issues boundary, so the agent-facing block
/// documents the same surface `tix epic` implements. It is spliced into the
/// **baseline** block (every detection state), because the CLI surface it
/// documents is present in every project — which is what the subcommand-parity
/// guard asserts.
const EPICS_SECTION: &str = include_str!("../templates/epics.md");

/// The roadmap / initiatives section of the generated `AGENTS.md` block (WP08 T042).
///
/// Structurally parallel to [`PROJECTS_SECTION`] and [`EPICS_SECTION`]: the
/// `tix initiative`/`tix roadmap` surface exists in every project, so it is spliced
/// into the **baseline** block and the subcommand-parity guard asserts the
/// generated output names both verbs. The section stays strictly to what the code
/// supports — the verbs in `crate::cli`, the one normalized filter, the
/// join-not-blend summary, and the lint boundary.
const ROADMAP_SECTION: &str = include_str!("../templates/roadmap.md");

/// The cycle-schedules section of the generated `AGENTS.md` block (WP09 T049,
/// mission `repeating-cycle-schedules-01M3C7W1`).
///
/// Structurally parallel to the other feature sections: it names the config shape,
/// the stable id and the reference label, the CLI verbs, the seven lifecycle
/// actions, the snapshot rule and the read-only/gated boundaries, so the
/// agent-facing block documents the same surface `tix cycle schedule` implements.
/// It is spliced into the **baseline** block (every detection state), because the
/// CLI surface it documents is present in every project — which is what the
/// subcommand-parity guard asserts.
const CYCLE_SCHEDULES_SECTION: &str = include_str!("../templates/cycle-schedules.md");

/// The capacity section of the generated `AGENTS.md` block (WP09 T049, mission
/// `cycle-capacity-planning-01M3C85C`).
///
/// Structurally parallel to the other feature sections: it names the configuration
/// shape, the explicit scope and unit rules, the shared formulas, the warning policy,
/// the CLI verbs, the TUI tab, the MCP tools and the close/portable boundaries, so the
/// agent-facing block documents the same surface `tix capacity` implements. It is
/// spliced into the **baseline** block (every detection state), because the CLI surface
/// it documents is present in every project — which is what the subcommand-parity guard
/// asserts.
const CAPACITY_SECTION: &str = include_str!("../templates/capacity.md");

/// The `tix time` surface (mission `per-issue-time-tracking-01M3C9A4`, WP08 T043).
///
/// Spliced into the same baseline slot as every other feature section, so `tix time`
/// — a new top-level subcommand — cannot ship with no agent-facing documentation. The
/// section states the confirmed model (whole minutes, an optional calendar date, an
/// author, an optional note), the three mutations and three reads, the one-event audit
/// rule, the never-zero rule, and the escalation boundaries the mission refuses (no
/// timer, no timezone or overlap rule, no billing or hosted service).
const TIME_SECTION: &str = include_str!("../templates/time.md");

/// The `tix analytics` surface (mission `burndown-scope-analytics-01M3C9J1`, WP08 T044).
///
/// Spliced into the same baseline slot as every other feature section, so `tix
/// analytics` — a new top-level subcommand — cannot ship with no agent-facing
/// documentation. The section states the three verbs, the two modes, the coverage
/// semantics, the projection's three states, the chart document and the remote
/// boundary, and, deliberately, the mission's own boundaries: no hosted service, no
/// guessed history, no new unit or conversion, and notably that the estimate-change
/// class sees only what the `tix edit` seam records.
const ANALYTICS_SECTION: &str = include_str!("../templates/analytics.md");

/// The remaining top-level commands that had no agent-facing line (WP08 T043).
///
/// The subcommand-parity guard derives its expected set from `clap`, and seven of the
/// 41 top-level commands had no line in the block: the six here and `tix time` (which
/// has its own section above). Each one-liner is the command's own `clap` `about`
/// first line, restated in this block's voice — the parser's description is the
/// authoritative one, so this is not a second, drifting opinion about what each does.
const OTHER_COMMANDS_SECTION: &str = include_str!("../templates/other-commands.md");

/// Build the tix section for `AGENTS.md`.
///
/// Derived from `contracts/cli.md` — every clap-defined subcommand must
/// appear verbatim in the output (tested by a unit-level guard).
///
/// When `detected.spec_kitty` is `true` the output additionally contains the
/// spec-kitty dogfooding section between `### Updating` and `### Storage
/// layout` (FR-019); when `detected.openspec` is `true` the OpenSpec tracking
/// section is spliced in the same slot, after any spec-kitty section; when
/// `detected.spec_kit` is `true` the Spec Kit tracking section follows both.
/// When none is detected the output is byte-identical to today's block
/// (FR-020, locked by a golden test).
///
/// The beads section of the generated `AGENTS.md` block.
///
/// Spliced into the **baseline** block (every detection state), because
/// `tix beads` and `tix init --beads` exist in every project — which is what the
/// subcommand-parity guard asserts. It leads with the one rule an agent must not
/// get wrong: the beads database is never opened directly.
const BEADS_SECTION: &str = include_str!("../templates/beads.md");

#[allow(clippy::too_many_lines)]
pub fn build_section(detected: Detected) -> String {
    // The generated block is built from constants so that its text is
    // deterministic across runs (no timestamps, no commit hashes).
    let mut section = format!(
        include_str!("../templates/baseline.md"),
        start = MARKER_START,
        end = MARKER_END,
    );
    // The stable-ID template surface is spliced before `### Safety` — the same
    // baseline slot every detection state emits, immediately ahead of the
    // generated `tix bulk` appendix — so the block documents it like every other
    // feature section.
    section = section.replace("### Safety", &format!("{TEMPLATES_SECTION}\n\n### Safety"));
    // The first-class-projects section is spliced into the **baseline** block as
    // well — the CLI surface it documents exists in every project — immediately
    // before `### Storage layout`, so framework tracking sections (below) follow it.
    section = section.replace(
        "### Storage layout",
        &format!("{PROJECTS_SECTION}\n\n### Storage layout"),
    );
    // The epics section is spliced into the baseline block the same way, after the
    // projects section and before `### Storage layout`, so framework tracking
    // sections (below) follow it.
    section = section.replace(
        "### Storage layout",
        &format!("{EPICS_SECTION}\n\n### Storage layout"),
    );
    // The roadmap/initiatives section follows the epics section in the same
    // baseline slot (WP08 T042), so the generated block documents the surface the
    // subcommand-parity guard checks for.
    section = section.replace(
        "### Storage layout",
        &format!("{ROADMAP_SECTION}\n\n### Storage layout"),
    );
    // The cycle-schedules section follows the roadmap section in the same baseline
    // slot (WP09 T049, mission `repeating-cycle-schedules-01M3C7W1`), so the
    // generated block documents the surface the subcommand-parity guard checks for.
    section = section.replace(
        "### Storage layout",
        &format!("{CYCLE_SCHEDULES_SECTION}\n\n### Storage layout"),
    );
    // The beads section follows in the same baseline slot, so `tix beads` — a new
    // top-level subcommand — is documented like every other feature section and the
    // subcommand-parity guard's clap-derived set is satisfied.
    section = section.replace(
        "### Storage layout",
        &format!("{BEADS_SECTION}\n\n### Storage layout"),
    );
    // The capacity section follows the cycle-schedules section in the same baseline
    // slot (WP09 T049, mission `cycle-capacity-planning-01M3C85C`), so the generated
    // block documents the surface the subcommand-parity guard checks for.
    section = section.replace(
        "### Storage layout",
        &format!("{CAPACITY_SECTION}\n\n### Storage layout"),
    );
    // The time section follows the capacity section in the same baseline slot
    // (WP08 T043, mission `per-issue-time-tracking-01M3C9A4`), so `tix time` — a new
    // top-level subcommand — is documented like every other feature section.
    section = section.replace(
        "### Storage layout",
        &format!("{TIME_SECTION}\n\n### Storage layout"),
    );
    // The remaining top-level commands that had no line in the block follow the
    // time section (WP08 T043). With all seven now documented, the subcommand-parity
    // guard's `clap`-derived set is satisfied in the one direction it was missing.
    section = section.replace(
        "### Storage layout",
        &format!("{OTHER_COMMANDS_SECTION}\n\n### Storage layout"),
    );
    // The analytics section follows the other-commands section (WP08 T044, mission
    // `burndown-scope-analytics-01M3C9J1`), so `tix analytics` — a new top-level
    // subcommand — is documented like every other feature section and the
    // subcommand-parity guard's `clap`-derived set keeps its coverage.
    section = section.replace(
        "### Storage layout",
        &format!("{ANALYTICS_SECTION}\n\n### Storage layout"),
    );
    // Collect each enabled framework's tracking section in fixed order
    // (Spec Kitty, then OpenSpec) and splice the joined fragment before
    // `### Storage layout` (contract placement). With no framework enabled
    // the baseline passes through untouched.
    let mut tracking = Vec::new();
    if detected.spec_kitty {
        tracking.push(DOGFOODING_SECTION);
    }
    if detected.openspec {
        tracking.push(OPENSPEC_SECTION);
    }
    if detected.spec_kit {
        tracking.push(SPEC_KIT_SECTION);
    }
    if !tracking.is_empty() {
        section = section.replace(
            "### Storage layout",
            &format!("{}\n\n### Storage layout", tracking.join("\n\n")),
        );
    }
    // The batch block, **generated** from the same projection the MCP schema is
    // generated from (`bulk::metadata` / `commands::bulk::agents_doc`). Hand-writing it
    // here would be a fifth description of the patch whitelist, and the ones before it
    // already drifted: the point of `agents_doc` is that the documented surface and the
    // implemented one cannot disagree, and that only holds if there is one writer.
    section = section.replace(
        "### Safety",
        &format!("{}\n### Safety", super::bulk::agents_doc()),
    );
    section
}

/// Insert or replace the tix block in `existing` with `block`.
///
/// A thin wrapper over [`super::upsert_block`] with this module's HTML-comment
/// markers, so the `AGENTS.md` block and the `.gitignore` block `tix init`
/// writes share one implementation.
fn insert_block(existing: &str, block: &str) -> String {
    super::upsert_block(existing, block, MARKER_START, MARKER_END)
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    /// The eight detection states (Spec Kitty × OpenSpec × Spec Kit).
    fn states() -> [Detected; 8] {
        let mut out = Vec::new();
        for spec_kitty in [false, true] {
            for openspec in [false, true] {
                for spec_kit in [false, true] {
                    out.push(Detected {
                        spec_kitty,
                        openspec,
                        spec_kit,
                    });
                }
            }
        }
        out.try_into().unwrap()
    }

    #[test]
    fn build_section_contains_every_clap_subcommand() {
        // The expected set is derived from `clap` itself rather than from a
        // hand-written list. The list this guard used to carry named 25 of the 41
        // top-level commands, and 16 shipped outside it (`time` plus `archive`,
        // `bulk`, `health`, `layout`, `lint`, `mcp`, `mentions`, `milestone`, `new`,
        // `recurring`, `restore`, `rollover`, `search`, `triage`, `wip`) — so a new
        // subcommand could ship with no agent-facing line and this guard stayed
        // green. Deriving the set from the parser closes the direction it was
        // missing: every `Command` variant `clap` can dispatch must be advertised.
        //
        // The floor is the guard's own positive control: an introspection that
        // silently returned an empty or near-empty list would pass every assertion
        // below, so the derived set is required to hold at least every command that
        // exists today.
        use clap::CommandFactory;
        let expected: Vec<String> = crate::cli::Cli::command()
            .get_subcommands()
            .map(|sub| sub.get_name().to_string())
            .collect();
        assert!(
            expected.len() >= 41,
            "clap introspection found only {} top-level commands: {expected:?}",
            expected.len()
        );
        for detected in states() {
            let section = build_section(detected);
            for cmd in &expected {
                assert!(
                    section.contains(&format!("`tix {cmd}")),
                    "build_section({detected:?}) output missing subcommand {cmd}"
                );
            }
        }
    }

    #[test]
    fn build_section_has_marker_pair() {
        for detected in states() {
            let s = build_section(detected);
            assert!(s.starts_with(MARKER_START), "bad start for {detected:?}");
            assert!(s.ends_with(MARKER_END), "bad end for {detected:?}");
        }
    }

    /// The golden pre-mission block: `build_section(Detected::NONE)` must be
    /// exactly this (FR-020). A future unrelated block change must update it
    /// deliberately.
    #[test]
    #[allow(clippy::too_many_lines)]
    fn build_section_none_matches_golden_pre_mission_block() {
        let golden = "\
<!-- tix:start -->
## tix — issue tracker (AI agent usage)

Conventional usage for autonomous issue management.  Run from the repository root.

### Viewing
- `tix ready`                             the unblocked, highest-priority work to start next
- `tix list`                              my issues in the board's entry column, one line each
- `tix list --all`                        every active issue, ignoring the default scope
- `tix list --status \"in progress\"`       filter by column
- `tix list --columns num,id,title`       choose and order the displayed columns
- `tix list --format toml`                records as xml, yml or toml instead of the table
- `tix status`                            board summary: column -> count
- `tix view|show <number|slug>`          full issue incl. frontmatter + body
- `tix render`                           render the current board once and exit
- `tix init-agents`                       regenerate this AGENTS.md section
- `tix version`                            the crate version, build commit and build time

### Creating
- `tix create --title \"Title\" --priority high --labels bug --assignee @me`
- `tix create`                            interactive (needs a TTY)
- `tix new`                               open the TUI with the create-issue form already open
- `--assignee @me` resolves to the configured git author identity; bare
  handles like `@alice` are stored as-is. Without `--assignee`, the assignee
  defaults to the git identity, falling back to unassigned when git is absent
  (never an error)

### Updating
- `tix edit <number> --title \"New\" --priority urgent`
- `tix edit <number> --assignee <value>`  physically moves the issue into the
  new assignee's segment; `--assignee \"\"` clears it (moves to unassigned/)
- `tix move <number> \"in progress\"`       move between board columns
- `tix move <number> done --commit <hash>` hand-picked finish (commit recorded)
- `tix move <number> done` (no `--commit`) auto-captures the current `HEAD`;
  `--commit auto` resolves the closing commit from git history instead
- `tix edit <number> --commit auto` back-fills the commit that last touched the issue file
- `tix link <a> <b> --type <kind>`  create a typed relationship;
  `--type` is required and there is no default kind, so an untyped
  relationship is never recorded by accident. Kinds: `blocks`, `blocked_by`
  (an input spelling that stores the equivalent `blocks` edge), `duplicates`,
  `related`
- `tix link list [<sel>] [--kind <kind>]`  list relationships, ordered by
  `(kind, target)`
- `tix link edit <id> [--to <sel>] [--type <kind>]`  retarget and/or re-kind;
  the relationship's id survives
- `tix link remove <id>` / `tix unlink <a> <b> --type <kind>`  remove one
  relationship (idempotent — an absent edge is a no-op)
- `tix chain <sel>`  print the transitive dependency chain an issue holds up
- `tix list --show relationships` / `tix view <sel> --show relationships`
  add relationship and blocked information to list and detail output

### Working an issue (status, decisions, questions)
- **REQUIRED — the status moves as the work moves.** An issue that is being
  worked on is moved to the board's active column while it is in flight
  (`tix move <number> \"in progress\"`), and to `done` with the closing commit
  when it lands (`tix move <number> done --commit <hash>`). A `todo` issue whose
  work is already finished makes the board lie to every other agent reading it
- **REQUIRED — \"in review\" is a human-review column.** Moving an issue (or a
  work package) to \"in review\" hands it to a person to look at — it is not a
  self-review. Before you move it there, run every automatic check yourself
  (linting, formatting, the test suite, a build) and leave the repository in a
  clean, running state, so the human is reviewing finished work, not a red
  build. Move work there only when your own checks are done and you are
  waiting on a human.
- **REQUIRED — when you move work to \"in review\", write a verification guide
  into the issue's comments.** The guide must let the human assert that the
  work package is complete without asking you: the URL where the feature is
  visible, the account to log in with, any prerequisites to set up first, and
  the exact command that runs the relevant test (for example `cargo test
  <filter>` or the one scenario that exercises the change).
- **REQUIRED — a decision or a question is recorded as a comment on the issue it
  belongs to, as it happens.** When you ask the human a question, when they
  answer it, and when a call is taken while working an issue (a scope decision,
  an approach rejected, a trade-off accepted), append it to that issue's
  `## Comments`: `tix edit <number>` opens the file (the comment section is
  plain markdown) and MCP has `comment_on_issue`. The body states the task; the
  comments state what was decided while doing it, so the next agent reads the
  answer instead of re-asking the question or re-opening a settled call. A
  decision that lives only in a chat log is a decision the board does not have

### Archiving (hiding an issue without deleting it)
- `tix archive <sel>…` / `tix restore <sel>…` rename the issue's file **in place**
  to a dot-prefixed name (`.2026-10-01-foo.md`). The filename *is* the marker:
  nothing else changes, `number`/`status`/`created` and every frontmatter field
  stay byte-identical, one `## History` event records the operation, and a
  redundant call is refused by name (`already_archived` / `not_archived`)
- Default views never read an archived file (`tix list`, `tix search`, the TUI
  board) — that is what keeps a repository with thousands of archived issues cheap
  to open. `--with-archived` is the scope flag that loads them and
  `archived:true|false` narrows the loaded set; in the TUI `a` toggles them and
  their cards read `[archived]`
- An archived issue still resolves (`tix show <sel>`, `tix restore <sel>`) and
  **keeps its number**: `tix create` allocates above every issue, archived ones
  included, so a number is never recycled onto a different issue
- `tix bulk archive` archives each selected issue by delegating to the store's
  archive service — the one archive implementation, so a file is renamed in place
  and nothing is deleted

### Parent issues (a decomposition, not a dependency)
- An issue has **at most one** parent, stored as a canonical `parent:` issue id.
  Numbers and title/filename fragments remain input aliases. This is
  a decomposition tree, **not** a typed dependency edge: use `--type blocks` for
  *must happen before*, and `parent:` for *part of*
- `tix create --title T --parent <sel>`  create a child
- `tix edit <sel> --parent <sel>`  set or change a parent; `--parent ''` clears it
- `tix parent show <sel>` / `tix parent children <sel>` / `tix parent list`
  the hierarchy, one issue's direct children, and the whole board
- A self-parent and a cycle are refused, and a refused write changes nothing.
  Deleting a parent **keeps** its children: the reference survives and `tix lint`
  reports it as `unresolved_parent` so it can be repaired
- `tix search parent:<sel>`  the direct children of `<sel>`

### Workflow status categories
- Board columns stay in `tix.toml`; a board maps its columns into the **global**
  six-word vocabulary with `[kanban.<name>.category_map]`. `Issue.status` still
  holds the **column name**, so every existing board keeps working unchanged
- Categories: `backlog`, `unstarted`, `active`, `blocked`, `completed`, `canceled`
  (`tix workflow categories` prints them, in the order adjacency is evaluated
  against). `completed` and `canceled` are both terminal; only `completed` means
  the work is done, so a `canceled` issue is never counted as complete anywhere
- A column with no mapping resolves to the **safe default** (`unstarted`), and
  `tix lint` reports that as one `unmapped_column_default` warning per board.
  A column the board does not configure at all is `(unmapped)` everywhere
- Transition policy is **opt-in per board**: only a board that declares a
  `category_map` gets the terminal lock, so a legacy board keeps completion
  detection and the commit-clearing rule and gains no new refusals
- Leaving a terminal category needs an explicit reason: `tix move <n> <col>
  --reason <text>` or `tix edit <n> --status <col> --reason <text>`. The reason is
  recorded in the issue's History, and MCP takes the same argument
- A board whose `category_map` omits `completed` is **refused at config load**;
  a board with no `category_map` at all is not
- `tix lint` reports a hand-edited `status:` that contradicts the issue's own
  recorded decisions (`direct_edit_policy_violation`) and **writes nothing** — no
  repair, no appended entry
- `tix workflow show|transitions` report the resolved vocabulary, where each
  category came from, and which transitions are permitted

### Triage inbox
- Opt-in: the inbox exists because `tix.toml` has a `[triage]` section. `tix init`
  writes one (with the `triage`/`wontfix` columns it needs, and `wontfix` mapped to
  `canceled`), so a new repository starts with an inbox; removing the section is
  what turns it off
  (`entry_status`, `declined_status`, optional `accepted_status`, optional
  `deferred_status` = `entry_status`). With no section there is no inbox, and every
  `tix triage` command refuses and names the missing section
- `tix triage list [--age|--count]` and `tix triage status` read the queue. Age comes
  from the entry stamp; an issue that recorded none has an **unknown** age, never `0`
- One decision: `tix triage accept <sel> [--to <col>]`, `tix triage decline <sel>`,
  `tix triage duplicate <sel> --of <sel>`, `tix triage defer <sel>`.
  `--reason` is required for decline, duplicate and defer — a missing one is refused
  (`missing_reason`) before anything is written
- Decline moves the issue to the configured `canceled` column and never deletes or
  archives it; `canceled` is terminal but **not** complete. Duplicate records a typed
  `duplicates` relation and never merges the files. Defer refreshes the entry stamp
  and leaves the issue in the inbox
- A batch previews by default and writes only with `--apply`: `tix triage batch
  --select <list>|--filter <query> --decision <accept|decline|duplicate|defer>
  [--reason <text>] [--to <col>] [--of <sel>] [--apply]`. Each selected issue gets
  exactly one outcome (`applied`/`no-op`/`rejected`/`failed`), and a rejected item
  does not roll back the ones that succeeded
- One applied decision is one `## History` event (`triage_accepted`,
  `triage_declined`, `triage_duplicated`, `triage_deferred`) carrying actor,
  timestamp, prior state, result and reason; a no-op or a rejection appends none, and
  events are never rewritten
- The TUI opens the same inbox with `t` (`a`/`d`/`u`/`f` decide, `p` is the marked
  set); MCP exposes `triage_list`, `triage_status`, `triage_preview` and
  `triage_apply`, the write requiring `confirm: true`
- `tix import --triage-policy entry|remote` decides whether an import enters the
  inbox; absent the flag it routes only when the project has a `[triage]` section, and
  the report always names the mapping used

### Saved issue views (#172)
- A saved view names a filter/sort/group/visibility definition; definitions live
  in `tix.toml` under `[views.<view-id>]` — no database, no hosted view service
- `tix view create --name \"Active\" --filter \"status:todo\" --sort due` saves one;
  `tix view list` prints id, name, scope and resolution; `tix view show <id>`
  prints the normalized plan. The `v-…` id is the identity; the name is a label
- `tix view apply <id>` is **read-only**: it marks the active view, rebuilds the
  board through the shared filter path, and prints the plan and the result issue
  numbers; `tix view status <id>` reports divergence without reconciling anything
- `tix view update` keeps the stable id; `tix view duplicate` allocates a new one;
  `tix view delete <id> --confirm` removes **only the definition**, never issues
- Scope is explicit (`repository` | `project` | `user`); `user` is share metadata
  only, not an account or a permission. Plans use the existing query grammar
- The TUI opens the list with `v` (Enter applies); MCP exposes the `view_*` tools
  with the same normalized plan; see `docs/saved-views.md`

### Recurring series (#175)
- A **series** is an issue whose `series:` frontmatter states a rule (the families
  `daily`, `weekly`, `monthly`, `interval`, and a `cron-like` macro vocabulary of
  `@daily`/`@weekly`/`@monthly`); its **occurrences** are issues generated from
  it. Identity is the immutable `series.id`, and a date is the canonical key
  `<series-id>@<YYYY-MM-DD>`: a display name is a label, never a handle, and an
  ambiguous one is refused rather than guessed
- `tix recurring list` / `tix recurring show <sel>` / `tix recurring detect
  [--date YYYY-MM-DD]` read series state and what is due. Detection is
  **read-only**: it creates no issue, appends no History entry and changes no
  series, and the TUI start/refresh warning is the same detector's answer
- `tix recurring generate <sel> --date D --yes` creates the occurrence
  idempotently (a key that is taken is a no-op, never a second issue), and
  `pause`/`resume`/`end` plus `skip`/`defer`/`ignore` are the lifecycle actions.
  **Every mutation needs `--yes`**; MCP takes the same operations with
  `confirm: true`, and an unconfirmed call is refused with `not_confirmed` before
  the store is touched
- One applied action is exactly one append-only `transition_accepted` History
  entry naming the action, the occurrence and the caller's reason; a repeated
  action is a no-op that appends nothing. Generation writes the occurrence, its
  link and the series cursor in one transaction

### Work-in-progress limits (#176)
- A board can cap each column: `[kanban.<board>.wip]` in `tix.toml`, edited with
  `tix wip show|set|unset|unit`. `max_items` counts cards, `max_estimate` counts
  estimate in the `unit` you name, and `policy` is `warn` or `block` — required,
  because a limit that silently warns is a policy nobody configured. A board with no
  `wip` section behaves exactly as it did before limits existed
- **Every** move asks one service: `tix move`, the TUI's `>`/`<` and its context
  menu and drag, the triage inbox, MCP's `move_issue`, and `tix bulk edit`. A move
  within the limit is allowed and appends nothing; `warn` proceeds and reports its
  decision; `block` refuses unless an authorized override carries it; and an estimate
  that cannot be counted in the column's unit refuses the move rather than counting
  as zero
- `tix move <sel> <col> --override --reason WHY --yes` (MCP: `override` +
  `reason` + `yes`) is the only way past a `block`, and all three are required. The
  override binds the limit it was authorized against, so a limit that changed in the
  meantime is `stale_override_limit`; an applied override appends exactly one
  `transition_accepted` entry carrying actor, time, reason and the before/after
  figures
- `tix status`, `tix list --show wip`, the board's column headers and `tix lint`
  report the usage. Lint reports a violation as a **warning** and repairs nothing.
  A bulk batch judges each item against the state that item would see, refuses the
  blocked items without rolling back the applied ones, and exits non-zero when it
  was partial

### Projects (first-class delivery containers)
- A project is a **stable generated id** — the `[projects.<id>]` registry key in
  `tix.toml` — with a mutable display `name`; every association stores the **id**,
  so a rename changes the label only and never re-keys anything (C-002)
- `tix project create <name> [--description --status --lead --start-date --due-date]`,
  `tix project edit <id> [--name …]` (label only), `tix project list` (a row per
  project: id, name, member count), `tix project show <id>` (record, progress,
  health counts and members), `tix project delete <id>` (removes the registry
  entry **only** — issues keep their references as diagnosable
  `missing_project_reference` findings, never deleted)
- Associate and dissociate: `tix project add <id> <sel>…`,
  `tix project remove <id> <sel>…`, `tix project bulk-add <id> <sel>…`,
  `tix create --projects <id,id>`, `tix edit <sel> --add-project <id>` /
  `--remove-project <id>` / `--projects \"\"` (clears every association). An issue
  may belong to **multiple** projects
- Filter, group and sort by project: `tix search \"project:<id>\"`, `tix list
  --project <id>`, `tix list --group project` / `--sort project` and
  `tix status --group project`. A single membership buckets by project id, several
  as `(multiple projects)`, and none as `(no project)`. The TUI exposes the same
  through `F10` (Portfolio) and the filter modal's project row

### Epics (cross-cutting delivery containers)
- An epic is a **stable generated id** — the `[epics.<id>]` registry key in
  `tix.toml` — with a mutable display `name`; an issue holds an `epics:` list of
  **ids**, so a rename changes the label only and never breaks a reference (C-003)
- `tix epic create <name> [--description --status --owner --start-date --due-date --weight]`,
  `tix epic edit <id> [--name …]` (label only), `tix epic list` (a row per epic:
  id, name, child and member counts), `tix epic show <id>` (hierarchy, progress,
  weight, blockers and members), `tix epic navigate <id>` (parent, children,
  ancestors, depth), `tix epic delete <id>` (removes the registry entry **only** —
  member issues and child epics keep their references as diagnosable
  `missing_epic_reference` findings, never deleted)
- Associate and dissociate: `tix epic add <id> <sel>…`,
  `tix epic remove <id> <sel>…`, `tix epic bulk-add <id> <sel>…`,
  `tix create --epics <id,id>`, `tix edit <sel> --add-epic <id>` /
  `--remove-epic <id>` / `--epics \"\"` (clears every reference). An issue may
  reference **several** epics; a repeated reference is idempotent and dereferencing
  drops only the reference
- Filter, group and sort by epic: `tix search \"epic:<id>\"`, `tix list --epic <id>`,
  `tix list --group epic` / `--sort epic` and `tix status --group epic`. A single
  membership buckets by epic id, several as `(multiple epics)`, and none as
  `(no epic)`. The TUI exposes the same through `F10` (Portfolio, Epics tab) and the
  filter modal's epic row
- Parent epics form a **single-parent** hierarchy keyed by epic id; navigation
  reports the parent, children, ancestors and depth, and progress, weight and
  blocked work come from the one service rollup. A stored parent that no longer
  exists stays as a diagnosable unresolved reference (it is repaired, never
  cascaded)
- **Tasks are ordinary issues** (C-001): a `task`-labelled issue referencing an
  epic is a member like any other — there is no task entity, file or frontmatter
  key. Epic identity is a `tix.toml` registry entry; there is no database, no new
  record format and no hosted planning service (C-004). A relationship a tracker
  cannot represent stays local with a diagnostic (C-007)

### Roadmap (initiatives over existing delivery)
- An initiative is a **stable generated id** — the `[initiatives.<id>]` registry key
  in `tix.toml` — with a mutable display `name`, plus optional
  outcome/owner/status/priority/labels/target date and explicit project-**id**,
  epic-**id** and milestone-**name** references. A rename changes the label only and
  re-keys nothing
- `tix initiative create <name> [--outcome --owner --status --priority --labels
  --target-date --projects --epics --milestones]`, `tix initiative edit <id>
  [--name …]` (label only), `tix initiative list` (id, name, target date, status),
  `tix initiative show <id>`, `tix initiative delete <id> [--yes]` (the registry
  entry only — no issue, project, epic or milestone is ever deleted)
- Associate and dissociate: `tix initiative associate <id>
  --project|--epic|--milestone <ref>` and `tix initiative dereference <id> …`,
  naming **exactly one** kind flag. A repeated association is an idempotent no-op,
  and a well-formed reference that names nothing is stored and diagnosed rather than
  resolved or inferred from an owner
- Read the roadmap: `tix roadmap list [--from <date> --to <date> --owner <id>
  --status <state> --label <label> --query <text>]` and `tix roadmap show <id>`.
  Rows are ordered by target date ascending, then stable id; an initiative with no
  target date renders the explicit `undated` state and sorts last — never an invented
  period — and a bounded period excludes it
- The one filter: the CLI flags, the TUI filter editor, the MCP arguments and the
  portable document all parse into the same normalized `RoadmapFilter`. Malformed
  syntax (a non-`YYYY-MM-DD` date, a reversed period, a blank owner/status/label) is
  refused before anything is projected; a well-formed value that names nothing simply
  matches nothing
- The summary is a **join, not a blend**: each row carries one contribution per
  referenced source entity — its `<kind>:<id>` provenance, its derived label, its
  resolution, the owning service's own progress value, the shared health policy's
  risk state and the policy version — and no aggregate percentage, score or average.
  States are `complete` / `partial` / `empty` / `missing_reference` / `unknown`; a
  genuine zero is a value, never conflated with empty or unknown
- Missing references stay visible on the row, in the detail and in the ordered
  diagnostics (field, reference and remedy), never dropped and never silently
  repaired. `tix lint` reports **issue-held** project/epic references (and an epic
  registry's own references); an initiative's references are config-held and are
  diagnosed on the roadmap surfaces, not by `tix lint`
- `tix export --target roadmap [--output <path>]` writes the versioned portable
  document. The TUI's `F10` Portfolio page has a **Roadmap** tab (`c` create, `e`
  rename, `a` associate, `x` dereference, `f` filter, `r` re-read, `j`/`k` rows), and
  MCP exposes
  the `initiative_*` management tools plus `roadmap_list`/`roadmap_show`/
  `roadmap_summary`. Applying or filtering a roadmap view never mutates an issue,
  project, epic or milestone
- An initiative change appends **no** `## History` event: an initiative is a config
  record, so the atomic `tix.toml` write is the audit

### Cycle schedules (repeating time boxes)
- A schedule is a `[cycle_schedules.<id>]` table in `tix.toml`: the **id** is the
  identity and a mutable display `name` is only a label, with an explicit `cadence`
  (`daily`|`weekly`|`monthly`|`interval`), a `duration`, an anchor `start_date`, an
  explicit `timezone` (`UTC` or a fixed `±HH:MM` — a named zone is refused),
  `look_ahead`, and a `state` of `active`|`paused`|`ended` (terminal). A duration
  that provably reaches past its cadence step is refused; back-to-back is allowed
- `tix cycle schedule list` / `show <SCHEDULE>` read the registry;
  `create … --confirm` / `edit <SCHEDULE> … --confirm` / `delete <SCHEDULE>
  --confirm` manage it. A generated cycle is an ordinary `docs/cycles/*.md` record
  carrying `schedule_id`, `sequence` and `state`: its stable id is
  `{schedule-id}:{sequence}` (`sprint:000001`) and its reference name is
  `<name> <start-date> <sequence>` (`Engineering sprint 2026-01-05 000001`)
- `tix cycle schedule detect [--date YYYY-MM-DD]` is **read-only** — it reports what
  is ended or due and writes nothing. `tix cycle schedule generate <SCHEDULE>
  --confirm` creates the next cycles idempotently, keyed on the stable
  `(schedule-id, sequence)`: an existing key is a no-op and a rejection ends the
  batch rather than skipping a hole
- The seven lifecycle actions are one confirmed request each:
  `close <SCHEDULE> --reason TEXT` (captures an immutable snapshot under
  `docs/cycle_snapshots/`, marks the record closed and appends `cycle_closed` to the
  cycle record's own body), `extend … --reason TEXT`, `skip … --reason TEXT`,
  `adjust-future … --reason TEXT`, `pause`, `resume` and `ignore` (writes nothing).
  **Every mutation requires `--confirm`**; a reason is required for `close`,
  `extend`, `skip` and `adjust-future`
- A close's snapshot is written **once** and never recomputed from the live
  schedule, so it survives a later schedule edit or rename. The CLI reads the file
  `close` names; the TUI and MCP's `cycle_snapshot_list` list them. No action
  creates, moves or rewrites an issue — a cycle's trail is its record, its snapshot
  and its cursor, not an issue `## History`
- The TUI's `F8` Schedule page opens the same registry on its **Cycle schedules**
  tab (`n` create, `E` edit, `g` generate, `c` close, `e` extend, `a` adjust-future,
  `p` pause, `r` resume, `s` skip, `i` ignore), rendering exactly the values the CLI
  prints. MCP exposes the `cycle_schedule_*` tools, the writes requiring
  `confirm: true`

### beads integration
- `tix beads detect|status|list|show` read a beads workspace through the `bd` CLI,
  read-only; `tix beads watch` follows its events journal
- `tix beads push|pull` move issues between tix and beads (`bd import` / `bd export`),
  and `tix beads reconcile` does both. `tix import --target beads` and
  `tix export --target beads` are the same two directions on the export/import surface
- **Never open the beads Dolt database.** No SQL, no `.beads/embeddeddolt/`, and
  `.beads/issues.jsonl` is not a source of truth — every interaction goes through
  `bd`, which owns its schema, its single-writer store and its id assignment
- `[beads] mode = mirror` makes this repository read-only over beads: every
  write path refuses. `mode = sync` installs
  `.beads/hooks/{on_create,on_update,on_close}`, which run `tix beads pull` after
  every beads mutation
- Identity lives in `.tix-beads-state.json` (safe to commit); do not hand-edit it

### Capacity (what a cycle, team or assignee may commit)
- `[capacity.<id>]` in `tix.toml` targets **exactly one** scope: `cycle` (a cycle name
  or a generated cycle's stable id — the two spellings fold to one scope), `team` (a
  `[teams.<label>]` key) or `assignee` (a handle, `@ada` folding with `ada`), with an
  explicit `capacity` at or above zero and an explicit `unit` a board declares.
  `enabled = false` keeps the numbers and enforces nothing. Validation is
  field-aware and happens before the write, so a bad field is refused by name and
  nothing changes
- `tix capacity list` / `show <REF>` / `delete --scope … --id …` manage the section;
  `tix capacity set --scope cycle|team|assignee --id <REF> --value <N> --unit <UNIT>`
  is the one write path. A reference is a name **or** its stable id
- `tix capacity calculate --scope cycle|team|assignee --id <REF>` prints the shared
  calculation: `committed`, `completed`, `remaining`, `overflow` and `utilization`,
  plus the ceiling, unit, state and the counted/uncounted/unestimated issue counts.
  `--warnings` adds the overflow report on stderr
- Units convert **only explicitly** (a board's `[kanban.<board>.wip.units]` ratio:
  `1w` counts as `5d` when `w = 5`). An estimate in an undeclared or incompatible
  unit is a `missing_conversion` diagnostic — never a silent zero — and an issue with
  no estimate stays visible as unestimated. An absent value prints `-` (JSON `null`),
  never `0`; a scope with no ceiling in force is the explicit `unconfigured` state
- Every finding carries a machine-readable kind (`capacity_overflow`,
  `missing_conversion`, `unknown_scope`, …), the scope, the numbers, the unit and the
  offending issues, with a **reason** and a **remedy**. Overflow is a **report**: no
  surface moves, resizes or rewrites an issue, a record or a snapshot, and reading
  capacity writes nothing
- A close captures the scope's calculation **once** as immutable evidence under
  `docs/cycle_snapshots/` (`tix capacity snapshot <CYCLE>` reads it back); a reopen
  appends one `## History` event and leaves the prior snapshot byte-identical
- The TUI's `F8` Schedule page has a **Capacity** tab (`Tab` between `[Series]`,
  `[Cycle schedules]`, `[Capacity]`; `j`/`k` move, `q` back) rendering exactly the CLI's
  values, and appends the compact per-cycle cell to the board's cycle-grouped headers.
  MCP exposes `capacity_list`/`capacity_show`/`capacity_calculate`/`capacity_snapshot`
  (read-only; configuration is the CLI's write)
- `tix export --target capacity` writes the versioned local document (scopes,
  snapshots, history, diagnostics); remote `tix export --target gitlab|linear` mirrors
  issues and is **separate** — a remote target that cannot carry capacity is diagnosed
  as `unsupported_remote_field` rather than dropping data

### Time (actual effort, in whole minutes)
- An entry is a stable id, a **whole number of minutes** (`duration_minutes`), an
  optional `YYYY-MM-DD` calendar date, an author and an optional note; current
  entries live in the issue's own frontmatter (`time_entries`) as one typed value
- There is deliberately **no timer** (no start/stop state, no clock read), **no
  timezone conversion or overlap rule**, and **no billing, payroll, invoicing or
  hosted service**: each is an escalation trigger, not a missing flag
- `tix time add <ISSUE> --minutes <N> --author <IDENTITY> [--date YYYY-MM-DD]
  [--note <TEXT>]` appends one entry and prints the id it minted
- `tix time edit <ISSUE> --entry <ID> [--minutes <N>] [--date <D> | --clear-date]
  [--note <TEXT> | --clear-note] [--author <IDENTITY>] --reason <TEXT>` is a
  correction: it names only the fields it replaces and leaves the rest, including
  preserved unknown metadata, exactly as they were
- `tix time delete <ISSUE> --entry <ID> --reason <TEXT> --confirm` removes the
  entry from the **current projection** only; its value and its event stay in
  `## History`
- `tix time show <ISSUE>` lists the issue's current entries and its total;
  `tix time history <ISSUE>` reads the audit view (the current count, every prior
  value a deletion kept, and the correction count)
- `tix time total [ISSUE] [--issue <N>] [--assignee <IDENTITY>] [--from <D>]
  [--to <D>] [--audit] [--by issue|assignee|date]` prints one scope's total;
  `--by` adds one block per group and `--audit` adds the deletion context
- Every applied mutation appends **exactly one** attributable `## History` event
  naming the entry and its before/after values, so a correction and a deletion are
  auditable and no prior event is rewritten. A refused mutation (an unconfirmed
  delete, a missing reason, an unknown entry, a blank author) writes **nothing**:
  the tree is byte-identical
- An absent value prints `-`, never `0`: `nothing` (the scope selected no entry)
  and `unknown` (it selected entries and none was usable) are visibly different
  from a recorded `0`, and a malformed entry is reported with its kind and remedy
  without blocking any unrelated read
- The TUI's `F7` Stats page has a **Time** tab beside `[Health]`, and the issue
  detail pane shows the issue's own total; MCP exposes `time_show`, `time_history`,
  `time_total`, `time_add`, `time_edit` and `time_delete`, and
  `tix export --target time` writes the versioned local document. Every one of
  those surfaces renders the **one shared calculation**, so their totals agree

### Other top-level commands
- `tix health` — project and issue health: read a scope, roll it up, or record a
  change (metadata, never a status or a done marker)
- `tix layout` — inspect or migrate the configured on-disk issue layout (`[layout]`)
- `tix mentions` — the non-interactive `@mentions` surface: list what is pending and
  apply the three answers without a prompt
- `tix migrate` — migrate local issue data to the canonical identity model
- `tix milestone` — manage milestones
- `tix rollover` — roll open issues forward from an expired cycle to its successor
- `tix mcp` — serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools

### Analytics (burndown and scope)
- `tix analytics burndown --scope cycle|milestone --id <REF> [--from <D> --to <D>]
  [--mode count|estimate --unit <UNIT>]` prints the daily series: the scope, its
  period, the mode and unit, the documented formula version, the coverage tally and
  one tab-separated row per day (`scope_total`, `remaining`, `completed`,
  `scope_added`, `completions`, `coverage`). An unanswerable value prints `-`, never
  `0`, and a day with no recorded history is `coverage_limited`, never a zero by
  assumption
- `tix analytics projection --scope cycle|milestone --id <REF> --as-of <D>` draws the
  read-only end-date forecast against the scope's own planning end: its `status`
  (`projected`/`unknown`/`insufficient_data`), `remaining`, `observed_rate`,
  `projected_completion` and `meets_end_date`, each rendered `-` when the record
  cannot derive it. It never invents a date and never moves a planning date
- `tix analytics export --scope cycle|milestone --id <REF> --mode count|estimate
  [--unit <UNIT>] [--as-of <D>] [--output <path>]` writes the deterministic,
  versioned local chart document (`tix-chart`, schema 1): the scope, period and
  snapshot binding, the mode/unit and precision, the ordered points with their
  coverage markers and `null`s, the optional projection, the aggregate coverage, the
  formula version and the findings. The same repository state produces byte-identical
  output
- Scope and mode are **closed vocabularies**: an unknown word is refused by name.
  Estimate mode converts only through the board's declared
  `[kanban.<board>.wip.units]` ratio — a missing or unknown unit is a
  `missing_conversion` finding, never a silent count
- Analytics is a **pure projection** over append-only History, issue frontmatter and
  the cycle/milestone planning data, and the shared status/unit services: there is no
  analytics store, no issue mutation and no second cycle/status model. A day with no
  recorded history is explicitly `unknown`/`coverage_limited`; scope that was never
  recorded is **not** guessed; history a closed snapshot bounds is not rewritten by a
  later edit. The estimate-change class sees only what the `tix edit` seam records —
  an estimate written by another path appends no event and cannot appear — and the
  coverage markers are what a reader gets. A hosted analytics service, a guessed
  historical scope, a new conversion rule or an external data source is an escalation
  trigger
- The TUI's `F7` Stats page has a **Burndown** tab beside `[Health]` and `[Time]`
  rendering exactly the CLI's values (no TUI-only numbers); MCP exposes
  `analytics_metadata`, `analytics_burndown`, `analytics_projection` and
  `analytics_export`, whose text is the CLI's own; and the remote export targets
  (`gitlab`/`linear`) cannot carry a chart document, which the document itself
  reports as `unsupported_remote_field` rather than dropping values silently

### Storage layout
- New issues are stored grouped by assignee:
  `docs/issues/<assignee>/YYYY-MM-DD-<slug>.md` (bare handles `@alice` and
  full identities `Name <email>` both work; a missing assignee lands in
  `docs/issues/unassigned/`; legacy flat files at `docs/issues/` still read).

### Closing
- `tix move <number> done`                auto-captures HEAD (or prompts without git)
- `tix delete <number> --yes`             remove an issue

### External sync (if available)
- `tix export --target gitlab`            push local issues to GitLab
- `tix export --target linear`            push local issues to Linear
- `tix import --target gitlab`            pull open issues from GitLab
- `tix import --target linear`            pull open issues from Linear

### Templates (stable-ID, data-only)
- Templates live in `tix.toml` under `[issue_templates.<id>]`, keyed by an
  **immutable stable ID** with a mutable display `name`. A rename changes only
  the label, so scope, defaults, prompts, references and preserved unknown
  metadata survive it. The **ID** is what `tix create --template` and MCP
  `create_issue(template=…)` select — never the name, which is only a label
- `tix template list` / `tix template show <id-or-name>`  read a
  `id<TAB>name<TAB>scope` row per template, or one structured `key: value`
  block. Read-only
- `tix template create --id <id> [--name <n>] [--scope <scope>]
  [--default FIELD=VALUE]… [--prompt key=FIELD[,kind=…][,required=…]]…
  [--reference kind=KIND,value=VALUE[,required=…]]…`
- `tix template update <ref> [--name <n>] [--scope <scope>] [--default …]…
  [--prompt …]… [--reference …]…`  replaces only the parts supplied: an absent
  flag leaves the stored value as it was, and a same-content update reports
  `unchanged`
- `tix template duplicate <ref> [--id <new>] [--name <n>]` copies to a new ID
  (`<source>-copy`, then `-copy-2`, …); `tix template delete <ref> [--yes]`
  removes the **definition** only — never an issue created from it
- Scope is explicit: `repository` (the default), `board:<name>` or
  `team:<label>`. There is no implicit inheritance, and a scope naming an
  unknown board or team is refused
- `tix create --template <stable-id> [--scope <scope>] [normal create flags]`
  merges the template with the flags through a **pure resolver**, then calls the
  same create core `tix create` does — there is no second issue writer. `--title`
  is required unless the template defaults one, because resolution never prompts
- A template is **data**: defaults name normal create fields only, prompts carry
  a closed kind (`text | select | multi-select | date`), and references are typed
  (`label | assignee | file | cycle | milestone | relationship`). Nothing is
  executable
- Required prompts and missing references are validated **before** any issue file
  is written: a failure prints every diagnostic as `kind: field: message` and
  creates no partial issue, leaving the template and the store untouched

### `tix bulk`

Batch editing over a selection. **Preview is the default**; `--apply` writes.

```text
tix bulk edit     --select <list> <patch flags> [--apply]
tix bulk link     --select <list> --target <sel> --type <kind> [--apply]
tix bulk unlink   --select <list> --target <sel> --type <kind> [--apply]
tix bulk archive  --select <list> [--apply]
```

- Name **exactly one** of `--select <list>`, `--filter <query>` or
  `--search <query>`. They are different grammars, so a bulk operation will
  not guess which issues you meant.
- Patch flags (the whole whitelist):
  status, priority, assignee, labels, start-date, due-date, estimate, cycles, milestones
  `title` and `body` are deliberately **not** in it: a batch title rewrite is
  the one operation nobody has asked for and everybody would regret.
- `--clear <field>` takes one of:
  priority, assignee, labels, start-date, due-date, estimate, cycles, milestones
  One per invocation — a second is refused rather than silently dropped. A
  **blank** flag value is *unchanged*, never \"set to empty\": to empty a field,
  name it in `--clear`.
- `archive` delegates to the store's archive service: each selected issue's
  file is renamed **in place** to a dot-prefixed name and exactly one History
  event records it. `number`, `status` and every other frontmatter field are
  left byte-identical, re-running reports the no-op, and nothing is deleted.
- A **partial** batch exits non-zero and prints one outcome per selected
  issue. Items that failed are **not** rolled back, and the output does not
  imply that they are. Re-running is idempotent and reports the no-op.
- Per-item statuses are the same four words on every surface:
  `applied`, `no-op`, `rejected`, `failed`

### Safety
- Root `tix.toml` defines columns; run `tix init` only to create the default.
- `.md` files under `docs/issues/` are the source of truth — a plain editor works too.
- `tix init` keeps `.tix-backups/` (and the legacy `*.tixbak` / `*.md.bak`)
  in `.gitignore` (a `# tix:start` / `# tix:end` block) so the backups `tix`
  writes before rewriting an issue are never committed; `tix clean` prunes
  `.tix-backups/` to the newest 10 per issue and removes any legacy scattered
  backup still on disk (confirming each, or all at once with `-f`/`--force`).
<!-- tix:end -->";
        assert_eq!(build_section(Detected::NONE), golden);
    }

    #[test]
    fn build_section_none_omits_all_sections() {
        let s = build_section(Detected::NONE);
        assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
        assert!(!s.contains("### Tracking OpenSpec work with tix"));
        assert!(!s.contains("### Tracking Spec Kit work with tix"));
    }

    #[test]
    fn build_section_spec_kitty_contains_dogfooding_section() {
        let s = build_section(Detected {
            spec_kitty: true,
            openspec: false,
            spec_kit: false,
        });
        assert!(s.contains("### Tracking Spec Kitty tasks with tix"));
        assert!(!s.contains("### Tracking OpenSpec work with tix"));
        for bullet in [
            "`tix create --title \"<wp title>\" --labels spec-kitty`",
            "`tix list --status \"todo\"`",
            "`tix move <number> done`",
            "`tix move <number> done --commit <hash>`",
            "`tix edit <number>`",
            "`create_issue`",
            "`move_issue`",
            "mirror every Spec Kitty task as a tix issue",
        ] {
            assert!(s.contains(bullet), "missing bullet {bullet}");
        }
        // Placement: after "### Updating" and before "### Storage layout".
        let updating_end = s.find("### Updating").unwrap() + "### Updating".len();
        let storage_start = s.find("### Storage layout").unwrap();
        let slice = &s[updating_end..storage_start];
        assert!(
            slice.contains("### Tracking Spec Kitty tasks with tix"),
            "dogfooding section not placed between Updating and Storage layout"
        );
    }

    #[test]
    fn build_section_openspec_contains_openspec_section() {
        let s = build_section(Detected {
            spec_kitty: false,
            openspec: true,
            spec_kit: false,
        });
        assert!(s.contains("### Tracking OpenSpec work with tix"));
        assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
        assert!(s.contains("detected via `openspec/`"));
        for bullet in [
            "`tix create --title \"<change>: <task>\" --labels openspec`",
            "`tix list --status \"todo\"`",
            "`tix move <number> done`",
            "`tix move <number> done --commit <hash>`",
            "`tix edit <number>`",
            "`create_issue`",
            "`move_issue`",
            "mirror every OpenSpec change/task as a tix issue",
        ] {
            assert!(s.contains(bullet), "missing bullet {bullet}");
        }
        // Placement: after "### Updating" and before "### Storage layout".
        let updating_end = s.find("### Updating").unwrap() + "### Updating".len();
        let storage_start = s.find("### Storage layout").unwrap();
        let slice = &s[updating_end..storage_start];
        assert!(
            slice.contains("### Tracking OpenSpec work with tix"),
            "openspec section not placed between Updating and Storage layout"
        );
    }

    #[test]
    fn build_section_all_contains_all_sections_in_order() {
        let s = build_section(Detected {
            spec_kitty: true,
            openspec: true,
            spec_kit: true,
        });
        let kitty = s.find("### Tracking Spec Kitty tasks with tix").unwrap();
        let openspec = s.find("### Tracking OpenSpec work with tix").unwrap();
        let spec_kit = s.find("### Tracking Spec Kit work with tix").unwrap();
        assert!(kitty < openspec, "spec-kitty section must precede openspec");
        assert!(
            openspec < spec_kit,
            "openspec section must precede spec-kit"
        );
        assert_eq!(
            s.matches("### Tracking Spec Kitty tasks with tix").count(),
            1
        );
        assert_eq!(s.matches("### Tracking OpenSpec work with tix").count(), 1);
        assert_eq!(s.matches("### Tracking Spec Kit work with tix").count(), 1);
        let updating_end = s.find("### Updating").unwrap() + "### Updating".len();
        let storage_start = s.find("### Storage layout").unwrap();
        let slice = &s[updating_end..storage_start];
        assert!(slice.contains("### Tracking Spec Kitty tasks with tix"));
        assert!(slice.contains("### Tracking OpenSpec work with tix"));
        assert!(slice.contains("### Tracking Spec Kit work with tix"));
    }

    #[test]
    fn build_section_spec_kit_contains_spec_kit_section() {
        let s = build_section(Detected {
            spec_kitty: false,
            openspec: false,
            spec_kit: true,
        });
        assert!(s.contains("### Tracking Spec Kit work with tix"));
        assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
        assert!(!s.contains("### Tracking OpenSpec work with tix"));
        assert!(s.contains("detected via `.specify/`"));
        for bullet in [
            "`tix create --title \"<feature>: <task>\" --labels speckit`",
            "`tix import --target speckit`",
            "`tix list --status \"todo\"`",
            "`tix move <number> done`",
            "`tix move <number> done --commit <hash>`",
            "`tix edit <number>`",
            "`create_issue`",
            "`move_issue`",
            "mirror every Spec Kit task as a tix subissue",
            "`tix create --title \"<task>\" --parent <feature-issue>`",
            "`tix parent children",
            "record findings and progress on the issue as you work it",
            "`/speckit-analyze`",
            "`/speckit-taskstoissues` is GitHub-only",
        ] {
            assert!(s.contains(bullet), "missing bullet {bullet}");
        }
        // Placement: after "### Updating" and before "### Storage layout".
        let updating_end = s.find("### Updating").unwrap() + "### Updating".len();
        let storage_start = s.find("### Storage layout").unwrap();
        let slice = &s[updating_end..storage_start];
        assert!(
            slice.contains("### Tracking Spec Kit work with tix"),
            "spec-kit section not placed between Updating and Storage layout"
        );
    }

    #[test]
    fn build_section_spec_kit_builds_on_golden_block() {
        let base = build_section(Detected::NONE);
        let s = build_section(Detected {
            spec_kitty: false,
            openspec: false,
            spec_kit: true,
        });
        assert!(s.starts_with(&base[..base.find("### Updating").unwrap()]));
        assert!(s.ends_with(&base[base.find("### Storage layout").unwrap()..]));
        assert!(s.len() > base.len());
    }

    #[test]
    fn build_section_spec_kitty_builds_on_golden_block() {
        let base = build_section(Detected::NONE);
        let s = build_section(Detected {
            spec_kitty: true,
            openspec: false,
            spec_kit: false,
        });
        // The dogfooding block is a strict superset with the exact insertion
        // marker boundary preserved.
        assert!(s.starts_with(&base[..base.find("### Updating").unwrap()]));
        assert!(s.ends_with(&base[base.find("### Storage layout").unwrap()..]));
        assert!(s.len() > base.len());
    }

    #[test]
    fn build_section_openspec_builds_on_golden_block() {
        let base = build_section(Detected::NONE);
        let s = build_section(Detected {
            spec_kitty: false,
            openspec: true,
            spec_kit: false,
        });
        assert!(s.starts_with(&base[..base.find("### Updating").unwrap()]));
        assert!(s.ends_with(&base[base.find("### Storage layout").unwrap()..]));
        assert!(s.len() > base.len());
    }

    #[test]
    fn detection_report_prints_one_line_per_detected_framework() {
        assert_eq!(
            detection_report(Detected::NONE),
            [] as [std::string::String; 0]
        );
        assert_eq!(
            detection_report(Detected {
                spec_kitty: true,
                openspec: false,
                spec_kit: false,
            }),
            vec!["Detected Spec Kitty (via .kittify/)".to_string()]
        );
        assert_eq!(
            detection_report(Detected {
                spec_kitty: false,
                openspec: true,
                spec_kit: false,
            }),
            vec!["Detected OpenSpec (via openspec/)".to_string()]
        );
        assert_eq!(
            detection_report(Detected {
                spec_kitty: false,
                openspec: false,
                spec_kit: true,
            }),
            vec!["Detected Spec Kit (via .specify/)".to_string()]
        );
        assert_eq!(
            detection_report(Detected {
                spec_kitty: true,
                openspec: true,
                spec_kit: true,
            }),
            vec![
                "Detected Spec Kitty (via .kittify/)".to_string(),
                "Detected OpenSpec (via openspec/)".to_string(),
                "Detected Spec Kit (via .specify/)".to_string(),
            ]
        );
    }

    #[test]
    fn detection_report_lines_match_the_generated_sections() {
        for detected in states() {
            let report = detection_report(detected);
            let section = build_section(detected);
            // Discriminate by marker dir, not by framework name: "Spec Kit"
            // is a substring of "Spec Kitty", so name matching is ambiguous.
            let reports_spec_kitty = report.iter().any(|l| l.contains(".kittify/"));
            let reports_openspec = report.iter().any(|l| l.contains("openspec/"));
            let reports_spec_kit = report.iter().any(|l| l.contains(".specify/"));
            assert_eq!(
                reports_spec_kitty,
                section.contains("### Tracking Spec Kitty tasks with tix"),
                "{detected:?}: report and section disagree on Spec Kitty"
            );
            assert_eq!(
                reports_openspec,
                section.contains("### Tracking OpenSpec work with tix"),
                "{detected:?}: report and section disagree on OpenSpec"
            );
            assert_eq!(
                reports_spec_kit,
                section.contains("### Tracking Spec Kit work with tix"),
                "{detected:?}: report and section disagree on Spec Kit"
            );
            assert_eq!(
                report.len(),
                detected.spec_kitty as usize
                    + detected.openspec as usize
                    + detected.spec_kit as usize
            );
        }
    }

    #[test]
    fn detect_flags_present_directories() {
        let dir = tempfile::tempdir().unwrap();
        assert_eq!(detect(dir.path()), Detected::NONE);
        std::fs::create_dir_all(dir.path().join(".kittify")).unwrap();
        assert_eq!(
            detect(dir.path()),
            Detected {
                spec_kitty: true,
                openspec: false,
                spec_kit: false,
            }
        );
        std::fs::create_dir_all(dir.path().join("openspec")).unwrap();
        assert_eq!(
            detect(dir.path()),
            Detected {
                spec_kitty: true,
                openspec: true,
                spec_kit: false,
            }
        );
        std::fs::create_dir_all(dir.path().join(".specify")).unwrap();
        assert_eq!(
            detect(dir.path()),
            Detected {
                spec_kitty: true,
                openspec: true,
                spec_kit: true,
            }
        );
        // A plain file named `.specify` is not a Spec Kit project.
        let file_dir = tempfile::tempdir().unwrap();
        std::fs::write(file_dir.path().join(".specify"), "not a dir").unwrap();
        assert!(!detect(file_dir.path()).spec_kit);
    }

    #[test]
    fn insert_block_replaces_between_markers() {
        let before = "line 1\nline 2\n";
        let after = "\nline 3\n";
        let existing = format!(
            "{}{}OLD_CONTENT{}{}",
            before, MARKER_START, MARKER_END, after
        );
        let new_block = format!("{}\nNEW_CONTENT\n{}", MARKER_START, MARKER_END);
        let result = insert_block(&existing, &new_block);
        assert!(result.starts_with("line 1\nline 2\n"));
        assert!(result.contains("NEW_CONTENT"));
        assert!(result.contains("line 3"));
        assert!(!result.contains("OLD_CONTENT"));
    }

    #[test]
    fn insert_block_appends_when_no_markers() {
        let existing = "some existing text\n";
        let new_block = "block";
        let result = insert_block(existing, new_block);
        assert!(result.contains("some existing text"));
        assert!(result.contains("block"));
    }

    #[test]
    fn insert_block_appends_when_single_marker() {
        let existing = "text <!-- tix:start --> orphaned\n";
        let new_block = format!("{}\nfresh\n{}", MARKER_START, MARKER_END);
        let result = insert_block(existing, &new_block);
        assert!(result.contains("text <!-- tix:start --> orphaned"));
        assert!(result.contains("fresh"));
        // Original stray marker + the appended block's marker.
        assert_eq!(result.matches(MARKER_START).count(), 2);
        assert_eq!(result.matches(MARKER_END).count(), 1);
    }

    #[test]
    fn insert_block_handles_empty_existing() {
        let existing = "";
        let new_block = format!("{}\nblock\n{}", MARKER_START, MARKER_END);
        let result = insert_block(existing, &new_block);
        assert!(result.contains("block"));
        assert_eq!(result.matches(MARKER_START).count(), 1);
    }

    #[test]
    fn atomic_write_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("AGENTS.md");
        atomic_write(&path, "hello").unwrap();
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
    }
}