SLOPSHOPPER

slonk-card

Shows the slonk card this session works on and the column it is in right now

newbandguardcommandtoaststatus
v0.4.1no licenseupdated 2026-10-09romandots/claude-mods/plugins/slonk-card
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slonk-card
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /slonk-card ⎿ slonk-card: Карточка ещё не выбрана: работайте со slonk или укажите /slonk-card <KEY>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Маркетплейс модов для Claude Code.

МодЧто делает
slonk-cardПоказывает карточку slonk, с которой работает сессия, и колонку, в которой она сейчас лежит

Установка

Нужен Claude Code v2.1.287+ в терминале или приложение Claude Desktop v2.1.286+ (вкладка Code).

claude plugin marketplace add romandots/claude-mods
claude plugin install slonk-card@romandots-mods

То же можно сделать прямо в сессии Claude Code:

/plugin marketplace add romandots/claude-mods
/plugin install slonk-card@romandots-mods

Если сессия уже открыта, выполните в ней /reload-plugins. Проверить, что мод загрузился: /plugin. В терминале под вкладками будет строка 1 mod active · slonk-card.

Обновление: /plugin → Installed → slonk-card → Update, или claude plugin update slonk-card@romandots-mods.

Мод выполняется с вашими правами. Посмотреть, какие события он слушает и что вызывает, можно до установки:

claude plugin validate plugins/slonk-card

slonk-card

Следит за вызовами MCP-инструментов slonk, в том числе у субагентов, и показывает текущую карточку:

  • строка статуса под полем ввода: slonk TANSULTANT-2016 · Code Review (терминал и Desktop);
  • полоса над полем ввода с заголовком и кнопками «Обновить» и «Скрыть», а под ней степпер: по прямоугольнику на каждую колонку основного потока (Backlog → To Do → Analysis → Development → Security Review → Code Review → Testing → Documenting → Merging → Done); пройденные залиты зелёным, текущая мерцает, будущие — пустые рамки, Blocked — красный прямоугольник на месте, где карточка застряла;
  • уведомление при смене колонки: TANSULTANT-2016: Development → Code Review.

Как мод определяет карточку:

  • вызов, касающийся одной карточки (get_issue, claim_issue, transition_issue, create_issue и т.п.), делает её текущей;
  • если в ответе нет колонки (comment_issue, link_git_ref), мод сам запрашивает get_issue;
  • раз в минуту мод перечитывает карточку и замечает перенос, сделанный другим агентом или человеком в Plane.

slonk распознаётся по ответу: ключ вида PROJ-123 и состояние с группой Plane. Поэтому подходит любое имя MCP-сервера (slonk-developer, коннектор с UUID-именем и т.п.).

Команда /slonk-card

КомандаДействие
/slonk-cardПеречитать текущую карточку
/slonk-card PROJ-123Следить за другой карточкой
/slonk-card offПерестать следить
/slonk-card hide / showСкрыть или показать полосу

Ограничения

  • В облачных сессиях, чат-панели VS Code и claude -p мод ничего не рисует.
  • Показывается последняя карточка, которой касалась сессия.
  • Изменения, сделанные не из этой сессии, видны с задержкой до минуты.
  • В режиме разрешений auto Claude Code не даёт моду самому вызывать MCP: опрос отключается после первого отказа, и колонка обновляется только при вызовах slonk в сессии.

Разработка

claude plugin validate plugins/slonk-card
claude plugin test plugins/slonk-card

Для проверки на лету: claude --plugin-dir plugins/slonk-card. Перед релизом поднимите version в plugins/slonk-card/.claude-plugin/plugin.json.

Source 2 files
hooks/register.tsx 349 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Card } from '../types'
5
6const card = atom({ plugin: 'slonk-card', key: 'card' } as const, null)
7const lastServer = atom({ plugin: 'slonk-card', key: 'lastServer' } as const, null)
8const isHidden = atom({ plugin: 'slonk-card', key: 'isHidden' } as const, false)
9const pulse = atom({ plugin: 'slonk-card', key: 'pulse' } as const, false)
10
11const POLL_MS = 60_000
12const PULSE_MS = 700
13// slonk's main flow, left to right: one stepper cell per column.
14export const FLOW = [
15  'Backlog',
16  'To Do',
17  'Analysis',
18  'Development',
19  'Security Review',
20  'Code Review',
21  'Testing',
22  'Documenting',
23  'Merging',
24  'Done',
25] as const
26// Any MCP tool, mcp__<server>__<tool>: slonk may be connected as slonk-developer,
27// slonk-qa, ... or as a connector named by a UUID, so a server is told by its answers.
28const MCP_TOOL = /^mcp__(.+)__([a-z_]+)$/i
29const ISSUE_KEY = /^[A-Z][A-Z0-9_]*-\d+$/
30// Plane's state groups: an issue carrying one of them came from slonk.
31const PLANE_GROUPS = new Set(['backlog', 'unstarted', 'started', 'completed', 'cancelled'])
32// Servers that answered with a slonk issue in this module's life.
33const slonkServers = new Set<string>()
34const isSlonk = (server: string) => /slonk/i.test(server) || slonkServers.has(server)
35
36const GROUP_COLOR: Record<string, string> = {
37  backlog: 'gray',
38  unstarted: 'blue',
39  started: 'yellow',
40  completed: 'green',
41  cancelled: 'red',
42}
43
44type Issue = { key: string; title: string; column: string; group: string }
45
46const asIssue = (value: unknown): Issue | null => {
47  if (typeof value !== 'object' || value === null) return null
48  const v = value as Record<string, unknown>
49  const state = v.state as Record<string, unknown> | undefined
50  if (typeof v.key !== 'string' || !ISSUE_KEY.test(v.key)) return null
51  if (typeof state?.name !== 'string' || typeof state.group !== 'string') return null
52  if (!PLANE_GROUPS.has(state.group)) return null
53
54  return {
55    key: v.key,
56    title: typeof v.name === 'string' ? v.name : '',
57    column: state.name,
58    group: state.group,
59  }
60}
61
62/** Every slonk issue a tool answer carries: a single issue or a list of them. */
63export const issuesIn = (text: string | undefined): Issue[] => {
64  if (!text) return []
65  let data: unknown
66  try {
67    data = JSON.parse(text)
68  } catch {
69    return []
70  }
71  const one = asIssue(data)
72  if (one) return [one]
73  const d = data as Record<string, unknown> | null
74  for (const field of ['issue', 'result']) {
75    const nested = asIssue(d?.[field])
76    if (nested) return [nested]
77  }
78  const list = d?.issues
79  return Array.isArray(list) ? list.map(asIssue).filter((i): i is Issue => i !== null) : []
80}
81
82const describe = (c: Card) => `${c.key} · ${c.column}${c.title ? ` — ${c.title}` : ''}`
83
84let poll: { cancel: () => void } | null = null
85let flicker: { cancel: () => void } | null = null
86// False once the host refused a call the mod made on its own (e.g. under the auto
87// permission mode, whose classifier only judges actions the model asked for).
88let canPoll = true
89
90const store = async ($: EngineInterface, issue: Issue, server: string) => {
91  const now = await $.clock.now()
92  const before = await read($, card)
93  if (before && before.key === issue.key && before.column !== issue.column) {
94    $.ui.toast(`${issue.key}: ${before.column} → ${issue.column}`)
95  }
96  const at = FLOW.indexOf(issue.column as (typeof FLOW)[number])
97  const flowIndex = at >= 0 ? at : before?.key === issue.key ? before.flowIndex : undefined
98  await update($, card, () => ({ ...issue, server, checkedAt: now, flowIndex }))
99  $.ui.status(`slonk ${issue.key} · ${issue.column}`)
100}
101
102/** Re-reads the tracked card (or `key`) through slonk's get_issue. */
103const refresh = async ($: EngineInterface, key?: string): Promise<string> => {
104  const current = await read($, card)
105  const target = key ?? current?.key
106  const server = current?.server ?? (await read($, lastServer))
107  if (!target) return 'Карточка ещё не выбрана: работайте со slonk или укажите /slonk-card <KEY>.'
108  if (!server) return 'Не знаю, через какой slonk-сервер читать: сначала вызовите любой инструмент slonk.'
109
110  try {
111    const res = await $.mcp.call(server, 'get_issue', { issue_id: target }).catch((err: unknown) => {
112      // The host refused the call itself (not the server answering an error).
113      canPoll = false
114      throw err
115    })
116    const text = res.content.map(block => block.text ?? '').join('')
117    const issue = issuesIn(text)[0]
118    if (res.isError || !issue) throw new Error(text.slice(0, 200) || 'пустой ответ')
119    await store($, issue, server)
120    return describe({ ...issue, server, checkedAt: 0 })
121  } catch (err) {
122    const message = err instanceof Error ? err.message : String(err)
123    if (current && current.key === target) {
124      await update($, card, c => (c ? { ...c, error: message } : c))
125    }
126    return `Не удалось прочитать ${target}: ${message}`
127  }
128}
129
130/** A card still travelling the flow: its current step flickers. */
131const isMoving = (c: Card) => c.column !== 'Done' && FLOW.includes(c.column as (typeof FLOW)[number])
132
133type StepState = 'done' | 'current' | 'blocked' | 'todo'
134
135/** How a step is painted: filled when passed or current, outlined when ahead. */
136export const stepLook = (step: StepState, isLit: boolean) => {
137  // Every step carries the same square border so all of them have one shape;
138  // a filled step's border is the colour of its fill.
139  const fill = (color: string) => ({ backgroundColor: color, borderStyle: 'single', borderColor: color })
140  if (step === 'done') return fill('success')
141  if (step === 'blocked') return fill('error')
142  if (step === 'current') return fill(isLit ? 'claude' : 'warning')
143  return { backgroundColor: undefined, borderStyle: 'single', borderColor: 'inactive' }
144}
145
146const STEP_PX = 7
147// Plane's own state colours, so the stepper reads like the board.
148const SVG_COLOR = { done: '#46A758', blocked: '#EF4444', current: '#F59E0B', currentLit: '#D97706', todo: '#9AA4BC' }
149
150/**
151 * The desktop stepper: one rectangle per FLOW column across the band's width,
152 * passed and current ones filled, the ones ahead outlined; the current one
153 * flickers through an SVG animation, so no redraw is needed for it.
154 */
155export const stepperSvg = (states: StepState[]) => {
156  const span = 1000
157  const gap = 8
158  const w = (span - gap * (states.length - 1)) / states.length
159  const rects = states.map((step, i) => {
160    const x = (i * (w + gap)).toFixed(2)
161    const size = `x="${x}" y="0.5" width="${w.toFixed(2)}" height="${STEP_PX - 1}"`
162    if (step === 'todo') {
163      return `<rect ${size} fill="none" stroke="${SVG_COLOR.todo}" stroke-width="1" vector-effect="non-scaling-stroke"/>`
164    }
165    if (step === 'current') {
166      return (
167        `<rect ${size} fill="${SVG_COLOR.current}">` +
168        `<animate attributeName="fill" values="${SVG_COLOR.current};${SVG_COLOR.currentLit};${SVG_COLOR.current}" dur="1.4s" repeatCount="indefinite"/>` +
169        `</rect>`
170      )
171    }
172    return `<rect ${size} fill="${step === 'done' ? SVG_COLOR.done : SVG_COLOR.blocked}"/>`
173  })
174
175  return (
176    // Wider than any band: the desktop draws an SVG at its own width capped by
177    // the slot, so this makes it span the band; the height is pinned by the prop.
178    `<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="${STEP_PX}" ` +
179    `viewBox="0 0 ${span} ${STEP_PX}" preserveAspectRatio="none">${rects.join('')}</svg>`
180  )
181}
182
183/** Terminal cell width so the nine steps and their gaps span the band. */
184const cellWidth = (columns: number) => Math.max(3, Math.floor((columns - (FLOW.length - 1)) / FLOW.length))
185
186/** What each FLOW cell shows for a card. */
187export const steps = (c: Pick<Card, 'column' | 'group' | 'flowIndex'>): StepState[] => {
188  const at = FLOW.indexOf(c.column as (typeof FLOW)[number])
189  const current = at >= 0 ? at : (c.flowIndex ?? -1)
190  const isDone = c.column === 'Done' || c.group === 'completed'
191  const isStuck = at < 0 && c.group !== 'completed'
192
193  return FLOW.map((_, i) => {
194    if (isDone || i < current) return 'done'
195    if (i === current) return isStuck ? 'blocked' : 'current'
196    return 'todo'
197  })
198}
199
200export const register: Register = on => {
201  on('session.start', async ($, e, next) => {
202    await $.command.register({
203      name: 'slonk-card',
204      description: 'Текущая карточка slonk: /slonk-card [KEY | off | hide | show]',
205    })
206    poll?.cancel()
207    flicker?.cancel()
208    flicker = $.clock.every(PULSE_MS, () => {
209      void read($, card).then(c => (c && isMoving(c) ? update($, pulse, p => !p) : undefined))
210    })
211    poll = $.clock.every(POLL_MS, () => {
212      if (canPoll) void read($, card).then(c => (c ? refresh($) : undefined))
213    })
214
215    return next(e)
216  })
217
218  on('session.end', async ($, e, next) => {
219    poll?.cancel()
220    poll = null
221    flicker?.cancel()
222    flicker = null
223
224    return next(e)
225  })
226
227  on('tool.call', { tool: MCP_TOOL }, async ($, e, next) => {
228    const ran = await next(e)
229    const [, server = '', name = ''] = MCP_TOOL.exec(e.tool) ?? []
230    if (ran.deny !== undefined || ran.isError) return ran
231
232    const args = e as unknown as Record<string, unknown>
233    const issueId = typeof args.issue_id === 'string' ? args.issue_id : undefined
234    const found = issuesIn(ran.text)
235    if (found.length > 0) slonkServers.add(server)
236    if (!isSlonk(server)) return ran
237    await update($, lastServer, () => server)
238
239    if (found.length === 1 && (issueId || name === 'create_issue' || name === 'get_issue')) {
240      // A single-card call: this card becomes the tracked one.
241      await store($, found[0]!, server)
242    } else if (found.length > 0) {
243      // A list: only refresh the tracked card if it is in there.
244      const current = await read($, card)
245      const same = current && found.find(i => i.key === current.key)
246      if (same) await store($, same, server)
247    } else if (issueId) {
248      // comment_issue, link_git_ref, ...: no state in the answer, ask for it.
249      const current = await read($, card)
250      if (current?.key !== issueId) {
251        await update($, card, () => ({ key: issueId, title: '', column: '…', group: '', server, checkedAt: 0 }))
252      }
253      await refresh($, issueId)
254    }
255
256    return ran
257  })
258
259  on('command.run', { command: 'slonk-card' }, async ($, e) => {
260    const arg = e.args.trim()
261
262    if (arg === 'off') {
263      await update($, card, () => null)
264      $.ui.status(undefined)
265      return { text: 'Слежение за карточкой выключено.' }
266    }
267    if (arg === 'hide' || arg === 'show') {
268      await update($, isHidden, () => arg === 'hide')
269      return { text: arg === 'hide' ? 'Полоса скрыта.' : 'Полоса показана.' }
270    }
271    if (arg && !ISSUE_KEY.test(arg.toUpperCase())) {
272      return { text: 'Использование: /slonk-card [KEY | off | hide | show]' }
273    }
274
275    return { text: await refresh($, arg ? arg.toUpperCase() : undefined) }
276  })
277
278  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279    const c = await read($, card)
280    if (!c || e.props.hasSurvey || (await read($, isHidden))) return next(e)
281
282    const { Box, Button, Text } = $.ui.resolve(e)
283    const minutes = c.checkedAt ? Math.floor(((await $.clock.now()) - c.checkedAt) / 60_000) : null
284
285    // Only the terminal flickers by redraw; the desktop SVG animates itself.
286    const isLit = e.surface === 'desktop' ? false : await read($, pulse)
287
288    let desktopStepper: RenderElement | null = null
289    if (e.surface === 'desktop') {
290      const { Svg } = $.ui.resolve(e)
291      const states = steps(c)
292      const at = Math.min(states.filter(st => st === 'done').length + 1, FLOW.length)
293      desktopStepper = (
294        <Box width="100%" marginTop={1}>
295          <Svg
296            source={stepperSvg(states)}
297            alt={`${c.key}: ${c.column}, шаг ${at} из ${FLOW.length}`}
298            height={STEP_PX}
299            isInteractive
300          />
301        </Box>
302      )
303    }
304
305    return (
306      <Box flexDirection="column">
307        <Box flexDirection="row" width="100%">
308          <Box flexShrink={0}>
309            <Text dimColor wrap="truncate">slonk </Text>
310            <Text bold wrap="truncate">{c.key}</Text>
311            <Text wrap="truncate"> · </Text>
312            <Text bold wrap="truncate" color={GROUP_COLOR[c.group] ?? 'white'}>
313              {c.column}
314            </Text>
315          </Box>
316          <Box flexGrow={1} flexShrink={1} minWidth={0}>
317            {c.title !== '' && (
318              <Text dimColor wrap="truncate-end">
319                {' '}
320                {c.title}
321              </Text>
322            )}
323          </Box>
324          <Box flexShrink={0}>
325            {minutes !== null && minutes > 0 && <Text dimColor wrap="truncate"> · {minutes} мин назад</Text>}
326            <Text> </Text>
327            <Button key="refresh" label="Обновить" onPress={() => void refresh($)} />
328            <Button key="hide" label="Скрыть" onPress={() => update($, isHidden, () => true)} />
329          </Box>
330        </Box>
331        {desktopStepper ?? (
332          <Box flexDirection="row">
333            {steps(c).map((step, i) => {
334              const look = stepLook(step, isLit)
335              const width = cellWidth(e.props.bodyColumns)
336              return (
337                <Text key={`step-${i}`} backgroundColor={look.backgroundColor} color={look.borderColor}>
338                  {i === 0 ? '' : ' '}
339                  {look.backgroundColor ? ' '.repeat(width) : `▕${' '.repeat(Math.max(0, width - 2))}▏`}
340                </Text>
341              )
342            })}
343          </Box>
344        )}
345      </Box>
346    )
347  })
348}
349
types/index.d.ts 30 lines
1export type Card = {
2  /** Issue key, e.g. SLONK-37 */
3  key: string
4  title: string
5  /** Column (state name), e.g. "Code Review" */
6  column: string
7  /** Plane state group: backlog | unstarted | started | completed | cancelled */
8  group: string
9  /** MCP server the card was last seen through, e.g. slonk-developer */
10  server: string
11  /** Epoch ms of the last successful read */
12  checkedAt: number
13  /** Last refresh error, if the latest read failed */
14  error?: string
15  /** Position in FLOW of the last flow column the card was in (kept while Blocked) */
16  flowIndex?: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'slonk-card': {
22      card: Card | null
23      lastServer: string | null
24      isHidden: boolean
25      /** Toggles while a card is in progress: the stepper's current step flickers on it */
26      pulse: boolean
27    }
28  }
29}
30