tix-tracker 0.6.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
//! `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.
//!
//! 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::error::Result;

/// 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.
pub fn run(cwd: &Path) -> 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 {
            atomic_write(&path, &new)?;
            println!("Updated {}", path.display());
        }
    } else {
        let new = format!("{}\n", block);
        atomic_write(&path, &new)?;
        println!("Created {}", path.display());
    }
    Ok(path)
}

/// The spec-kitty dogfooding section, rendered verbatim (FR-019) when
/// `.kittify/` is detected (see `contracts/agent-dogfooding.md`).
const DOGFOODING_SECTION: &str = "### Tracking Spec Kitty tasks with tix

This project runs Spec Kitty (detected via `.kittify/`). Use tix to track its task issues:

- Filing a Spec Kitty task/work package → `tix create --title \"<wp title>\" --labels spec-kitty`
- Opening an issue → `tix list --status \"todo\"`
- Add the specification / plan of the concrete task in the issue markdown body.
- Marking a task done → `tix move <number> done` (interactive) or
  `tix move <number> done --commit <hash>` (non-interactive)
- Commenting on a task issue → `tix edit <number>` (body/comment section is plain markdown)
- **REQUIRED — always use `tix mcp` to create and move tix issues** while
  tracking Spec Kitty tasks. The MCP server is wired into this repo
  (`opencode.jsonc`), so in opencode call the `tix_*` tools directly:
  `create_issue` to file a new task issue, `move_issue` to advance it (moving
  to `done` records the commit via the `commit` argument), and the read tools
  (`list_issues`, `get_issue`, `board_status`, `search_issues`) to inspect the
  board. Do NOT hand-edit issue files or drive status changes through the CLI
  prompts. See `docs/mcp.md`.
- **REQUIRED — mirror every Spec Kitty task as a tix issue.** For each finished
  mission/work package, create tix issues replicating: (1) every spec-kitty
  subtask (T0xx) as its own issue, and (2) every review finding / follow-up
  item. All get `--labels spec-kitty`. Add the task spec/plan in the issue body.
  Mark matching issues `done --commit <hash>` when the lane/commit lands.";

/// 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 = "### Tracking OpenSpec work with tix

This project uses OpenSpec (detected via `openspec/`). Use tix to track its changes and tasks:

- Filing an OpenSpec change/task → `tix create --title \"<change>: <task>\" --labels openspec`
- Add the change's proposal/spec/plan in the issue markdown body.
- Opening an issue → `tix list --status \"todo\"`
- Marking a task done → `tix move <number> done` (interactive) or
  `tix move <number> done --commit <hash>` (non-interactive)
- Commenting on a task issue → `tix edit <number>` (body/comment section is plain markdown)
- **REQUIRED — always use `tix mcp` to create and move tix issues** while
  tracking OpenSpec work. The MCP server is wired into this repo
  (`opencode.jsonc`), so in opencode call the `tix_*` tools directly:
  `create_issue` to file a new task issue, `move_issue` to advance it (moving
  to `done` records the commit via the `commit` argument), and the read tools
  (`list_issues`, `get_issue`, `board_status`, `search_issues`) to inspect the
  board. Do NOT hand-edit issue files or drive status changes through the CLI
  prompts. See `docs/mcp.md`.
- **REQUIRED — mirror every OpenSpec change/task as a tix issue.** For each finished
  change, create tix issues replicating: (1) every task in the change's `tasks.md`
  as its own issue, and (2) every review finding / follow-up item. All get
  `--labels openspec`. Add the task spec/plan in the issue body. Mark matching
  issues `done --commit <hash>` when the change/task lands.";

/// 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 = "### Tracking Spec Kit work with tix

This project uses GitHub Spec Kit (detected via `.specify/`). Use tix to track its tasks:

- Filing a Spec Kit task/work package → `tix create --title \"<feature>: <task>\" --labels speckit`
- **Mirror the whole tree idempotently** → `tix import --target speckit` (one
  parent issue per feature, one subissue per task, plus bugs and assessments;
  safe to re-run after `/speckit-analyze` appends tasks).
- Add the feature's spec/plan/task context in the issue markdown body.
- Opening an issue → `tix list --status \"todo\"`
- Marking a task done → `tix move <number> done` (interactive) or
  `tix move <number> done --commit <hash>` (non-interactive)
- Commenting on a task issue → `tix edit <number>` (body/comment section is plain markdown)
- **REQUIRED — always use `tix mcp` to create and move tix issues** while
  tracking Spec Kit tasks. The MCP server is wired into this repo
  (`opencode.jsonc`), so in opencode call the `tix_*` tools directly:
  `create_issue` to file a new task issue, `move_issue` to advance it (moving
  to `done` records the commit via the `commit` argument), and the read tools
  (`list_issues`, `get_issue`, `board_status`, `search_issues`) to inspect the
  board. Do NOT hand-edit issue files or drive status changes through the CLI
  prompts. See `docs/mcp.md`.
- **REQUIRED — mirror every Spec Kit task as a tix subissue.** Create one parent
  issue per feature (`tix create --title \"<feature>\" --labels speckit`) and file
  every task in `specs/NNN-<feature>/tasks.md` as its own **child** issue
  (`tix create --title \"<task>\" --parent <feature-issue>`); bugs
  (`.specify/bugs/<slug>/`) and assessments (`.specify/assessments/<slug>/`) each
  get their own `--labels speckit` issue. Use `tix parent children
  <feature-issue>` to see the decomposition. Mark matching issues
  `done --commit <hash>` when the feature lands.
- **REQUIRED — record findings and progress on the issue as you work it.** When
  you run `/speckit-analyze` (or otherwise assess a task), append its findings and
  every decision taken to that issue's comments (`tix edit <number>`, or MCP
  `comment_on_issue`), and keep the status moving — `tix move <number> \"in
  progress\"` while in flight, `done --commit <hash>` when it lands. A finding or a
  decision that lives only in the chat is one the board does not have.
- **`/speckit-taskstoissues` is GitHub-only.** Do not use it as the tix board:
  it requires a GitHub `origin` remote and the GitHub MCP tools, and creates
  duplicate issues. Mirror to tix as above instead.";

/// 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).
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!(
        "{start}
## tix — issue tracker (AI agent usage)

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

### Viewing
- `tix list`                              list issues (board ownership)
- `tix list --status \"in progress\"`       filter by column
- `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

### 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 — 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` is refused; batch archive/restore is the planned archive
  mission's scope, not a second implementation of this one

### Parent issues (a decomposition, not a dependency)
- An issue has **at most one** parent, stored as a plain `parent:` number. 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

### 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

### 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 `*.tixbak` and `*.md.bak` in `.gitignore` (a `# tix:start` /
  `# tix:end` block) so the backups `tix` writes before rewriting an issue are
  never committed; `tix clean` removes those backup files already on disk
  (confirming each, or all at once with `-f`/`--force`).
{end}",
        start = MARKER_START,
        end = MARKER_END,
    );
    // 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 {
    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() {
        // Every detection state advertises the full CLI surface; the guard's
        // `expected` list is the arbiter and stays in sync with cli.rs.
        for detected in states() {
            let section = build_section(detected);
            let expected = [
                "init",
                "init-agents",
                "create",
                "edit",
                "move",
                "view",
                "render",
                "clean",
                "list",
                "status",
                "delete",
                "link",
                "unlink",
                "chain",
                "parent",
                "export",
                "import",
                // WP09 T042: `tix workflow` was added in WP04 and this list is the
                // arbiter that keeps the generated section in step with `cli.rs`.
                // WP04 recorded the omission rather than fixing it, and the recorded
                // omission is exactly how a `tix workflow` subcommand can ship with
                // no agent-facing documentation.
                "workflow",
            ];
            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]
    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 list`                              list issues (board ownership)
- `tix list --status \"in progress\"`       filter by column
- `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

### 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 — 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` is refused; batch archive/restore is the planned archive
  mission's scope, not a second implementation of this one

### Parent issues (a decomposition, not a dependency)
- An issue has **at most one** parent, stored as a plain `parent:` number. 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

### 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

### `tix bulk`

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

```text
tix bulk edit     --select <list> <patch flags> [--preview|--apply]
tix bulk link     --select <list> --target <sel> --type <kind> [--preview|--apply]
tix bulk unlink   --select <list> --target <sel> --type <kind> [--preview|--apply]
tix bulk archive  --select <list> [--preview|--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`.
- 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`
- `archive` is understood but not performable in this build: it delegates to
  mission issue-archive-restore-01M3C4JF, which has not been implemented. A request for it is
  refused **per selected issue**, so the refusal names what it refused.

### 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 `*.tixbak` and `*.md.bak` in `.gitignore` (a `# tix:start` /
  `# tix:end` block) so the backups `tix` writes before rewriting an issue are
  never committed; `tix clean` removes those backup files already 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!(detection_report(Detected::NONE).is_empty());
        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");
    }
}