The usual way to give Cursor a Jira or Linear ticket is middleware: an MCP server, a token, a tool schema. It works until the MCP process dies, the token rotates, and the agent spends a turn explaining it cannot list issues. You are back in the browser. That is a lot of moving parts for "read this task."
The agent already edits your .ts and .rs files. Tickets do not need a different transport. Put them on disk, in Git, next to the code.
flowchart LR
agent[Cursor Agent]
yaml[Ticket and wiki YAML]
git[Git]
sqlite[(SQLite cache)]
ui[Gitoza UI]
agent -->|read and write| yaml
yaml --> git
yaml -->|incremental index| sqlite
sqlite -->|lists and search| ui
yaml -->|open detail| ui
Same folder for the agent and the UI. Git keeps history. SQLite is only a cache so lists stay fast.
Remote tickets break the loop the agent is good at
Read a file, change a file, show a diff. That is the loop. A remote ticket is none of those things.
The first time I wired Cursor to Jira through MCP it lasted about a day. Then the token rotated, the container needed a restart, and the chat window had no tools. Firewall rules and mcp.json drift are not "project context." They are ops for a read query.
The token is usually wider than the repo, too. Scoping every Jira project is tedious, so people ship read:jira-work and call it done. That ACL does not show up in the PR.
And when the agent updates a remote issue, the write is live. No staging area. If it hallucinates a status flip across ten tickets, there is no git reset. You get whatever the API already committed.
Tickets as YAML in the repo
If the agent can patch src/lib.rs, it should be able to patch the ticket that says why. In Gitoza the tree looks like this:
.gitoza/
tasks/tickets/
Gitoza_handbook/
.project.yaml # ticket_prefix: GITO
GITO-AIDEMO.yaml # one file = one ticket
GITO-WIKPDF.yaml
releases/
1.2.0.yaml
wiki/
01-overview/
W-HBK000.yaml
03-tickets/
W-HBK010.yaml
One project folder, one file per ticket, and the filename stem is the ID (GITO-AIDEMO). There are no nested hierarchies—just a flat, predictable tree that both grep and an LLM context window can parse in milliseconds.
The schema is small on purpose. Identity lives in the filename, so you can omit ticket_id from the YAML. Extra keys are optional:
---
title: "AI Demo: Document native lifecycle from open to release"
type: task
status: open
priority: medium
reporter: Cursor Agent
tags:
- native
- demo
- qa
---
We need a full flowchart of how native tickets move from open to release.
## Problem
Remote PM tools give agents no staging area and no rollback.
## Acceptance criteria
- [ ] Mermaid lifecycle diagram in this body
- [ ] Linked from [[wiki:W-HBK010|Tickets overview]]
That is the document Gitoza renders and the document Cursor opens. Wiki pages under .gitoza/wiki/ use the same shape: title, optional tags, Markdown body. No second format to teach the model.
Give it one example file and it copies the neighbor. It is not guessing a Jira JSON blob through a tool schema.
A prompt that actually works — no MCP, no API key:
Search .gitoza/tasks/tickets/Gitoza_handbook/ for any ticket about
exporting wiki pages to PDF.
If none exists, CREATE:
.gitoza/tasks/tickets/Gitoza_handbook/GITO-WIKPDF.yaml
Frontmatter: title, type: task, status: open, priority: medium,
tags: [handbook, wiki], reporter: Cursor Agent.
Body: problem statement, 3-bullet AC, and [[wiki:W-HBK010|Tickets overview]].
If a match exists, report its id. Do not duplicate. No MCP.
The agent writes a file. Git sees an untracked path. Done.
SQLite is a cache, not the database
YAML on disk is the source of truth. SQLite is there so the UI can virtualize thousands of rows without walking the tree on every keystroke.
When Cursor (or you) drops a new ticket, we do not rebuild the world. git status --porcelain, keep YAML under .gitoza/, upsert those rows. Past 200 changed files we just reindex; that is the escape hatch, not the common path.
// rust-backend/src/lib.rs
pub(crate) const INCREMENTAL_THRESHOLD: usize = 200;
if changed_tickets.len() > INCREMENTAL_THRESHOLD {
let _ = tickets::reindex_tickets_for_repo(&conn, repo_path, slug);
} else {
tickets::apply_incremental_ticket_changes(
&conn, repo_path, slug, changed_tickets, &ticket_audit,
)?;
}
Per file: parse, upsert, or delete the row if the file is gone.
// rust-backend/src/tickets.rs
pub(crate) fn apply_incremental_ticket_changes(
conn: &Connection,
repo_path: &Path,
slug: &str,
changed: &[String],
audit: &crate::AuditStampSource,
) -> Result<()> {
for rel in changed {
if !is_ticket_yaml_path(rel) {
continue;
}
let abs = repo_path.join(rel);
if abs.is_file() {
let Some(d) = read_ticket_yaml(&abs)? else { continue };
if validate_ticket_file_path_for_write(rel).is_err() {
continue;
}
upsert_ticket_db(conn, detail_to_upsert(&d, rel, slug, audit))?;
} else {
conn.execute(
"DELETE FROM tickets WHERE repo_slug = ? AND file_path = ?",
params![slug, rel],
)?;
}
}
Ok(())
}
Lists hit SQLite. Opening a ticket re-reads the YAML. updated_at / updated_by live in the index, not in the file, so agents do not invent timestamps. If the cache is stale, delete the rows and scan disk again.
Video: local agent, no MCP
Copy the workspace path from Gitoza, open that folder in Cursor, ask it to find / create / edit tickets. Gitoza picks up the files when they hit disk.
Handbook used in the recording: github.com/gitoza-io/gitoza-handbook.
Collaboration: one branch, linear rebase
The usual objection is merge-conflict hell in .gitoza/tasks/. That happens if you copy GitHub Flow onto tickets: three branches, three people, one GITO-1.yaml. We don't.
One work branch, named gitoza. Publish is fetch → add → commit → rebase onto origin/gitoza → push.
flowchart TB
fetch[fetch origin]
add[add YAML]
commit[commit on gitoza]
rebase[rebase onto origin/gitoza]
conflict{Same ticket edited?}
push[push]
pick[Resolve in UI]
history[Linear history]
fetch --> add --> commit --> rebase --> conflict
conflict -->|no| push
conflict -->|yes| pick --> push
push --> history
// crates/sync-engine/src/publish.rs
let rebase = git_run(
self.repo_path,
&["rebase", "-Xfind-renames=100%", origin_ref.as_str()],
)
.await;
Different tickets rebase cleanly. The same ticket is a real conflict, and the UI makes you pick a side. That is rarer than people expect, because each ticket is a small file.
If the agent wrecks a batch of YAML, you already know the fix. In the app you can restore / discard local changes the same way you would in VS Code — or drop to Git:
git checkout -- .gitoza/tasks/tickets/
# committed locally, not pushed:
git reset --hard HEAD~1
Nobody has to pick a feature branch. They see a linear timeline on gitoza.
Because tickets live locally, the whole backlog works offline on a train or behind a strict enterprise firewall—zero outbound webhooks required.
Try it
Gitoza is the desktop app that indexes and renders this tree. Clone the handbook, open the same path in Cursor, and ask the agent to find a ticket. No MCP.
Top comments (0)