SLOPSHOPPER

ticket-band

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

newbandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
README

claude-composio-mods

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/.

  • ticket-band: Shows the Linear ticket for the current branch above the prompt, with one-press Done once every PR has merged.
  • review-chaser: /reviews lists your open PRs waiting on reviewers, with a Slack nudge button per reviewer.
  • snippet: /snippet drafts your daily Slack update from the last 24h of Linear + GitHub and posts it on one press.
  • composio-daily: /standup pane, looks up failing-test errors in Datadog ("seen before"), and sets your Slack status while the agent is busy.
  • link-prefetch: Paste a Slack thread, Linear issue or GitHub PR link and Claude gets its contents as context before it answers.
  • composio-pulse: Times every composio CLI call into a status-line sparkline (/pulse for details) and blocks re-linking a toolkit that's already connected.
  • review-lint: When an edit adds a cache or a brittle test assertion, hands Claude a cache-review checklist or rules against implementation-coupled tests.

Install

/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>.

Requirements

  • The Composio CLI, installed at ~/.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.

Settings

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.

Source 2 files
hooks/register.tsx 138 lines
1import { 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}
138
types/index.d.ts 20 lines
1export 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