SLOPSHOPPER

Sherlog

Groom an issue backlog: find stale, duplicate, vague, unlabeled and unprioritized issues, report them, and apply only the fixes you approve.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sherlog
│ ┃ groom-report ✕ › fix the failing auth test and add an audit log call │ ┃ No grooming report yet. Run /sherlog:groom. │ ⏺ 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 │ │ › /groom-report │ ⎿ sherlog: No grooming report yet. Run /sherlog:groom first. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · groom-report
No grooming report yet. Run /sherlog:groom.
README

Sherlog

Sherlog investigates your issue backlog like a detective. It finds stale, duplicate, vague, unlabeled/mislabeled, unprioritized and orphaned issues, shows a numbered report of proposed fixes, and applies only the items you approve.

Linear is supported today. The workflow is tracker-agnostic: each tracker is a single adapter file under skills/groom/references/trackers/.

Prerequisites

You need one Linear MCP connection in Claude Code:

  • the claude.ai Linear connector (Settings → Connectors on claude.ai, while logged in to the same account), or
  • the official plugin: /plugin install linear@claude-plugins-official, then authenticate when prompted.

This plugin doesn't bundle its own Linear server, so you don't end up with a duplicate set of Linear tools.

Install

/plugin marketplace add Sherlemious/claude-plugins
/plugin install sherlog@sherlemious

Use

Ask in plain language:

clean up our Linear backlog find duplicate tickets in the Mobile project groom my issues

Or call the skill directly:

/sherlog:groom                     # the only team, or asks which team
/sherlog:groom TheLearningMate     # a team
/sherlog:groom "Q4 Launch"         # a project
/sherlog:groom mine stale=30       # your issues, 30-day stale threshold

Checklist pane (TUI)

In an interactive Claude Code session, the report opens as a checklist pane, and the chat shows only a short summary. In fullscreen the pane docks beside the transcript; otherwise it sits above the prompt.

▤ TheLearningMate · Linear · 132 scanned · ◷ 60d · ✓ 0/12
[ y: Apply 1 selected ]  [ a: Select shown ]  [ c: Clear ]  [ q: Close ]
≡ All 12  ⧉ Duplicate 3  ◷ Stale 5  ? Vague 2  ◆ Labels 2

[x]  1 ▂▄▆ ▾ TLM-42 ✎ ⧉ Mark duplicate of TLM-17 + comment
    ╭──────────────────────────────────────────────────────────╮
    │ Login fails on Safari                                    │
    │ Priority ▂▄▆ High                                        │
    │ Will do Mark duplicate of TLM-17 + comment               │
    │ Why Same Safari failure; TLM-17 has repro steps          │
    │                                                          │
    │ ✎ Comment to post                                        │
    │ Marking as duplicate of TLM-17, which covers the same …  │
    │ Open in Linear ↗                                         │
    ╰──────────────────────────────────────────────────────────╯
[ ]  2 ··· ▸ TLM-88 ✎ ◷ Cancel + comment
[ ]  3 ▂   ▸ TLM-95   ◆ +Feature
  • Click a ticket ID (or press Enter on it) to expand it in place. It shows the title, current priority, what will happen, why, the exact comment or new description to be posted, and a link to the issue. Only one ticket is open at a time, and clicking the ID again collapses it.
  • Ticking a row also expands it, so you see what you're approving.
  • Drafts longer than 10 lines are cut off until you press Show all.
  • Icons: priority shows as !!! urgent, ▂▄▆ high, ▂▄ medium, ▂ low or ··· none. ✎ marks rows that post text. Buckets are ⧉ duplicate, ⇄ related, ◷ stale, ? vague, ◆ labels, ▲ priority, ◔ estimate and ○ orphaned.
  • ctrl+x tab focuses the pane, Tab or the arrow keys move, and y applies. Clicking a bucket filters the rows.
  • Apply sends apply 1, 4-6 as your message, exactly as if you had typed it. Rows then update to ✓ / ✗ as the writes finish.
  • Show or hide the pane: /groom-report toggles it, and q (Hide) closes it. If it gets closed while a report is still active, a one-line band above the prompt shows ▤ Grooming report · N selected · [ g: Show ] [ h: Dismiss ], so you can bring it back. Your ticks are kept.

When no pane can be shown (headless runs, older Claude Code versions), the full markdown report is printed in chat instead.

What it proposes

BucketProposed action
Stale (not started, untouched for 60+ days)Cancel, with a comment explaining why
DuplicateMark as a duplicate of the canonical issue, with a comment, or link as related
Vague (no repro steps or acceptance criteria)A description rewrite, or a comment asking the creator specific questions
Unlabeled / mislabeledAdd or remove a Bug / Feature / Improvement label
Missing priority / estimatePropose a value with a reason
Orphaned (no project)Assign an obvious project, or flag the issue as needing a home

Safety model

  • It reports first. Nothing changes until you reply with item numbers, e.g. apply 1-4, 7. Even "just fix everything" gets a report first.
  • It never deletes. Closing uses the Canceled status.
  • Every close or duplicate-mark gets a comment explaining why.
  • Labels are changed one at a time, so your other labels are never touched.
  • A batch is capped at 25 issues.
  • Writes still go through permission prompts. The skill pre-approves only read-only Linear tools.

What this plugin reads, runs and sends

  • No servers, network calls or credentials of its own. The plugin bundles no MCP server, makes no HTTP requests, runs no package installs or shell scripts, and asks for no API keys. Its only external access is through the Linear connection you already have (the claude.ai Linear connector or linear@claude-plugins-official), with your account's permissions.
  • What it reads from Linear: your teams, workflow statuses, labels, projects and cycles, the current user (for the mine scope), and the open issues in the scope you choose. That includes their titles, descriptions, status, labels, priority, estimate, assignee, creator, dates and URLs. Comments are read only for candidate issues. The read-only list_* and get_* Linear tools are pre-approved while the skill runs, so these reads don't prompt.
  • What it writes to Linear, and only for the items you approve by number: status changes to Canceled, duplicate and related links, label additions and removals, priority, estimate and project, description rewrites, and comments. Every write goes through Claude Code's normal permission prompt. Nothing is ever deleted.
  • What it stores: only the current report, which items you've ticked and the apply results. These are held in Claude Code's session state for the checklist pane, are never written to disk, and are gone when the session ends.
  • The checklist pane is the hooks module hooks/register.tsx, readable TypeScript that Claude Code loads. What the hooks do lists each hook.
  • No telemetry. Issue data goes nowhere except the Claude conversation you run the skill in and the Linear workspace it came from.

The skill and agent are plain Markdown. The pane is a Claude Code feature; on surfaces without panes, the skill prints the same report in chat.

What the hooks do

hooks/hooks.json loads one module, hooks/register.tsx, which registers these hooks. None of them makes network requests, reads or writes files, or runs processes.

HookWhat it does
session.startRegisters the two pane tools below and the /groom-report command, then continues the session normally.
tool.call for mcp__sherlog__show_reportServes this plugin's own tool. It stores the report the skill passes in, resets ticks and results, and opens the pane. It answers with whether the pane opened, so the skill knows to keep the chat short or print the full report.
tool.call for mcp__sherlog__mark_resultsServes this plugin's own tool. It records each item as applied, failed or skipped so the pane shows ✓ / ✗ / –.
command.run for /groom-reportShows the pane if it's hidden, or hides it if it's shown.
ui.close for the groom-report paneNotes that the pane was closed, by its close mark, Esc or Hide, then lets the close go through unchanged.
ui.render for the groom-report paneDraws the checklist pane: header, buttons, bucket filters, rows and the expanded ticket.
ui.render above the promptWhen a report is active and the pane is closed, draws the one-line band with Show and Dismiss. Otherwise it leaves the area above the prompt untouched.
Tools this mod stands in for

The mod's two tool.call hooks answer for, and stand in for, exactly two tools: the ones it registers itself, mcp__sherlog__show_report and mcp__sherlog__mark_results. No other code implements those tools, so the hooks return their own result instead of calling next.

  • show_report is called once per grooming run, after the report is built. The hook stores the report and opens the pane.
  • mark_results is called after the approved writes. The hook marks rows as applied, failed or skipped.

No hook matches any other tool, so every other tool call, Linear included, runs exactly as it would without the plugin.

Prompts this mod submits

The mod submits exactly one kind of prompt: apply <item numbers>, for example apply 1-3, 7.

  • When: only when you press Apply (or its hotkey y) in the pane with at least one row ticked, and at most 25 items per prompt.
  • What goes into it: only the item numbers of the rows you ticked. The pane reads these from its own session state with state.get, and that is the only stored data that ever leaves the mod in a prompt. Issue titles, descriptions, drafts and URLs are never put into a prompt.
  • As whom: it is submitted as your own message, exactly the text you would type to approve those items.

The mod never submits any other prompt, adds no hidden context, and doesn't change the system prompt.

Customize

Edit skills/groom/references/heuristics.md to change thresholds and rules, and issue-quality-rubric.md to change what a "good issue" looks like.

Adding a tracker

Copy skills/groom/references/trackers/linear.md to <tracker>.md and map each abstract operation to that tracker's tools. Mark the operations it can't support. Then add detection for it in SKILL.md, and add its read-tool globs to allowed-tools.

Source 2 files
hooks/register.tsx 524 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GroomItem, GroomReport, GroomResult, GroomResultStatus } from '../types'
5
6const PANE = 'groom-report'
7const MAX_BATCH = 25
8
9const report = atom({ plugin: 'sherlog', key: 'report' } as const, null)
10const selected = atom({ plugin: 'sherlog', key: 'selected' } as const, [])
11const results = atom({ plugin: 'sherlog', key: 'results' } as const, {})
12const bucket = atom({ plugin: 'sherlog', key: 'bucket' } as const, 'all')
13const expanded = atom({ plugin: 'sherlog', key: 'expanded' } as const, null)
14const showAll = atom({ plugin: 'sherlog', key: 'showAll' } as const, false)
15const isPaneOpen = atom({ plugin: 'sherlog', key: 'isPaneOpen' } as const, false)
16const isBandHidden = atom({ plugin: 'sherlog', key: 'isBandHidden' } as const, false)
17
18const DRAFT_LINES = 10
19
20/** Glyph and color per bucket: single-width symbols so rows stay aligned. */
21const BUCKET_ICONS: Record<string, { icon: string; color: string }> = {
22  Duplicate: { icon: '⧉', color: 'magenta' },
23  Related: { icon: '⇄', color: 'blue' },
24  Stale: { icon: '◷', color: 'gray' },
25  Vague: { icon: '?', color: 'yellow' },
26  Labels: { icon: '◆', color: 'cyan' },
27  Priority: { icon: '▲', color: 'red' },
28  Estimate: { icon: '◔', color: 'green' },
29  Orphaned: { icon: '○', color: 'white' },
30}
31
32function iconOf(name: string): { icon: string; color: string } {
33  return BUCKET_ICONS[name] ?? { icon: '•', color: 'white' }
34}
35
36/** Linear-style priority bars, always three cells wide. */
37function priorityOf(priority: number | undefined): { bars: string; color?: string; label: string } {
38  switch (priority) {
39    case 1:
40      return { bars: '!!!', color: 'red', label: 'Urgent' }
41    case 2:
42      return { bars: '▂▄▆', color: 'yellow', label: 'High' }
43    case 3:
44      return { bars: '▂▄ ', label: 'Medium' }
45    case 4:
46      return { bars: '▂  ', label: 'Low' }
47    case 0:
48      return { bars: '···', label: 'No priority' }
49    default:
50      return { bars: '   ', label: 'Unknown' }
51  }
52}
53
54const STATUSES: readonly GroomResultStatus[] = ['pending', 'applied', 'failed', 'skipped']
55
56const SHOW_SCHEMA = {
57  type: 'object',
58  properties: {
59    scope: { type: 'string', description: 'What was groomed, e.g. "Team TheLearningMate"' },
60    tracker: { type: 'string', description: 'e.g. "Linear"' },
61    scanned: { type: 'number', description: 'Open issues scanned' },
62    staleDays: { type: 'number' },
63    skipped: { type: 'string', description: 'Skipped buckets and why, or omit' },
64    items: {
65      type: 'array',
66      items: {
67        type: 'object',
68        properties: {
69          n: { type: 'number', description: 'Item number; "apply" messages use it' },
70          issue: { type: 'string', description: 'Human identifier, e.g. TLM-42' },
71          url: { type: 'string' },
72          title: { type: 'string' },
73          bucket: {
74            type: 'string',
75            description: 'Duplicate, Related, Stale, Vague, Labels, Priority, Estimate, Orphaned',
76          },
77          action: { type: 'string', description: 'Short literal action, e.g. "Cancel + comment"' },
78          reason: { type: 'string', description: 'One line of concrete evidence' },
79          draft: {
80            type: 'string',
81            description:
82              'The exact markdown the apply step will post for this item (the comment, or the new description), including standard close/duplicate comments. Omit only when nothing is posted.',
83          },
84          draftKind: { type: 'string', description: '"Comment" or "New description"' },
85          priority: {
86            type: 'number',
87            description: 'Current priority, 0 none, 1 urgent, 2 high, 3 medium, 4 low',
88          },
89        },
90        required: ['n', 'issue', 'title', 'bucket', 'action', 'reason'],
91      },
92    },
93  },
94  required: ['scope', 'tracker', 'scanned', 'items'],
95}
96
97const MARK_SCHEMA = {
98  type: 'object',
99  properties: {
100    results: {
101      type: 'array',
102      items: {
103        type: 'object',
104        properties: {
105          n: { type: 'number' },
106          status: { type: 'string', enum: ['applied', 'failed', 'skipped'] },
107          note: { type: 'string' },
108        },
109        required: ['n', 'status'],
110      },
111    },
112  },
113  required: ['results'],
114}
115
116function asText(value: unknown, fallback = ''): string {
117  return typeof value === 'string' ? value : fallback
118}
119
120function optionalText(value: unknown): string | undefined {
121  return typeof value === 'string' && value.trim() !== '' ? value : undefined
122}
123
124function parseItems(value: unknown): GroomItem[] {
125  if (!Array.isArray(value)) return []
126  const items: GroomItem[] = []
127  for (const raw of value) {
128    if (raw === null || typeof raw !== 'object') continue
129    const one = raw as Record<string, unknown>
130    if (typeof one.n !== 'number') continue
131    items.push({
132      n: one.n,
133      issue: asText(one.issue, '?'),
134      url: optionalText(one.url),
135      title: asText(one.title),
136      bucket: asText(one.bucket, 'Other'),
137      action: asText(one.action),
138      reason: asText(one.reason),
139      draft: optionalText(one.draft),
140      draftKind: optionalText(one.draftKind),
141      priority:
142        typeof one.priority === 'number' && one.priority >= 0 && one.priority <= 4
143          ? Math.round(one.priority)
144          : undefined,
145    })
146  }
147  return items.sort((a, b) => a.n - b.n)
148}
149
150/** [1,2,3,5,7,8] -> "1-3, 5, 7-8" */
151export function compressRanges(numbers: readonly number[]): string {
152  const sorted = [...new Set(numbers)].sort((a, b) => a - b)
153  const runs: [number, number][] = []
154  for (const n of sorted) {
155    const last = runs[runs.length - 1]
156    if (last !== undefined && n === last[1] + 1) last[1] = n
157    else runs.push([n, n])
158  }
159  return runs.map(([from, to]) => (from === to ? `${from}` : `${from}-${to}`)).join(', ')
160}
161
162function bucketsOf(items: readonly GroomItem[]): string[] {
163  return [...new Set(items.map(item => item.bucket))]
164}
165
166function mark(result: GroomResult | undefined, isSelected: boolean): string {
167  switch (result?.status) {
168    case 'pending':
169      return '[…]'
170    case 'applied':
171      return '[✓]'
172    case 'failed':
173      return '[✗]'
174    case 'skipped':
175      return '[-]'
176    default:
177      return isSelected ? '[x]' : '[ ]'
178  }
179}
180
181async function openPane($: EngineInterface, focus: boolean) {
182  const shown = await read($, report)
183  const title = shown === null ? 'Backlog grooming' : `Grooming: ${shown.scope}`
184  const opened = await $.ui.open(focus ? { id: PANE, title, focus: true } : { id: PANE, title })
185  await update($, isPaneOpen, () => true)
186  return opened
187}
188
189async function closePane($: EngineInterface) {
190  await $.ui.close({ id: PANE })
191  await update($, isPaneOpen, () => false)
192}
193
194async function apply($: EngineInterface) {
195  const shown = await read($, report)
196  const done = await read($, results)
197  const picked = (await read($, selected)).filter(n => done[String(n)] === undefined)
198  if (shown === null || picked.length === 0) {
199    $.ui.toast('Select at least one item to apply.')
200    return
201  }
202  const batch = [...picked].sort((a, b) => a - b).slice(0, MAX_BATCH)
203  if (picked.length > MAX_BATCH) {
204    $.ui.toast(`Applying the first ${MAX_BATCH}; batches are capped.`)
205  }
206  await update($, results, current => {
207    const next = { ...current }
208    for (const n of batch) next[String(n)] = { status: 'pending' }
209    return next
210  })
211  await update($, selected, current => current.filter(n => !batch.includes(n)))
212  await $.prompt.submit({ text: `apply ${compressRanges(batch)}`, asUser: true })
213}
214
215export const register: Register = on => {
216  on('session.start', async ($, e, next) => {
217    await $.tool.register({
218      name: 'show_report',
219      description:
220        'Shows a backlog grooming report as an interactive checklist pane; each row expands to show its exact comment or rewrite. Call it before writing the report in chat. The result says whether the pane is open: if so, keep the chat to a short summary. The person ticks items and presses Apply, which sends "apply <numbers>" as their message. Changes nothing in the tracker.',
221      inputSchema: SHOW_SCHEMA,
222    })
223    await $.tool.register({
224      name: 'mark_results',
225      description:
226        'Marks report items as applied, failed or skipped in the grooming pane, after the corresponding tracker writes. Changes nothing in the tracker.',
227      inputSchema: MARK_SCHEMA,
228    })
229    await $.command.register({
230      name: 'groom-report',
231      description: 'Show or hide the backlog grooming checklist pane',
232    })
233    return next(e)
234  })
235
236  on('command.run', { command: 'groom-report' }, async $ => {
237    if ((await read($, report)) === null) {
238      return { text: 'No grooming report yet. Run /sherlog:groom first.' }
239    }
240    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
241    if (isUp) {
242      await closePane($)
243      return { text: 'Grooming pane hidden. Run /groom-report again, or press Show above the prompt, to bring it back.' }
244    }
245    await update($, isBandHidden, () => false)
246    await openPane($, true)
247    return { text: 'Grooming pane shown.' }
248  })
249
250  // However the pane goes away (its close mark, Esc, the Hide button), remember it.
251  on('ui.close', { id: 'groom-report' }, async ($, e, next) => {
252    const closed = await next(e)
253    await update($, isPaneOpen, () => false)
254    return closed
255  })
256
257  // Pane closed while a report is live: a one-line band above the prompt brings it back.
258  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259    const shown = await read($, report)
260    const isQuiet =
261      e.props.hasSurvey ||
262      shown === null ||
263      (await read($, isPaneOpen)) ||
264      (await read($, isBandHidden))
265    if (isQuiet || shown === null) return next(e)
266
267    const { Box, Text, Button } = $.ui.resolve(e)
268    const done = await read($, results)
269    const picked = (await read($, selected)).filter(n => done[String(n)] === undefined)
270    const applied = Object.values(done).filter(result => result.status === 'applied').length
271    return (
272      <Box key="groom-band" flexDirection="row" columnGap={2}>
273        <Text wrap="truncate-end">
274          <Text bold>▤ Grooming report</Text>
275          <Text dimColor>
276            {' '}
277            · {shown.scope} · {picked.length} selected · ✓ {applied}/{shown.items.length}
278          </Text>
279        </Text>
280        <Button key="band-show" hotkey="g" variant="primary" label="Show" onPress={() => openPane($, true)} />
281        <Button key="band-dismiss" hotkey="h" dimColor label="Dismiss" onPress={() => update($, isBandHidden, () => true)} />
282      </Box>
283    )
284  })
285
286  on('tool.call', { tool: 'mcp__sherlog__show_report' }, async ($, e) => {
287    const items = parseItems(e.items)
288    if (items.length === 0) {
289      return { deny: 'show_report needs at least one item with a numeric "n".' }
290    }
291    const next: GroomReport = {
292      scope: asText(e.scope, 'backlog'),
293      tracker: asText(e.tracker, 'tracker'),
294      scanned: typeof e.scanned === 'number' ? e.scanned : items.length,
295      staleDays: typeof e.staleDays === 'number' ? e.staleDays : undefined,
296      skipped: optionalText(e.skipped),
297      items,
298    }
299    await update($, report, () => next)
300    await update($, selected, () => [])
301    await update($, results, () => ({}))
302    await update($, bucket, () => 'all')
303    await update($, expanded, () => null)
304    await update($, showAll, () => false)
305    await update($, isBandHidden, () => false)
306
307    let isOpen = false
308    try {
309      isOpen = (await openPane($, true)).isPlaced
310    } catch {
311      // Headless runs and surfaces without panes: the chat report stands alone.
312    }
313    return {
314      result: isOpen
315        ? `PANE OPEN with ${items.length} items and their drafts. In chat, print only the report header, the per-bucket counts and one line: "Review and tick items in the pane (ctrl+x tab), or type apply <numbers>." Do not print the table or drafts unless the person asks.`
316        : `PANE NOT SHOWN. Print the full markdown report (table and drafts) in chat. Tell the person they can run /groom-report to open the checklist.`,
317    }
318  })
319
320  on('tool.call', { tool: 'mcp__sherlog__mark_results' }, async ($, e) => {
321    const list = Array.isArray(e.results) ? e.results : []
322    let count = 0
323    await update($, results, current => {
324      const next = { ...current }
325      for (const raw of list) {
326        if (raw === null || typeof raw !== 'object') continue
327        const one = raw as Record<string, unknown>
328        const status = one.status as GroomResultStatus
329        if (typeof one.n !== 'number' || !STATUSES.includes(status)) continue
330        next[String(one.n)] = typeof one.note === 'string' ? { status, note: one.note } : { status }
331        count += 1
332      }
333      return next
334    })
335    return { result: `Marked ${count} item(s) in the pane.` }
336  })
337
338  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
339    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
340    const shown = await read($, report)
341    if (shown === null) {
342      return <Text dimColor>No grooming report yet. Run /sherlog:groom.</Text>
343    }
344    const picked = await read($, selected)
345    const done = await read($, results)
346    const filter = await read($, bucket)
347    const open = await read($, expanded)
348    const isShowingAll = await read($, showAll)
349    const visible = filter === 'all' ? shown.items : shown.items.filter(item => item.bucket === filter)
350    const pickable = picked.filter(n => done[String(n)] === undefined)
351    const applied = Object.values(done).filter(result => result.status === 'applied').length
352
353    const expand = async (n: number) => {
354      await update($, showAll, () => false)
355      await update($, expanded, () => n)
356    }
357    const toggleExpand = async (n: number) => {
358      await update($, showAll, () => false)
359      await update($, expanded, current => (current === n ? null : n))
360    }
361    // Ticking a row opens it, so the text it will post is in view.
362    const toggle = async (n: number) => {
363      const isNowPicked = !picked.includes(n)
364      await update($, selected, current =>
365        current.includes(n) ? current.filter(one => one !== n) : [...current, n],
366      )
367      if (isNowPicked) await expand(n)
368    }
369    const selectVisible = () =>
370      update($, selected, current => {
371        const ids = visible.filter(item => done[String(item.n)] === undefined).map(item => item.n)
372        return [...new Set([...current, ...ids])]
373      })
374
375    const details = (item: GroomItem) => {
376      const result = done[String(item.n)]
377      const priority = priorityOf(item.priority)
378      const lines = item.draft?.split('\n') ?? []
379      const isLong = lines.length > DRAFT_LINES
380      const draftText =
381        isLong && !isShowingAll ? `${lines.slice(0, DRAFT_LINES).join('\n')}\n…` : item.draft
382      return (
383        <Box
384          key={`details:${item.n}`}
385          flexDirection="column"
386          marginLeft={4}
387          marginBottom={1}
388          borderStyle="round"
389          borderDimColor
390          paddingX={1}
391        >
392          <Text bold wrap="truncate-end">
393            {item.title}
394          </Text>
395          <Text>
396            <Text dimColor>Priority </Text>
397            <Text color={priority.color}>{priority.bars.trim() === '' ? '–' : priority.bars.trim()}</Text>
398            <Text dimColor> {priority.label}</Text>
399          </Text>
400          <Text>
401            <Text dimColor>Will do </Text>
402            {item.action}
403          </Text>
404          <Text>
405            <Text dimColor>Why </Text>
406            {item.reason}
407          </Text>
408          {result?.note !== undefined && <Text color="red">Result: {result.note}</Text>}
409          {draftText === undefined ? (
410            <Text dimColor>Nothing will be posted on this issue.</Text>
411          ) : (
412            <Box flexDirection="column" marginTop={1}>
413              <Text color="yellow">✎ {item.draftKind ?? 'Comment'} to post</Text>
414              <Markdown key={`draft:${item.n}`} text={draftText} />
415              {isLong && (
416                <Button
417                  key={`more:${item.n}`}
418                  plain
419                  dimColor
420                  label={isShowingAll ? 'Show less' : `Show all (${lines.length - DRAFT_LINES} more lines)`}
421                  onPress={() => update($, showAll, current => !current)}
422                />
423              )}
424            </Box>
425          )}
426          {item.url !== undefined && (
427            <Markdown key={`link:${item.n}`} text={`[Open in ${shown.tracker} ↗](${item.url})`} />
428          )}
429        </Box>
430      )
431    }
432
433    return (
434      <Box flexDirection="column">
435        <Text>
436          <Text bold>▤ {shown.scope}</Text>
437          <Text dimColor>
438            {' '}
439            · {shown.tracker} · {shown.scanned} scanned
440            {shown.staleDays === undefined ? '' : ` · ◷ ${shown.staleDays}d`} · ✓ {applied}/
441            {shown.items.length}
442          </Text>
443        </Text>
444        {shown.skipped !== undefined && (
445          <Text dimColor wrap="truncate-end">
446            Skipped: {shown.skipped}
447          </Text>
448        )}
449
450        <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
451          <Button
452            key="apply"
453            hotkey="y"
454            variant="primary"
455            label={`Apply ${pickable.length} selected`}
456            onPress={() => apply($)}
457          />
458          <Button key="select-all" hotkey="a" label="Select shown" onPress={selectVisible} />
459          <Button key="clear" hotkey="c" label="Clear" onPress={() => update($, selected, () => [])} />
460          <Button key="close" hotkey="q" role="dismiss" label="Hide" onPress={() => closePane($)} />
461        </Box>
462
463        <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
464          {['all', ...bucketsOf(shown.items)].map(name => {
465            const count =
466              name === 'all' ? shown.items.length : shown.items.filter(item => item.bucket === name).length
467            const icon = name === 'all' ? '≡' : iconOf(name).icon
468            return (
469              <Button
470                key={`bucket:${name}`}
471                plain
472                dimColor={filter !== name}
473                label={`${icon} ${name === 'all' ? 'All' : name} ${count}`}
474                onPress={() => update($, bucket, () => name)}
475              />
476            )
477          })}
478        </Box>
479
480        <Box flexDirection="column" marginTop={1}>
481          {visible.map(item => {
482            const result = done[String(item.n)]
483            const isOpen = item.n === open
484            const icon = iconOf(item.bucket)
485            const priority = priorityOf(item.priority)
486            return (
487              <Box key={`row:${item.n}`} flexDirection="column">
488                <Box flexDirection="row" columnGap={1}>
489                  <Button
490                    key={`toggle:${item.n}`}
491                    plain
492                    label={mark(result, picked.includes(item.n))}
493                    onPress={() => (result === undefined ? toggle(item.n) : expand(item.n))}
494                  />
495                  <Text dimColor>{String(item.n).padStart(2)}</Text>
496                  <Text color={priority.color} dimColor={priority.color === undefined}>
497                    {priority.bars}
498                  </Text>
499                  <Button
500                    key={`view:${item.n}`}
501                    plain
502                    dimColor={!isOpen}
503                    label={`${isOpen ? '▾' : '▸'} ${item.issue}`}
504                    onPress={() => toggleExpand(item.n)}
505                  />
506                  <Text color="yellow">{item.draft === undefined ? ' ' : '✎'}</Text>
507                  <Text color={icon.color}>{icon.icon}</Text>
508                  <Text wrap="truncate-end">{item.action}</Text>
509                </Box>
510                {isOpen && details(item)}
511              </Box>
512            )
513          })}
514        </Box>
515
516        <Text dimColor>
517          ctrl+x tab focus · Tab/arrows move · Enter on ▸ expands · Enter on [ ] ticks · ✎ posts text ·
518          /groom-report shows or hides this pane · nothing changes until Apply
519        </Text>
520      </Box>
521    )
522  })
523}
524
types/index.d.ts 44 lines
1export type GroomItem = {
2  n: number
3  issue: string
4  url?: string
5  title: string
6  bucket: string
7  action: string
8  reason: string
9  /** Current priority on the tracker's 0-4 scale (0 none, 1 urgent ... 4 low). */
10  priority?: number
11  /** Exact text the apply step will post: the comment, or the new description. */
12  draft?: string
13  /** What `draft` is: "Comment", "New description", ... */
14  draftKind?: string
15}
16
17export type GroomReport = {
18  scope: string
19  tracker: string
20  scanned: number
21  staleDays?: number
22  skipped?: string
23  items: GroomItem[]
24}
25
26export type GroomResultStatus = 'pending' | 'applied' | 'failed' | 'skipped'
27
28export type GroomResult = { status: GroomResultStatus; note?: string }
29
30declare module 'claude-code' {
31  interface PluginState {
32    'sherlog': {
33      report: GroomReport | null
34      selected: number[]
35      results: Record<string, GroomResult>
36      bucket: string
37      expanded: number | null
38      showAll: boolean
39      isPaneOpen: boolean
40      isBandHidden: boolean
41    }
42  }
43}
44