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

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/.
You need one Linear MCP connection in Claude Code:
/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.
/plugin marketplace add Sherlemious/claude-plugins
/plugin install sherlog@sherlemious
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
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
!!! 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 1, 4-6 as your message, exactly as if you had typed it. Rows then update to ✓ / ✗ as the writes finish./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.
| Bucket | Proposed action |
|---|---|
| Stale (not started, untouched for 60+ days) | Cancel, with a comment explaining why |
| Duplicate | Mark 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 / mislabeled | Add or remove a Bug / Feature / Improvement label |
| Missing priority / estimate | Propose a value with a reason |
| Orphaned (no project) | Assign an obvious project, or flag the issue as needing a home |
apply 1-4, 7. Even "just fix everything" gets a report first.linear@claude-plugins-official), with your account's permissions.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.hooks/register.tsx, readable TypeScript that Claude Code loads. What the hooks do lists each hook.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.
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.
| Hook | What it does |
|---|---|
session.start | Registers the two pane tools below and the /groom-report command, then continues the session normally. |
tool.call for mcp__sherlog__show_report | Serves 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_results | Serves this plugin's own tool. It records each item as applied, failed or skipped so the pane shows ✓ / ✗ / –. |
command.run for /groom-report | Shows the pane if it's hidden, or hides it if it's shown. |
ui.close for the groom-report pane | Notes 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 pane | Draws the checklist pane: header, buttons, bucket filters, rows and the expanded ticket. |
ui.render above the prompt | When 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. |
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.
The mod submits exactly one kind of prompt: apply <item numbers>, for example apply 1-3, 7.
y) in the pane with at least one row ticked, and at most 25 items per prompt.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.The mod never submits any other prompt, adds no hidden context, and doesn't change the system prompt.
Edit skills/groom/references/heuristics.md to change thresholds and rules, and issue-quality-rubric.md to change what a "good issue" looks like.
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.
hooks/register.tsx 524 lines1import { 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}
524types/index.d.ts 44 lines1export 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