jt agent contract
jt treats Jira like a remote VCS. Edit ticket files, inspect computed diffs, commit
the intended state, and let the user approve the whole push in a browser.
Workspace discovery and setup
A directory is a jt workspace if it contains:
.jira/config.json
Locate a workspace with two checks, in this order — run these exact commands rather than improvising a search:
jt config show— it walks up from the current directory itself. If it prints a workspace, use that one (the nearest ancestor); if it errors, move to step 2.ls -d ~/jira/*/.jira/config.json— each hit is a workspace exactly one directory under the conventional durable location~/jira/(for example~/jira/SBX). Pick the one whose config matches the project and confirm with the user. The config file always sits at<workspace>/.jira/config.json, so a recursive or depth-limitedfindis never needed — and depth-limited variants have wrongly concluded that no workspace exists.
If no workspace exists, propose creating ~/jira/<PROJECT_KEY>/ and ask for confirmation
before creating that directory or initializing it.
A .jira/config.json that is malformed or points to the wrong project is a setup problem:
report it rather than silently creating a second workspace elsewhere.
To initialize a new workspace, obtain any missing values:
- Jira site or board URL. A pasted board URL is acceptable; derive the base URL, project key, and board ID when possible.
- Jira account email, if it is not already known.
- Project key, if it cannot be derived from the URL.
After initialization, run jt meta sync and jt meta show. Inspect the available issue
types, statuses, priorities, sprints, and custom fields. Present the user with a concise
selection of custom fields to track; configure only fields the user explicitly selects.
Do not configure dozens of custom fields by default, it is likely the user only cares
about a small subset of possible fields.
The default workspace mirrors the project's sync.jql:
jt pull
jt changes
jt status
jt pull synchronizes Jira and rebases non-overlapping remote edits. jt changes
reports remote changes since the last acknowledgment, compactly: description edits
appear as ±line counts (jt changes --full prints the line diffs when they matter).
Surface non-empty output to the user; run jt changes --ack only after it has been
seen or handled — it records the ack and prints counts only, without repeating the
diffs. Changes the user approved on a push review page are recorded as seen
automatically and do not reappear here.
Use jt fetch KEY... when tracking individual tickets rather than a mirror.
Change workflow
jt fetch PROJ-123
# edit tickets/PROJ-123.json
jt diff PROJ-123
jt commit PROJ-123 -m "explain the change"
jt push # prints the review URL and returns immediately — nothing sent yet
open <url> # hand the page to the user (xdg-open on Linux)
jt await # blocks until the human decides, then reports the outcome
jt push is the only command that mutates Jira. It:
- Compiles committed state, never the working files.
- Checks that Jira has not changed since the last fetch.
- Starts a detached review server, prints
review page: <url>, and returns immediately. Nothing has been sent at this point. - The entire changeset is sent only when the user selects Approve & push on that page.
Pushing is three steps, never one
jt push returning is not a finished push — it is a page nobody has looked at yet.
Every push owes two more steps, immediately and in this order:
- Open the page for the user the moment the URL prints —
open <url>on macOS,xdg-open <url>on Linux. Opening it is part of running a push, not an optional courtesy: do not paste the URL and wait for the user to act. Then tell them it is ready. Only if no OS opener is available, give them the URL and say it must be opened by hand. Never fetch the page, inspect it, or drive it with browser automation: the opener hands it to the human, and approval belongs to them. - Run
jt await— as a background task where supported, so the session is not blocked. It reports each outcome exactly once. Exit status is0for approved and pushed (created keys are listed),2for changes requested (Request changes sends nothing; per-ticket notes arrive on stdout), and1for staleness, push failure, or a cancelled review.
Until jt await returns, the push is outstanding: do not report it as done, do not
claim anything reached Jira, and do not move on to unrelated work. If you are about to
end your turn with a review still pending, run jt await first. jt push prints both
remaining steps under the URL — treat that as a live instruction, not a summary.
The review page never expires — the human takes as long as the changeset needs.
jt await itself gives up after 600 seconds (--timeout SECS adjusts this); that
expiry collects nothing and is harmless — simply run jt await again. Never cancel a
review because a wait expired: the page is still live and the human may be mid-read.
jt cancel is for deliberately withdrawing a review (nothing is sent) — for example
when the user asks for edits outside the page — and is refused once the decision has
landed. A new jt push refuses to start while a review is pending or an outcome is
uncollected.
jt push --dry-run prints the compiled operations without serving or sending.
If the user requests edits, update the working file and commit another round. To omit
a ticket while keeping its working edits, run jt uncommit ID; the next review page
marks it withdrawn rather than silently dropping it. To discard working edits, run
jt restore ID.
Layers and undo verbs
Ticket state lives in four layers: working (tickets/*.json, yours to edit),
committed (the staged changeset push will send), base (last-fetched remote
state), and seen (the news baseline behind jt changes). Each undo verb targets
exactly one of them — pick by which layer you need to move:
jt uncommit ID— removes the committed copy only; the working file keeps your edits. Likegit restore --staged.jt restore ID— resets the working file to the committed copy if one is staged, otherwise to base; also undoes a stagedjt rm,jt archiveorjt unarchive. Likegit checkout -- <file>. It never touches the committed copy: to abandon a change that is already committed, runjt uncommitfirst, thenjt restore.jt untrack ID— removes every local layer for the ticket; Jira is untouched.jt resolve KEY— after a pull conflict only: advances base to the latest remote and keeps your whole working file as the desired state. Fields you never edited may still hold pre-conflict values, so the next diff can propose reverting the remote's newer edits to them — always runjt diffafterjt resolveand fast-forward any such field by hand before committing.
Hard rules
- Edit ticket files only in
tickets/. - Do not edit tool-owned
.jira/files..jira/config.jsonis the sole exception for deliberate workspace configuration changes such assync.jqlorcustomFields. - Never ask for, read, write, or print the API token, credentials file, or
$JIRA_API_TOKEN. Before initialization or remote operations, runjt config showto verify whether authentication is configured. If authentication is missing, tell the user to configure Jira credentials using the authentication mechanism supported by theirjtinstallation. Do not ask them to paste credentials into chat. Continue only afterjt config showreports a credential source. An API token expires; when one has,jtstops every remote command with "your Jira API token was rejected". Jira itself does not fail cleanly here — it answers a rejected token with 404s and empty search results — so treat that error as authoritative and never as evidence that tickets were deleted or a project is empty. It is a user action: tell them to mint a new token and update the sourcejt config shownames. Do not retry in a loop. - The moment
jt pushprints the review URL, open it for the user with the OS opener. Beyond that, never interact with a review page yourself — no fetching, no browser automation; the decision on it is the human's. - Never leave a review uncollected.
jt push→ open the URL →jt awaitis one indivisible sequence; a turn must not end with a review pending or an outcome uncollected. jt pushis itself the approval gate: nothing reaches Jira without the human's explicit decision on the review page. Never ask permission to run it — that gates the gate. Commit, push, and hand the user the URL.- Existing comments are append-only. Add a comment without an
id; never edit or remove one with anid. - Existing ticket files retain their
<KEY>.jsonname andkeyfield. - To take a ticket off the board, prefer
jt archive KEY— it is reversible (jt unarchive KEY) and needs a Jira Premium or Enterprise plan; if the push fails because the plan lacks it, say so and ask before falling back.jt rm KEYdeletes permanently and is never the default: propose archiving first, and usejt rmonly when the user asks for deletion in those words. Either way Jira changes only after commit and an approved push.jt untrack IDremoves all local state without changing Jira. Do not hand-delete tracked files. - Check
jt statusbefore committing. If unrelated parked edits exist, commit explicit IDs rather than committing everything.
Ticket files
jt schema prints the strict JSON Schema. A typical file is:
{
"key": "PROJ-123",
"updated": "2026-07-21T09:14:03.000+0000",
"project": "PROJ",
"type": "Story",
"summary": "…",
"status": "In Progress",
"description": "markdown or null",
"labels": ["api"],
"parent": "PROJ-100",
"sprint": "Sprint 42",
"assignee": "user@example.com",
"priority": "High",
"links": [{ "type": "blocks", "to": "PROJ-99" }],
"comments": [{ "id": "existing-id", "body": "read-only" }, { "body": "new" }],
"fields": { "Story Points": 5, "Components": ["api"] }
}
key and updated are absent on new tickets. status may also be absent, allowing
Jira's default. parent, sprint, assignee, and priority may be null.
Markdown supports headings, paragraphs, lists, fenced code, blockquotes, rules,
emphasis, inline code, strikethrough, and links. Tables, images, and raw HTML are
rejected at commit. If descriptionLossy is true, editing description replaces
remote content that Markdown could not represent.
Jira stores rich text as ADF, which is stricter than Markdown in three places. Each is
rejected at jt commit, naming the field, the line, and the offending text:
- A code span carries no other formatting.
**bold `code`**and*`code`*are invalid; write**bold** `code`instead. A link around a code span is the one legal combination:[`code`](https://example.com). - A blockquote holds only paragraphs, lists and code blocks. No heading, rule or
nested quote inside
>. - A list item holds only paragraphs, lists and code blocks. No heading, rule or blockquote indented under a bullet.
These are constraints of Jira's format, not of jt. Sent to Jira, each is refused with
400 INVALID_INPUT and nothing else — no field, no line, no rule named — which is why
they used to surface as an unexplained failure after the user had already approved the
push. jt commit now refuses them first and says exactly where. Check the rendered
result in jt diff before committing.
Creating tickets
jt new my-epic --type Epic --summary "The epic"
jt new my-story --type Story --parent @my-epic
Pending creations use @name in parent and link targets, from other new tickets and
from existing ones alike (for example, re-parenting PROJ-123 under @my-epic). One
push creates the new tickets first, replaces references with Jira keys, and renames the
files.
Conflicts and aliases
- If push reports staleness, run
jt pull, review, and recommit if necessary. - If pull reports a conflict, edit the working file to the desired final state, run
jt resolve KEY, review the diff, and commit again. - If a sprint, field, status, priority, issue type, or link name is unknown, run
jt meta sync.
Reading
The synced mirror is a local database: tickets/*.json hold every ticket sync.jql
matches, as ordinary JSON files. For any task that finds, filters, counts, or
summarizes existing tickets, run jt pull to freshen the mirror, then read and grep
the ticket files directly — do not fetch tickets one by one or query Jira for data
the workspace already holds.
jt show KEY # working copy
jt show KEY --base # last fetched copy
jt show KEY --committed # staged copy
jt diff --committed # exactly what push will send
jt show --web [KEY...]
jt diff --web
jt changes --web
jt log
Terminal output is compact by default; --full on jt changes, jt log, and
jt push --dry-run prints the long form (description line diffs, every journal op,
raw ADF bodies). Prefer the compact defaults unless the detail is needed.
The --web read commands produce a page or path for the user. As with review pages,
do not load localhost pages yourself.