use std::path::{Path, PathBuf};
use super::atomic_write;
use crate::config::Config;
use crate::error::{Result, TixError};
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Detected {
pub spec_kitty: bool,
pub openspec: bool,
pub spec_kit: bool,
}
impl Detected {
pub const NONE: Self = Self {
spec_kitty: false,
openspec: false,
spec_kit: false,
};
}
fn has_spec_kitty(root: &Path) -> bool {
std::fs::metadata(root.join(".kittify"))
.map(|m| m.is_dir())
.unwrap_or(false)
}
fn has_openspec(root: &Path) -> bool {
std::fs::metadata(root.join("openspec"))
.map(|m| m.is_dir())
.unwrap_or(false)
}
fn has_spec_kit(root: &Path) -> bool {
std::fs::metadata(root.join(".specify"))
.map(|m| m.is_dir())
.unwrap_or(false)
}
pub fn detect(root: &Path) -> Detected {
Detected {
spec_kitty: has_spec_kitty(root),
openspec: has_openspec(root),
spec_kit: has_spec_kit(root),
}
}
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
}
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)
}
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)
}
const BEADS_START: &str = "<!-- tix:beads:start -->";
const BEADS_END: &str = "<!-- tix:beads:end -->";
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(())
}
const DOGFOODING_SECTION: &str = include_str!("../templates/dogfooding.md");
const OPENSPEC_SECTION: &str = include_str!("../templates/openspec.md");
const SPEC_KIT_SECTION: &str = include_str!("../templates/spec-kit.md");
const TEMPLATES_SECTION: &str = include_str!("../templates/templates.md");
const PROJECTS_SECTION: &str = include_str!("../templates/projects.md");
const EPICS_SECTION: &str = include_str!("../templates/epics.md");
const ROADMAP_SECTION: &str = include_str!("../templates/roadmap.md");
const CYCLE_SCHEDULES_SECTION: &str = include_str!("../templates/cycle-schedules.md");
const CAPACITY_SECTION: &str = include_str!("../templates/capacity.md");
const TIME_SECTION: &str = include_str!("../templates/time.md");
const ANALYTICS_SECTION: &str = include_str!("../templates/analytics.md");
const OTHER_COMMANDS_SECTION: &str = include_str!("../templates/other-commands.md");
const BEADS_SECTION: &str = include_str!("../templates/beads.md");
#[allow(clippy::too_many_lines)]
pub fn build_section(detected: Detected) -> String {
let mut section = format!(
include_str!("../templates/baseline.md"),
start = MARKER_START,
end = MARKER_END,
);
section = section.replace("### Safety", &format!("{TEMPLATES_SECTION}\n\n### Safety"));
section = section.replace(
"### Storage layout",
&format!("{PROJECTS_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{EPICS_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{ROADMAP_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{CYCLE_SCHEDULES_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{BEADS_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{CAPACITY_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{TIME_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{OTHER_COMMANDS_SECTION}\n\n### Storage layout"),
);
section = section.replace(
"### Storage layout",
&format!("{ANALYTICS_SECTION}\n\n### Storage layout"),
);
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")),
);
}
section = section.replace(
"### Safety",
&format!("{}\n### Safety", super::bulk::agents_doc()),
);
section
}
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::*;
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() {
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:?}");
}
}
#[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}");
}
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}");
}
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}");
}
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,
});
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);
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,
}
);
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"));
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");
}
}