use std::path::{Path, PathBuf};
use crate::error::Result;
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,
}
impl Detected {
pub const NONE: Self = Self {
spec_kitty: false,
openspec: 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)
}
pub fn detect(root: &Path) -> Detected {
Detected {
spec_kitty: has_spec_kitty(root),
openspec: has_openspec(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());
}
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)
}
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.";
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.";
pub fn build_section(detected: Detected) -> String {
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.
{end}",
start = MARKER_START,
end = MARKER_END,
);
let mut tracking = Vec::new();
if detected.spec_kitty {
tracking.push(DOGFOODING_SECTION);
}
if detected.openspec {
tracking.push(OPENSPEC_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 {
let has_start = existing.contains(MARKER_START);
let has_end = existing.contains(MARKER_END);
match (has_start, has_end) {
(true, true) => {
let start = existing.find(MARKER_START).unwrap();
let end = existing.find(MARKER_END).unwrap() + MARKER_END.len();
let region = &existing[start..end];
if region.trim() == block.trim() {
return existing.to_string();
}
let before = &existing[..start];
let after = &existing[end..];
format!("{before}{block}{after}")
}
(true, false) | (false, true) => {
let trimmed = existing.trim_end();
format!("{}\n{}\n", trimmed, block)
}
(false, false) => {
let trimmed = existing.trim_end();
if trimmed.is_empty() {
format!("{}\n", block)
} else {
format!("{}\n\n{}\n", trimmed, block)
}
}
}
}
fn atomic_write(path: &Path, content: &str) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let tmp = parent.join(".tix-agents-tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn states() -> [Detected; 4] {
[
Detected::NONE,
Detected {
spec_kitty: true,
openspec: false,
},
Detected {
spec_kitty: false,
openspec: true,
},
Detected {
spec_kitty: true,
openspec: true,
},
]
}
#[test]
fn build_section_contains_every_clap_subcommand() {
for detected in states() {
let section = build_section(detected);
let expected = [
"init",
"init-agents",
"create",
"edit",
"move",
"view",
"render",
"list",
"status",
"delete",
"link",
"unlink",
"chain",
"parent",
"export",
"import",
"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:?}");
}
}
#[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:end -->";
assert_eq!(build_section(Detected::NONE), golden);
}
#[test]
fn build_section_none_omits_both_sections() {
let s = build_section(Detected::NONE);
assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
assert!(!s.contains("### Tracking OpenSpec work with tix"));
}
#[test]
fn build_section_spec_kitty_contains_dogfooding_section() {
let s = build_section(Detected {
spec_kitty: true,
openspec: 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,
});
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_both_contains_both_sections_in_order() {
let s = build_section(Detected {
spec_kitty: true,
openspec: true,
});
let kitty = s.find("### Tracking Spec Kitty tasks with tix").unwrap();
let openspec = s.find("### Tracking OpenSpec work with tix").unwrap();
assert!(kitty < openspec, "spec-kitty section must precede openspec");
assert_eq!(
s.matches("### Tracking Spec Kitty tasks with tix").count(),
1
);
assert_eq!(s.matches("### Tracking OpenSpec 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"));
}
#[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,
});
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,
});
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,
}),
vec!["Detected Spec Kitty (via .kittify/)".to_string()]
);
assert_eq!(
detection_report(Detected {
spec_kitty: false,
openspec: true,
}),
vec!["Detected OpenSpec (via openspec/)".to_string()]
);
assert_eq!(
detection_report(Detected {
spec_kitty: true,
openspec: true,
}),
vec![
"Detected Spec Kitty (via .kittify/)".to_string(),
"Detected OpenSpec (via openspec/)".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("Spec Kitty"));
let reports_openspec = report.iter().any(|l| l.contains("OpenSpec"));
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!(
report.iter().any(|l| l.contains(".kittify/")),
reports_spec_kitty,
"{detected:?}: Spec Kitty line must name the marker dir"
);
assert_eq!(
report.iter().any(|l| l.contains("openspec/")),
reports_openspec,
"{detected:?}: OpenSpec line must name the marker dir"
);
}
}
#[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,
}
);
std::fs::create_dir_all(dir.path().join("openspec")).unwrap();
assert_eq!(
detect(dir.path()),
Detected {
spec_kitty: true,
openspec: true,
}
);
}
#[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");
}
}