Tracks the Jira and GitHub issues and pull requests touched during a conversation, and what was done to each, in a toggleable pane.

Keeps a running record of every Jira issue and GitHub issue or pull request touched in the current Claude Code conversation, and of what was done to each: created, commented, edited, transitioned, closed, merged, reviewed. You can see it in a collapsible pane that you toggle with a button or a command, in the terminal and in the desktop app.
Tickets: 2 changed, 1 viewed [ Hide tickets ]
┌ Tickets ─────────────────────────────────────────────┐
│ [ Changes only ] [ Expand all ] │
│ │
│ Jira │
│ ▸ PROJ-123 Checkout times out · 2 actions, last: moved to In Review
│ │
│ GitHub │
│ ▾ acme/web#42 Fix retry loop · 2 actions │
│ https://github.com/acme/web/pull/42 │
│ ● opened pull request · github:create_pull_request
│ ● commented · "Rebased on main" · Bash │
└──────────────────────────────────────────────────────┘
claude plugin marketplace add ilionblaze/agora
claude plugin install ticket-tracker@agora
/tickets: toggles the pane; /tickets open and /tickets close set it explicitly.ctrl+x x, or the toggle.| Command | |
|---|---|
/tickets | Toggle the pane: open it if it's closed, close it if it's open |
/tickets open | Open the pane (does nothing if it's already open) |
/tickets close | Close the pane (does nothing if it's already closed) |
/tickets summary | Print the changes as text in the transcript (/tickets summary all includes views). Claude reads it too, so you can follow up with "post that as a status update". |
/tickets all · /tickets changes | Show or hide read-only activity in the pane |
/tickets rescan | Rebuild the list from the conversation history |
/tickets clear | Forget everything tracked so far |
Only /tickets summary writes to the transcript. The other commands reply with a short toast, if they reply at all, so Claude never reads them. Running /tickets with an unknown argument shows this list as a toast.
The plugin makes no model calls. It reads tool calls and the session history with plain string matching, and draws the band and pane locally, so tracking, the buttons and every command except summary add no tokens. /tickets summary adds its text to the conversation, about 20–40 tokens per item, because it's meant for Claude to read.
The list is built from the tool calls in the conversation. When a session starts (including --resume and --continue) the plugin reads the history the session already has, and after that it records each tool call as it finishes, from the main conversation and from subagents alike.
| Source | Recognized | |
|---|---|---|
GitHub MCP server (mcp__github__*) | issue read/create/update/close/reopen, comments, sub-issues; pull request read/create/update/merge, reviews, review comments and replies, branch updates, auto-merge, Copilot review requests | |
Atlassian / Jira MCP servers (any server or tool whose name contains jira or atlassian) | get, create, edit (with the fields changed), transition (with the target status), comment, assign, link, worklog | |
gh CLI via Bash | `gh issue | pr view/create/comment/edit/close/reopen/merge/review/ready/checkout/..., including -R owner/repo`, URLs, and chained commands. Created numbers are read from the output URL. |
jira / acli CLIs via Bash | any command containing an issue key, classified by its verb (move/transition, comment, assign, edit, create, view) | |
| WebFetch | GitHub issue/PR URLs and Jira /browse/KEY URLs, as views |
Searches and listings (JQL searches, gh pr list) aren't recorded because they don't target a specific item. Failed calls stay in the list, marked ✗ (failed), so you can see what didn't go through.
gh commands that don't name a repository are recorded as #123 (the current repository).claude --plugin-dir ./plugins/ticket-tracker # load from disk; saves hot-reload
claude plugin validate ./plugins/ticket-tracker
claude plugin test ./plugins/ticket-tracker
hooks/register.tsx holds the hooks: tool.call records activity, session.start backfills from history, ui.render draws the band and the pane, and command.run answers /tickets.hooks/extract.ts holds the pure logic that turns one tool call into ticket activity. To recognize a new tool or CLI, add it there and add a case to tests/tracker.test.tsx.types/index.d.ts is the state contract ($.state keys under ticket-tracker).The function-hooks plugin API is in early access and may change between Claude Code releases.
hooks/register.tsx 311 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ticket, TicketAction } from '../types'
5import { extract, merge, summarize } from './extract'
6import type { ToolCallRecord } from './extract'
7
8const PANE = 'ticket-tracker'
9const TITLE = 'Tickets'
10
11const tickets = atom({ plugin: 'ticket-tracker', key: 'tickets' } as const, [])
12const expanded = atom({ plugin: 'ticket-tracker', key: 'expanded' } as const, [])
13const showReads = atom({ plugin: 'ticket-tracker', key: 'showReads' } as const, false)
14const isOpen = atom({ plugin: 'ticket-tracker', key: 'isOpen' } as const, false)
15
16const USAGE = [
17 '/tickets toggle the Tickets pane',
18 '/tickets open open the pane (no change if already open)',
19 '/tickets close close the pane (no change if already closed)',
20 '/tickets summary print what was touched (changes only); `summary all` includes views',
21 '/tickets all show views as well as changes in the pane',
22 '/tickets changes show only changes in the pane',
23 '/tickets rescan rebuild the list from the conversation history',
24 '/tickets clear forget everything tracked so far',
25].join('\n')
26
27export const register: Register = on => {
28 on('session.start', async ($, e, next) => {
29 await $.command.register({
30 name: 'tickets',
31 description: 'Toggle the pane of Jira / GitHub items touched in this conversation',
32 argumentHint: '[open|close|summary|all|changes|rescan|clear]',
33 })
34 await rescan($)
35
36 return next(e)
37 })
38
39 // Only `summary` answers with text: a command's text becomes a transcript
40 // row the model reads, so every other subcommand replies with a toast,
41 // which the person sees and the model never does.
42 on('command.run', { command: 'tickets' }, async ($, e) => {
43 const [arg = '', more = ''] = e.args.trim().toLowerCase().split(/\s+/)
44
45 switch (arg) {
46 case 'summary':
47 return { text: summarize(await read($, tickets), more === 'all') }
48 case '':
49 case 'toggle':
50 await toggle($)
51 break
52 case 'open':
53 await open($)
54 break
55 case 'close':
56 await close($)
57 break
58 case 'all':
59 case 'changes':
60 await update($, showReads, () => arg === 'all')
61 $.ui.toast(arg === 'all' ? 'Tickets: showing views and changes.' : 'Tickets: showing changes only.')
62 break
63 case 'rescan': {
64 await update($, tickets, () => [])
65 const count = await rescan($)
66 $.ui.toast(`Tickets: rebuilt from history, ${count} item${count === 1 ? '' : 's'}.`)
67 break
68 }
69 case 'clear':
70 await update($, tickets, () => [])
71 await update($, expanded, () => [])
72 $.ui.toast('Tickets: cleared the tracked items.')
73 break
74 default:
75 $.ui.toast(USAGE, { timeoutMs: 12_000 })
76 }
77
78 return {}
79 })
80
81 // Every tool call, in the main conversation and in subagents.
82 on('tool.call', async ($, e, next) => {
83 const ran = await next(e)
84 try {
85 const found = extract({
86 id: e.tool_use_id ?? `${e.tool}:${await $.clock.now()}`,
87 tool: String(e.tool),
88 input: e as unknown as Record<string, unknown>,
89 text: ran.deny ?? (typeof ran.text === 'string' ? ran.text : textOf(ran.result)),
90 isError: ran.deny !== undefined || ran.isError === true,
91 at: await $.clock.now(),
92 })
93 if (found.length > 0) await update($, tickets, list => merge(list, found))
94 } catch (error) {
95 $.ui.log(`ticket-tracker: could not record ${String(e.tool)}: ${String(error)}`)
96 }
97
98 return ran
99 }).catch(($, e, next) => next(e))
100
101 on('ui.close', async ($, e, next) => {
102 if (e.id === PANE) await update($, isOpen, () => false)
103 return next(e)
104 }).catch(($, e, next) => next(e))
105
106 // The one-line band above the prompt: a count and the toggle button.
107 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
108 const list = await read($, tickets)
109 if (e.props.hasSurvey || list.length === 0) return next(e)
110
111 const { Box, Button, Text } = $.ui.resolve(e)
112 const shown = await read($, isOpen)
113 const changed = list.filter(t => t.actions.some(a => a.isWrite)).length
114
115 return (
116 <Box flexDirection="row" gap={1}>
117 <Text dimColor>
118 Tickets: {changed} changed, {list.length - changed} viewed
119 </Text>
120 <Button
121 key="ticket-tracker-toggle"
122 label={shown ? 'Hide tickets' : 'Show tickets'}
123 onPress={() => toggle($)}
124 />
125 </Box>
126 )
127 })
128
129 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
130 const { Box, Button, Text } = $.ui.resolve(e)
131 const all = await read($, tickets)
132 const open = new Set(await read($, expanded))
133 const withReads = await read($, showReads)
134
135 const list = withReads ? all : all.filter(t => t.actions.some(a => a.isWrite))
136 const hidden = all.length - list.length
137 const isAllOpen = list.length > 0 && list.every(t => open.has(t.key))
138
139 const toggleRow = (key: string) =>
140 update($, expanded, keys => (keys.includes(key) ? keys.filter(k => k !== key) : [...keys, key]))
141
142 const groups = (['jira', 'github'] as const)
143 .map(system => ({ system, items: list.filter(t => t.system === system) }))
144 .filter(g => g.items.length > 0)
145
146 return (
147 <Box flexDirection="column">
148 <Box flexDirection="row" gap={1} flexWrap="wrap">
149 <Button
150 key="filter"
151 label={withReads ? 'All activity' : 'Changes only'}
152 onPress={() => update($, showReads, v => !v)}
153 />
154 <Button
155 key="expand"
156 label={isAllOpen ? 'Collapse all' : 'Expand all'}
157 onPress={() => update($, expanded, () => (isAllOpen ? [] : list.map(t => t.key)))}
158 />
159 </Box>
160
161 {list.length === 0 && (
162 <Text dimColor>
163 {hidden > 0
164 ? `${hidden} item${hidden === 1 ? '' : 's'} viewed, none changed. Press "Changes only" to see them.`
165 : 'No Jira or GitHub items touched yet.'}
166 </Text>
167 )}
168
169 {groups.map(group => (
170 <Box key={`group:${group.system}`} flexDirection="column" marginTop={1}>
171 <Text bold>{group.system === 'jira' ? 'Jira' : 'GitHub'}</Text>
172 {group.items.map(ticket => (
173 ticketRow({
174 Box,
175 Button,
176 Text,
177 ticket,
178 isExpanded: open.has(ticket.key),
179 withReads,
180 onToggle: () => toggleRow(ticket.key),
181 })
182 ))}
183 </Box>
184 ))}
185
186 {hidden > 0 && list.length > 0 && (
187 <Box marginTop={1}>
188 <Text dimColor>
189 +{hidden} viewed only (hidden)
190 </Text>
191 </Box>
192 )}
193 </Box>
194 )
195 })
196}
197
198type Elements = ReturnType<EngineInterface['ui']['resolve']>
199
200function ticketRow(props: {
201 Box: Elements['Box']
202 Button: Elements['Button']
203 Text: Elements['Text']
204 ticket: Ticket
205 isExpanded: boolean
206 withReads: boolean
207 onToggle: () => unknown
208}) {
209 const { Box, Button, Text, ticket, isExpanded, withReads, onToggle } = props
210 const actions = ticket.actions.filter(a => withReads || a.isWrite)
211 const last = actions.at(-1)
212 const hasFailed = actions.some(a => a.isError)
213
214 return (
215 <Box key={`ticket:${ticket.key}`} flexDirection="column">
216 <Button key={`row:${ticket.key}`} plain onPress={onToggle}>
217 {isExpanded ? '▾ ' : '▸ '}
218 <Text bold color={hasFailed ? 'error' : undefined}>
219 {ticket.key}
220 </Text>
221 {ticket.title ? ` ${ticket.title}` : ''}
222 <Text dimColor>
223 {' '}
224 · {actions.length} action{actions.length === 1 ? '' : 's'}
225 {last && !isExpanded ? `, last: ${last.verb}` : ''}
226 </Text>
227 </Button>
228 {isExpanded && (
229 <Box flexDirection="column" paddingLeft={4}>
230 {ticket.url && <Text dimColor>{ticket.url}</Text>}
231 {actions.map(a => (
232 <Text color={colorOf(a)}>
233 {a.isError ? '✗ ' : a.isWrite ? '● ' : '○ '}
234 {a.verb}
235 {a.isError ? ' (failed)' : ''}
236 <Text dimColor>
237 {a.detail ? ` · ${a.detail}` : ''} · {shortTool(a.tool)}
238 </Text>
239 </Text>
240 ))}
241 </Box>
242 )}
243 </Box>
244 )
245}
246
247function colorOf(a: TicketAction): string | undefined {
248 if (a.isError) return 'error'
249 if (a.kind === 'created' || a.kind === 'merged') return 'success'
250 if (a.kind === 'closed') return 'merged'
251 if (a.kind === 'transitioned') return 'suggestion'
252 return undefined
253}
254
255function shortTool(tool: string): string {
256 return tool.replace(/^mcp__([^_]+(?:_[^_]+)*)__/, '$1:')
257}
258
259async function open($: EngineInterface): Promise<boolean> {
260 const opened = await $.ui.open({ id: PANE, title: TITLE })
261 await update($, isOpen, () => true)
262 if (!opened.isPlaced) $.ui.toast(`Tickets: the pane could not be shown here (${opened.reason}).`)
263 return opened.isPlaced
264}
265
266async function close($: EngineInterface): Promise<void> {
267 await $.ui.close({ id: PANE })
268 await update($, isOpen, () => false)
269}
270
271/** Opens the pane when closed, closes it when open; says whether it is open now. */
272async function toggle($: EngineInterface): Promise<boolean> {
273 const panes = await $.ui.panes()
274 if (panes.some(p => p.id === PANE)) {
275 await close($)
276 return false
277 }
278 await open($)
279 return true
280}
281
282/** Reads the conversation so far and folds every ticket call it holds into the list. */
283async function rescan($: EngineInterface): Promise<number> {
284 const messages = await $.session.messages()
285 const found = messages.flatMap(m =>
286 m.toolUses.flatMap(use => {
287 const call: ToolCallRecord = {
288 id: use.tool_use_id,
289 tool: use.tool,
290 input: use.input,
291 text: typeof use.text === 'string' ? use.text : textOf(use.result),
292 isError: use.isError === true,
293 }
294 return extract(call)
295 }),
296 )
297 const list = await update($, tickets, current => merge(current, found))
298
299 return list.length
300}
301
302function textOf(result: unknown): string | undefined {
303 if (result === undefined || result === null) return undefined
304 if (typeof result === 'string') return result
305 try {
306 return JSON.stringify(result).slice(0, 20_000)
307 } catch {
308 return undefined
309 }
310}
311hooks/extract.ts 452 lines1// Turns one tool call (live from `tool.call`, or read back from the
2// transcript) into the ticket activity it represents, if any.
3//
4// Pure functions only: no `$`, so the tests can drive them directly.
5
6import type { ActionKind, Ticket, TicketAction, TicketSystem } from '../types'
7
8export type ToolCallRecord = {
9 id: string
10 tool: string
11 input: Record<string, unknown>
12 /** The result as the model read it; absent while in flight. */
13 text?: string
14 isError?: boolean
15 at?: number
16}
17
18export type Activity = {
19 key: string
20 system: TicketSystem
21 title?: string
22 url?: string
23 action: TicketAction
24}
25
26const READ_KINDS: readonly ActionKind[] = ['viewed']
27
28const JIRA_KEY = /\b([A-Z][A-Z0-9_]{1,9}-\d+)\b/
29const GH_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)/
30const JIRA_URL = /https:\/\/[\w.-]+\/browse\/([A-Z][A-Z0-9_]{1,9}-\d+)/
31
32/** Every activity one tool call amounts to (most calls: zero or one). */
33export function extract(call: ToolCallRecord): Activity[] {
34 const tool = call.tool
35 if (tool === 'Bash') return fromBash(call)
36 if (tool === 'WebFetch') return fromWebFetch(call)
37 if (!tool.startsWith('mcp__')) return []
38
39 const [, server = '', name = ''] = tool.match(/^mcp__(.+?)__(.+)$/) ?? []
40 if (/jira|atlassian/i.test(server) || /jira/i.test(name)) return fromJiraMcp(call, name)
41 if (/github/i.test(server)) return fromGithubMcp(call, name)
42
43 return []
44}
45
46// ---------------------------------------------------------------- GitHub MCP
47
48type GhRule = { kind: ActionKind; verb: string; isPull?: boolean }
49
50const GH_TOOLS: Record<string, GhRule> = {
51 issue_read: { kind: 'viewed', verb: 'read issue' },
52 get_issue: { kind: 'viewed', verb: 'read issue' },
53 get_issue_comments: { kind: 'viewed', verb: 'read comments' },
54 create_issue: { kind: 'created', verb: 'created issue' },
55 update_issue: { kind: 'updated', verb: 'updated issue' },
56 issue_write: { kind: 'updated', verb: 'updated issue' },
57 add_issue_comment: { kind: 'commented', verb: 'commented' },
58 update_issue_comment: { kind: 'commented', verb: 'edited a comment' },
59 sub_issue_write: { kind: 'linked', verb: 'changed sub-issues' },
60 pull_request_read: { kind: 'viewed', verb: 'read pull request', isPull: true },
61 get_pull_request: { kind: 'viewed', verb: 'read pull request', isPull: true },
62 create_pull_request: { kind: 'created', verb: 'opened pull request', isPull: true },
63 update_pull_request: { kind: 'updated', verb: 'updated pull request', isPull: true },
64 merge_pull_request: { kind: 'merged', verb: 'merged', isPull: true },
65 update_pull_request_branch: { kind: 'updated', verb: 'updated branch', isPull: true },
66 pull_request_review_write: { kind: 'reviewed', verb: 'reviewed', isPull: true },
67 add_comment_to_pending_review: { kind: 'commented', verb: 'added review comment', isPull: true },
68 add_reply_to_pull_request_comment: { kind: 'commented', verb: 'replied to review comment', isPull: true },
69 request_copilot_review: { kind: 'reviewed', verb: 'requested Copilot review', isPull: true },
70 enable_pr_auto_merge: { kind: 'updated', verb: 'enabled auto-merge', isPull: true },
71 disable_pr_auto_merge: { kind: 'updated', verb: 'disabled auto-merge', isPull: true },
72}
73
74function fromGithubMcp(call: ToolCallRecord, name: string): Activity[] {
75 let rule = GH_TOOLS[name]
76 if (rule === undefined) return []
77 const input = call.input
78
79 // `issue_write` covers create and update under one name.
80 if (name === 'issue_write' && input.method === 'create') rule = { kind: 'created', verb: 'created issue' }
81
82 let { kind, verb } = rule
83 if (input.state === 'closed') ({ kind, verb } = { kind: 'closed', verb: 'closed' })
84 if (input.state === 'open' && kind === 'updated') ({ kind, verb } = { kind: 'reopened', verb: 'reopened' })
85 if (name === 'pull_request_review_write' && typeof input.event === 'string') {
86 verb = `reviewed (${String(input.event).toLowerCase().replace(/_/g, ' ')})`
87 }
88 if (typeof input.method === 'string' && kind === 'viewed' && input.method !== 'get') {
89 verb = `${verb}: ${input.method.replace(/^get_?/, '').replace(/_/g, ' ')}`
90 }
91
92 const fromUrl = call.text?.match(GH_URL)
93 const owner = str(input.owner) ?? fromUrl?.[1]
94 const repo = str(input.repo) ?? fromUrl?.[2]
95 const number =
96 num(input.issue_number ?? input.issueNumber ?? input.pullNumber ?? input.pull_number ?? input.number) ??
97 (kind === 'created' ? numberFromResult(call.text) : undefined)
98 if (number === undefined) return []
99
100 const key = ghKey(owner, repo, number)
101 const title = str(input.title) ?? (kind === 'viewed' ? jsonField(call.text, 'title') : undefined)
102
103 return [
104 {
105 key,
106 system: 'github',
107 title,
108 url: owner && repo ? `https://github.com/${owner}/${repo}/${rule.isPull ? 'pull' : 'issues'}/${number}` : undefined,
109 action: action(call, kind, verb, detailFrom(input)),
110 },
111 ]
112}
113
114// ------------------------------------------------------------------ Jira MCP
115
116const JIRA_VERBS: [RegExp, ActionKind, string][] = [
117 [/gettransition/i, 'viewed', 'read transitions'],
118 [/transition/i, 'transitioned', 'transitioned'],
119 [/comment/i, 'commented', 'commented'],
120 [/worklog/i, 'updated', 'logged work'],
121 [/link/i, 'linked', 'linked'],
122 [/assign/i, 'assigned', 'assigned'],
123 [/create/i, 'created', 'created'],
124 [/edit|update/i, 'updated', 'updated'],
125 [/get|read|fetch|view/i, 'viewed', 'read'],
126]
127
128function fromJiraMcp(call: ToolCallRecord, name: string): Activity[] {
129 if (/search|jql|list|lookup|project|metadata|resources|user/i.test(name) && !/issue$/i.test(name)) return []
130 const rule = JIRA_VERBS.find(([re]) => re.test(name))
131 if (rule === undefined) return []
132 const [, kind, baseVerb] = rule
133 const input = call.input
134
135 const fromInput =
136 str(input.issueIdOrKey) ?? str(input.issueKey) ?? str(input.issue_key) ?? str(input.key) ?? findJiraKey(input)
137 const key = fromInput ?? (kind === 'created' ? jsonField(call.text, 'key') ?? call.text?.match(JIRA_KEY)?.[1] : undefined)
138 if (key === undefined || !JIRA_KEY.test(key)) return []
139
140 let verb = baseVerb
141 const transition = input.transition as { id?: unknown; name?: unknown } | undefined
142 const target = str(transition?.name) ?? str(input.transitionName) ?? str(input.status) ?? str(input.transition_id)
143 if (kind === 'transitioned' && target) verb = `moved to ${target}`
144 if (kind === 'updated' && isRecord(input.fields)) {
145 const fields = Object.keys(input.fields)
146 if (fields.length > 0) verb = `updated ${fields.join(', ')}`
147 }
148
149 const title = str(input.summary) ?? (kind === 'viewed' ? jsonField(call.text, 'summary') : undefined)
150 const site = str(input.cloudId)?.includes('.') ? str(input.cloudId) : undefined
151
152 return [
153 {
154 key,
155 system: 'jira',
156 title,
157 url: site ? `https://${site.replace(/^https?:\/\//, '')}/browse/${key}` : undefined,
158 action: action(call, kind, verb, detailFrom(input)),
159 },
160 ]
161}
162
163// ---------------------------------------------------------------------- Bash
164
165const GH_CLI: Record<string, ActionKind> = {
166 view: 'viewed',
167 create: 'created',
168 comment: 'commented',
169 edit: 'updated',
170 close: 'closed',
171 reopen: 'reopened',
172 merge: 'merged',
173 review: 'reviewed',
174 ready: 'updated',
175 lock: 'updated',
176 unlock: 'updated',
177 transfer: 'updated',
178 pin: 'updated',
179 unpin: 'updated',
180 develop: 'linked',
181 checkout: 'viewed',
182 diff: 'viewed',
183 checks: 'viewed',
184}
185
186const JIRA_CLI: [RegExp, ActionKind, string][] = [
187 [/\b(move|transition)\b/, 'transitioned', 'transitioned'],
188 [/\bcomment\b/, 'commented', 'commented'],
189 [/\bassign\b/, 'assigned', 'assigned'],
190 [/\bworklog\b/, 'updated', 'logged work'],
191 [/\blink\b/, 'linked', 'linked'],
192 [/\bcreate\b/, 'created', 'created'],
193 [/\bedit\b/, 'updated', 'updated'],
194 [/\bview\b/, 'viewed', 'read'],
195]
196
197function fromBash(call: ToolCallRecord): Activity[] {
198 const command = str(call.input.command)
199 if (command === undefined) return []
200 const found: Activity[] = []
201
202 for (const segment of command.split(/&&|\|\||;|\n|\|/)) {
203 const words = tokenize(segment.trim())
204 const at = words.findIndex(w => w === 'gh' || w.endsWith('/gh'))
205 if (at >= 0) {
206 const one = fromGhCli(call, words.slice(at + 1))
207 if (one) found.push(one)
208 continue
209 }
210 if (words[0] === 'jira' || words[0] === 'acli') {
211 const one = fromJiraCli(call, segment)
212 if (one) found.push(one)
213 }
214 }
215
216 return found
217}
218
219function fromGhCli(call: ToolCallRecord, words: string[]): Activity | undefined {
220 const [noun, sub, ...rest] = words
221 if (noun !== 'issue' && noun !== 'pr') return undefined
222 const kind = sub === undefined ? undefined : GH_CLI[sub]
223 if (kind === undefined) return undefined
224 const isPull = noun === 'pr'
225
226 let owner: string | undefined
227 let repo: string | undefined
228 let number: number | undefined
229 let title: string | undefined
230 for (let i = 0; i < rest.length; i++) {
231 const word = rest[i] ?? ''
232 if (word === '-R' || word === '--repo') {
233 ;[owner, repo] = (rest[++i] ?? '').replace(/^github\.com\//, '').split('/')
234 } else if (word.startsWith('--repo=')) {
235 ;[owner, repo] = word.slice(7).split('/')
236 } else if (word === '-t' || word === '--title') {
237 title = rest[++i]
238 } else if (/^#?\d+$/.test(word) && number === undefined) {
239 number = Number(word.replace('#', ''))
240 } else if (GH_URL.test(word)) {
241 const m = word.match(GH_URL)!
242 ;[owner, repo, number] = [m[1], m[2], Number(m[4])]
243 }
244 }
245
246 if (number === undefined && kind === 'created') {
247 const m = call.text?.match(GH_URL)
248 if (m) [owner, repo, number] = [m[1], m[2], Number(m[4])]
249 }
250 // `gh pr view` / `gh pr comment` with no number targets the current branch's PR.
251 if (number === undefined && isPull) {
252 const m = call.text?.match(GH_URL)
253 if (m && m[3] === 'pull') [owner, repo, number] = [m[1], m[2], Number(m[4])]
254 }
255 if (number === undefined) return undefined
256
257 let verb: string = sub === 'checkout' ? 'checked out' : sub === 'checks' ? 'checked CI' : kind
258 if (kind === 'reviewed') {
259 if (rest.includes('--approve') || rest.includes('-a')) verb = 'approved'
260 else if (rest.includes('--request-changes') || rest.includes('-r')) verb = 'requested changes'
261 }
262 if (kind === 'viewed' && sub === 'view') verb = isPull ? 'read pull request' : 'read issue'
263 if (kind === 'created') verb = isPull ? 'opened pull request' : 'created issue'
264
265 return {
266 key: ghKey(owner, repo, number),
267 system: 'github',
268 title,
269 url: owner && repo ? `https://github.com/${owner}/${repo}/${isPull ? 'pull' : 'issues'}/${number}` : undefined,
270 action: action(call, kind, verb, `gh ${noun} ${sub}`),
271 }
272}
273
274function fromJiraCli(call: ToolCallRecord, segment: string): Activity | undefined {
275 const key = segment.match(JIRA_KEY)?.[1] ?? (/\bcreate\b/.test(segment) ? call.text?.match(JIRA_KEY)?.[1] : undefined)
276 if (key === undefined) return undefined
277 const rule = JIRA_CLI.find(([re]) => re.test(segment))
278 if (rule === undefined) return undefined
279 const [, kind, verb] = rule
280
281 return { key, system: 'jira', action: action(call, kind, verb, segment.trim().slice(0, 80)) }
282}
283
284// ------------------------------------------------------------------ WebFetch
285
286function fromWebFetch(call: ToolCallRecord): Activity[] {
287 const url = str(call.input.url) ?? ''
288 const gh = url.match(GH_URL)
289 if (gh) {
290 const [, owner, repo, type, n] = gh
291 return [
292 {
293 key: ghKey(owner, repo, Number(n)),
294 system: 'github',
295 url: `https://github.com/${owner}/${repo}/${type}/${n}`,
296 action: action(call, 'viewed', 'fetched page', 'WebFetch'),
297 },
298 ]
299 }
300 const jira = url.match(JIRA_URL)
301 if (jira?.[1]) {
302 return [{ key: jira[1], system: 'jira', url, action: action(call, 'viewed', 'fetched page', 'WebFetch') }]
303 }
304
305 return []
306}
307
308// ------------------------------------------------------------------- merging
309
310/** Folds activities into the ticket list, newest-touched first; idempotent per action id. */
311export function merge(tickets: readonly Ticket[], found: readonly Activity[]): Ticket[] {
312 const byKey = new Map(tickets.map(t => [t.key, t] as const))
313 let changed = false
314
315 for (const one of found) {
316 const known = byKey.get(one.key)
317 const ticket: Ticket = known ?? { key: one.key, system: one.system, actions: [] }
318 const at = ticket.actions.findIndex(a => a.id === one.action.id && a.verb === one.action.verb)
319 // A call seen in flight is replaced by its finished record.
320 const actions =
321 at >= 0
322 ? ticket.actions.map((a, i) => (i === at ? { ...a, ...one.action } : a))
323 : [...ticket.actions, one.action]
324 byKey.set(one.key, {
325 ...ticket,
326 title: one.title ?? ticket.title,
327 url: one.url ?? ticket.url,
328 actions,
329 lastAt: Math.max(ticket.lastAt ?? 0, one.action.at ?? 0),
330 })
331 changed = true
332 }
333
334 if (!changed) return [...tickets]
335 return [...byKey.values()].sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))
336}
337
338export function isWrite(kind: ActionKind): boolean {
339 return !READ_KINDS.includes(kind)
340}
341
342/** A plain-text report, used by `/tickets summary`. */
343export function summarize(tickets: readonly Ticket[], showReads: boolean): string {
344 const shown = tickets.filter(t => showReads || t.actions.some(a => a.isWrite))
345 if (shown.length === 0) {
346 return showReads ? 'No Jira or GitHub items touched in this conversation yet.' : 'No Jira or GitHub items changed in this conversation yet.'
347 }
348 const lines: string[] = []
349 for (const system of ['jira', 'github'] as const) {
350 const group = shown.filter(t => t.system === system)
351 if (group.length === 0) continue
352 lines.push(system === 'jira' ? 'Jira' : 'GitHub')
353 for (const t of group) {
354 lines.push(` ${t.key}${t.title ? ` — ${t.title}` : ''}${t.url ? ` (${t.url})` : ''}`)
355 for (const a of t.actions) {
356 if (!showReads && !a.isWrite) continue
357 lines.push(` - ${a.verb}${a.isError ? ' (failed)' : ''}${a.detail ? ` · ${a.detail}` : ''}`)
358 }
359 }
360 }
361
362 return lines.join('\n')
363}
364
365// ------------------------------------------------------------------- helpers
366
367function action(call: ToolCallRecord, kind: ActionKind, verb: string, detail?: string): TicketAction {
368 return {
369 id: call.id,
370 kind,
371 verb,
372 tool: call.tool,
373 detail,
374 isWrite: isWrite(kind),
375 isError: call.isError === true,
376 isPending: call.text === undefined && call.isError === undefined,
377 at: call.at,
378 }
379}
380
381function ghKey(owner: string | undefined, repo: string | undefined, number: number): string {
382 return owner && repo ? `${owner}/${repo}#${number}` : `#${number}`
383}
384
385function detailFrom(input: Record<string, unknown>): string | undefined {
386 const labels = Array.isArray(input.labels) ? input.labels.filter(l => typeof l === 'string') : []
387 const assignees = Array.isArray(input.assignees) ? input.assignees.filter(l => typeof l === 'string') : []
388 const parts = [
389 labels.length ? `labels: ${labels.join(', ')}` : undefined,
390 assignees.length ? `assignees: ${assignees.join(', ')}` : undefined,
391 typeof input.body === 'string' ? `“${oneLine(input.body)}”` : undefined,
392 typeof input.comment === 'string' ? `“${oneLine(input.comment)}”` : undefined,
393 typeof input.commentBody === 'string' ? `“${oneLine(input.commentBody)}”` : undefined,
394 ].filter(Boolean)
395
396 return parts.length ? parts.join(' · ') : undefined
397}
398
399function oneLine(text: string): string {
400 const flat = text.replace(/\s+/g, ' ').trim()
401 return flat.length > 60 ? `${flat.slice(0, 59)}…` : flat
402}
403
404function numberFromResult(text: string | undefined): number | undefined {
405 const url = text?.match(GH_URL)
406 if (url) return Number(url[4])
407 const field = text?.match(/"number"\s*:\s*(\d+)/)
408 return field ? Number(field[1]) : undefined
409}
410
411function jsonField(text: string | undefined, field: string): string | undefined {
412 const m = text?.match(new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.){1,200})"`))
413 if (!m?.[1]) return undefined
414 try {
415 return JSON.parse(`"${m[1]}"`) as string
416 } catch {
417 return m[1]
418 }
419}
420
421function findJiraKey(input: Record<string, unknown>): string | undefined {
422 for (const value of Object.values(input)) {
423 if (typeof value === 'string') {
424 const m = value.match(new RegExp(`^${JIRA_KEY.source}$`))
425 if (m) return m[1]
426 }
427 }
428 return undefined
429}
430
431function tokenize(text: string): string[] {
432 const words: string[] = []
433 for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)) {
434 words.push(m[1] ?? m[2] ?? m[3] ?? '')
435 }
436 return words
437}
438
439function str(value: unknown): string | undefined {
440 return typeof value === 'string' && value.length > 0 ? value : undefined
441}
442
443function num(value: unknown): number | undefined {
444 if (typeof value === 'number' && Number.isFinite(value)) return value
445 if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value)
446 return undefined
447}
448
449function isRecord(value: unknown): value is Record<string, unknown> {
450 return typeof value === 'object' && value !== null && !Array.isArray(value)
451}
452types/index.d.ts 54 lines1export type TicketSystem = 'github' | 'jira'
2
3export type ActionKind =
4 | 'viewed'
5 | 'created'
6 | 'updated'
7 | 'commented'
8 | 'closed'
9 | 'reopened'
10 | 'transitioned'
11 | 'assigned'
12 | 'linked'
13 | 'merged'
14 | 'reviewed'
15
16export type TicketAction = {
17 /** The tool_use_id of the call that did it. */
18 id: string
19 kind: ActionKind
20 /** What happened, in words: "commented", "moved to In Review". */
21 verb: string
22 /** The tool that did it: `mcp__github__add_issue_comment`, `Bash`. */
23 tool: string
24 detail?: string
25 isWrite: boolean
26 isError: boolean
27 isPending: boolean
28 /** Epoch ms when seen live; absent for calls read back from history. */
29 at?: number
30}
31
32export type Ticket = {
33 /** `owner/repo#12`, `#12` (repo unknown) or `PROJ-123`. */
34 key: string
35 system: TicketSystem
36 title?: string
37 url?: string
38 actions: TicketAction[]
39 lastAt?: number
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'ticket-tracker': {
45 tickets: Ticket[]
46 /** Keys of the tickets whose action lists are expanded in the pane. */
47 expanded: string[]
48 /** Show read-only activity (views) as well as changes. */
49 showReads: boolean
50 isOpen: boolean
51 }
52 }
53}
54