Linear ticket for the current branch above the prompt, with one-press Done once every PR merged

Seven Claude Code mods (function-hook plugins) that wire your terminal into Slack, Linear, GitHub and Datadog through the Composio CLI.
Demo: video/out.mp4. The video is built with fframes; its source is in video/.
/reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer./snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press./standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected./plugin marketplace add tridha643/claude-composio-mods
/plugin install ticket-band@claude-composio-mods
Install whichever mods you want the same way. To hack on them instead, clone the repo and run claude --plugin-dir plugins/<mod>.
~/.composio/composio and logged in, with the toolkits each mod uses connected (Slack, Linear, GitHub, Datadog).gh, logged in, for review-chaser and snippet.review-chaser and snippet search PRs in the ComposioHQ org; change the query in hooks/register.tsx for your own org.Set these from the /plugin config menu:
composio-daily → standup_channel: the Slack channel the standup Post button sends to.snippet → channel: the Slack channel /snippet posts to.ticket-band → team_keys: comma-separated Linear team keys to look for in branch names. Empty matches any KEY-123.composio-pulse → search_budget_ms: marks the median search time ✓ or ✗ against this budget. 0 hides the mark.An empty channel hides that Post button.
hooks/register.tsx 138 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ticket, TicketPr } from '../types'
5
6// userConfig.team_keys narrows this to your Linear team keys; empty matches any KEY-123
7let ticketInBranch = /\b([a-z]{2,10})-(\d+)\b/i
8// Commands after which the branch (and so the ticket) may have changed.
9const BRANCH_MOVES = /\b(git\s+(checkout|switch|worktree)|gt\s+(co|checkout|create|up|down|top|bottom|sync)|gh\s+pr\s+(checkout|merge))\b/
10
11const ticket = atom({ plugin: 'ticket-band', key: 'ticket' } as const, null)
12const isHidden = atom({ plugin: 'ticket-band', key: 'isHidden' } as const, false)
13
14const ISSUE = `query($id: String!) { issue(id: $id) {
15 id identifier title description state { name type } cycle { endsAt }
16 team { states { nodes { id name } } }
17 attachments { nodes { url metadata } } } }`
18
19const MARK_DONE = `mutation($id: String!, $stateId: String!, $body: String!) {
20 issueUpdate(id: $id, input: { stateId: $stateId }) { success }
21 commentCreate(input: { issueId: $id, body: $body }) { success } }`
22
23async function linear($: EngineInterface, query: string, variables: Record<string, string>) {
24 const args = JSON.stringify({ query_or_mutation: query, variables })
25 const { stdout } = await $.process.run([`${await $.env.get('HOME')}/.composio/composio`, 'execute', 'LINEAR_RUN_QUERY_OR_MUTATION', '-d', args], { timeoutMs: 60_000 })
26 const out = JSON.parse(stdout)
27 if (!out.successful) throw new Error(String(out.error))
28 return out.data.data
29}
30
31async function refresh($: EngineInterface) {
32 const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
33 const match = branch.exitCode === 0 ? ticketInBranch.exec(branch.stdout) : null
34 if (!match) return update($, ticket, () => null)
35
36 const { issue } = await linear($, ISSUE, { id: `${match[1]}-${match[2]}`.toUpperCase() })
37 if (!issue) return update($, ticket, () => null)
38
39 const prs: TicketPr[] = issue.attachments.nodes
40 .filter((a: any) => a.metadata?.isPullRequestProjection)
41 .map((a: any) => ({ number: a.metadata.number, url: a.url, status: a.metadata.draft ? 'draft' : a.metadata.status, mergedAt: a.metadata.mergedAt ?? null }))
42 .sort((a: TicketPr, b: TicketPr) => a.number - b.number)
43 const wanted = (issue.description ?? '').split('\n').find((line: string) => /^wanted:/i.test(line.trim()))
44
45 const next: Ticket = {
46 id: issue.identifier,
47 issueId: issue.id,
48 title: issue.title,
49 state: issue.state.name,
50 stateType: issue.state.type,
51 doneStateId: issue.team.states.nodes.find((s: any) => s.name === 'Done')?.id ?? null,
52 cycleEndsAt: issue.cycle?.endsAt ?? null,
53 wanted: wanted?.trim() ?? null,
54 prs,
55 }
56 await update($, ticket, () => next)
57}
58
59// Every PR merged or closed, at least one merged, ticket not closed yet.
60const isStale = (t: Ticket) =>
61 t.prs.length > 0 &&
62 t.prs.every(pr => pr.status === 'merged' || pr.status === 'closed') &&
63 t.prs.some(pr => pr.status === 'merged') &&
64 t.stateType !== 'completed' &&
65 t.stateType !== 'canceled'
66
67const day = (iso: string) => new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
68
69async function markDone($: EngineInterface, t: Ticket) {
70 if (!t.doneStateId) return $.ui.toast(`${t.id}: no Done state on this team`)
71 const merged = t.prs.filter(pr => pr.status === 'merged')
72 const last = merged.map(pr => pr.mergedAt).filter(Boolean).sort().at(-1)
73 const body = `Merged as ${merged.map(pr => `#${pr.number}`).join(', ')}${last ? ` (last on ${day(last)})` : ''}; moving to Done.`
74 try {
75 await linear($, MARK_DONE, { id: t.issueId, stateId: t.doneStateId, body })
76 $.ui.toast(`${t.id} → Done`)
77 await refresh($)
78 } catch (err) {
79 $.ui.toast(`${t.id}: Linear update failed: ${String(err).slice(0, 120)}`)
80 }
81}
82
83async function safeRefresh($: EngineInterface) {
84 try {
85 await refresh($)
86 } catch (err) {
87 $.ui.toast(`ticket-band: ${String(err).slice(0, 120)}`)
88 }
89}
90
91export const register: Register = (on, options) => {
92 const keys = String(options.team_keys ?? '').split(',').map(k => k.trim()).filter(k => /^[a-z0-9]+$/i.test(k))
93 if (keys.length > 0) ticketInBranch = new RegExp(`\\b(${keys.join('|')})-(\\d+)\\b`, 'i')
94 on('session.start', async ($, e, next) => {
95 void safeRefresh($)
96 $.clock.every(5 * 60_000, () => void safeRefresh($))
97 return next(e)
98 })
99
100 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101 const ran = await next(e)
102 if (BRANCH_MOVES.test(e.command)) void safeRefresh($)
103 return ran
104 })
105
106 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107 const t = await read($, ticket)
108 if (e.props.hasSurvey || t === null || (await read($, isHidden))) return next(e)
109
110 const { Box, Button, Text } = $.ui.resolve(e)
111 const icon = { merged: '✓', open: '○', draft: '◌', closed: '✗' } as Record<string, string>
112 const color = { merged: 'green', open: 'yellow', draft: 'gray', closed: 'red' } as Record<string, string>
113
114 return (
115 <Box flexDirection="column">
116 <Box gap={1}>
117 <Text bold color="magenta">{t.id}</Text>
118 <Text color="cyan">{t.state}</Text>
119 {t.cycleEndsAt && <Text dimColor>cycle ends {day(t.cycleEndsAt)}</Text>}
120 <Text wrap="truncate-end">{t.title}</Text>
121 </Box>
122 {t.prs.length > 0 && (
123 <Box gap={1}>
124 {t.prs.map(pr => <Text color={color[pr.status] ?? 'gray'}>#{pr.number} {icon[pr.status] ?? '?'}</Text>)}
125 </Box>
126 )}
127 {t.wanted && <Text dimColor wrap="truncate-end">{t.wanted}</Text>}
128 <Box gap={1}>
129 {isStale(t) && <Text color="yellow">every PR merged, still {t.state}</Text>}
130 {isStale(t) && <Button key="done" hotkey="d" variant="primary" label="Move to Done" onPress={() => markDone($, t)} />}
131 <Button key="refresh" hotkey="r" label="Refresh" dimColor onPress={() => safeRefresh($)} />
132 <Button key="hide" label="Hide" dimColor onPress={() => update($, isHidden, () => true)} />
133 </Box>
134 </Box>
135 )
136 })
137}
138types/index.d.ts 20 lines1export type TicketPr = { number: number; url: string; status: string; mergedAt: string | null }
2
3export type Ticket = {
4 id: string
5 issueId: string
6 title: string
7 state: string
8 stateType: string
9 doneStateId: string | null
10 cycleEndsAt: string | null
11 wanted: string | null
12 prs: TicketPr[]
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'ticket-band': { ticket: Ticket | null; isHidden: boolean }
18 }
19}
20