# Jt

> Manage Jira tickets with the jt CLI using local ticket files, computed diffs, and a human-approved push. Use for reading, searching, summarizing, creating, editing, linking, labeling, transitioning, commenting on, or deleting Jira tickets, assigning sprint work, or reviewing board changes.

- Skill: `owenoclee/jt` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add owenoclee/jt`
- Raw SKILL.md: https://api.skillmd.com/api/skills/owenoclee/jt/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: owenoclee (https://skillmd.com/u/owenoclee)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/owenoclee/jt

---


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

```text
.jira/config.json
```

Locate a workspace with two checks, in this order — run these exact commands rather
than improvising a search:

1. `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.
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-limited
   `find` is 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`:

```sh
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

```sh
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:

1. Compiles committed state, never the working files.
2. Checks that Jira has not changed since the last fetch.
3. Starts a detached review server, prints `review page: <url>`, and returns
   immediately. Nothing has been sent at this point.
4. 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:

1. **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.
2. **Run `jt await`** — as a background task where supported, so the session is not
   blocked. It reports each outcome exactly once. Exit status is `0` for approved and
   pushed (created keys are listed), `2` for changes requested (**Request changes**
   sends nothing; per-ticket notes arrive on stdout), and `1` for 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. Like `git restore --staged`.
- `jt restore ID` — resets the working file to the committed copy if one is staged,
  otherwise to base; also undoes a staged `jt rm`, `jt archive` or `jt unarchive`.
  Like `git checkout -- <file>`. It
  never touches the committed copy: to abandon a change that is already committed,
  run `jt uncommit` first, then `jt 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 run `jt diff` after `jt resolve` and 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.json` is the sole exception for
  deliberate workspace configuration changes such as `sync.jql` or `customFields`.
- Never ask for, read, write, or print the API token, credentials file, or
  `$JIRA_API_TOKEN`. Before initialization or remote operations, run `jt config show` to
  verify whether authentication is configured. If authentication is missing, tell the
  user to configure Jira credentials using the authentication mechanism supported by
  their `jt` installation. Do not ask them to paste credentials into chat. Continue only
  after `jt config show` reports a credential source. An API token expires; when one
  has, `jt` stops 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 source `jt config show` names. Do not retry in a loop.
- The moment `jt push` prints 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 await` is one
  indivisible sequence; a turn must not end with a review pending or an outcome
  uncollected.
- `jt push` is 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 an `id`.
- Existing ticket files retain their `<KEY>.json` name and `key` field.
- 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 KEY` deletes
  permanently and is never the default: propose archiving first, and use `jt rm` only
  when the user asks for deletion in those words. Either way Jira changes only after
  commit and an approved push. `jt untrack ID` removes all local state without
  changing Jira. Do not hand-delete tracked files.
- Check `jt status` before 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:

```jsonc
{
  "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

```sh
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.

```sh
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.

