SLOPSHOPPER

check-docs

A /check-docs pane with the markdown files written in this conversation, as tabs

newpaneguardcommandtimer
v1.0.0no licenseupdated 2026-10-10DiegoHeer/claude-mods/check-docs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · check-docs
│ ┃ Check docs ✕ › fix the failing auth test and add an audit log call │ ┃ No markdown files written in this │ ┃ conversation yet. ⏺ 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 │ │ › /check-docs │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Check docs
No markdown files written in this conversation yet.
README

claude-mods

My Claude Code mods, shared as a plugin marketplace. Mods need Claude Code v2.1.287 or later.

Install

In a Claude Code session:

/plugin marketplace add DiegoHeer/claude-mods
/plugin install review-diff@claude-mods
/plugin install check-docs@claude-mods
/plugin install skills@claude-mods
/plugin install statusline@claude-mods

Update later with /plugin marketplace update claude-mods.

Upgrading from branch-diff: it was renamed, so the update won't carry it over. Run /plugin uninstall branch-diff@claude-mods, then /plugin install review-diff@claude-mods.

Mods

review-diff

/review-diff opens a pane with every file changed on this branch compared to its base, committed, uncommitted and untracked work included, drawn like a pull request's "Files changed" view.

The base is git config review-diff.base if set, else origin/HEAD, else the first of origin/main, origin/master, main, master that exists. To pin it for a repo: git config review-diff.base develop.

Worktrees: the pane follows the worktree Claude last worked in (the one it entered with EnterWorktree, or the one holding the last file it edited), else the session's. When the repo has more than one worktree, a worktree picker in the header pins the pane to any of them; pick follow Claude to go back to following. Review comments stay with the worktree they were written in.

  • f switches between whole files and changed parts only
  • r refreshes; the pane also refreshes on its own after Claude edits files or runs commands
  • w steps to the next worktree, then back to follow Claude (shown with two or more worktrees)
  • Press a file in the list to jump to it
  • Files with uncommitted changes carry a ● uncommitted tag
  • Untracked files (not ignored) show in full with a ● new, untracked tag, up to 50 of them (files over 1 MB are listed as binary, not drawn)

Review comments (terminal and desktop, needs the mouse):

  • Click a line to comment on it; drag or shift-click to pick several lines
  • Type the comment and press Enter to keep it as a draft (✎ marks its lines)
  • s sends all drafts to Claude as one review message

check-docs

/check-docs opens a pane with the markdown files written in this conversation, such as specs and plans, one tab per file, newest first. The selected file is drawn as formatted markdown. Run /check-docs again to close it.

A file counts when Claude, or one of its subagents, wrote or edited a .md file with the Write or Edit tool.

  • n goes to the next tab, p to the previous one; press a tab to pick it
  • The pane refreshes on its own after Claude writes, edits or runs commands; a new file gets a tab without moving you off the one you are reading
  • Tabs follow the conversation: a resume brings them back, /clear starts empty
  • A deleted file keeps its tab; a file too long for the pane (about 100 000 characters) is cut

skills

/skills:install copies skills from the plugin's skills/library/ folder. Claude asks which skills you want, then where: globally (~/.claude/skills/) or into this repo (.claude/skills/). If a skill is already there, it asks to overwrite or skip. Nothing is committed.

To add a skill to the library, put its folder (with SKILL.md) in skills/library/.

statusline

Nothing happens until you run /statusline:install. It sets up the cship statusline (Linux, x86_64 or aarch64):

  • copies cship-responsive.sh and cache-ttl.sh to ~/.claude/, and cship-full.toml and cship-compact.toml to ~/.config/. A different file of your own at one of those paths is first saved as <file>.statusline-backup.
  • downloads to ~/.local/bin, after asking: cship (latest release, only if none is on your PATH) and starship 1.26.0 (sha256-checked, only if missing or older)
  • writes the statusLine block to ~/.claude/settings.json, after backing it up to ~/.claude/settings.json.statusline-backup. If another statusLine is there, it asks first.

It needs jq, curl, tar and coreutils, and prints the install command if one is missing. The line's files live in statusline/files/. After changing them and updating the plugin, run /statusline:install again to copy the new versions.

/statusline:uninstall removes the statusLine block (or puts back the one it replaced), backing up settings.json to ~/.claude/settings.json.statusline-uninstall-backup. It puts back your own saved files and deletes the rest. cship and starship stay in ~/.local/bin.

bun test statusline

Develop

claude plugin validate ./review-diff
claude plugin test ./review-diff
claude --plugin-dir ./review-diff

claude plugin validate ./check-docs
claude plugin test ./check-docs
claude --plugin-dir ./check-docs
Source 4 files
hooks/register.js 126 lines
1// /check-docs: a pane with the markdown files this conversation wrote, one tab
2// each, newest first; the selected one drawn as markdown.
3
4import { atom, read, update } from 'claude-code'
5import { collectDocPaths, keepKnown, knownPaths, rememberPaths, stepPath, tabLabels } from './docs.js'
6import { drawPane } from './view.js'
7
8const PANE = 'check-docs'
9const STORE_KEY = 'recent'
10const REFRESH_DELAY_MS = 300 // lets a burst of edits settle into one refresh
11
12const selected = atom({ plugin: 'check-docs', key: 'selected' }, null)
13let paths = []
14let pendingRefresh = null
15let refreshRun = 0
16const contents = new Map() // each file's content as last read, until something may change it
17
18const shownPath = (picked) => (paths.includes(picked) ? picked : (paths[0] ?? null))
19
20// The store is a convenience: a full or failing one must never stop the pane.
21async function readStore($) {
22  try {
23    return await $.store.get(STORE_KEY)
24  } catch {
25    return undefined
26  }
27}
28
29async function writeStore($, value) {
30  try {
31    await $.store.set(STORE_KEY, value)
32  } catch {}
33}
34
35async function refresh($) {
36  const run = ++refreshRun
37  const conversation = await $.session.id()
38  const written = await collectDocPaths((agentId) => (agentId ? $.session.messages({ agentId }) : $.session.messages()))
39  const recent = await readStore($)
40  // A newer refresh, or another conversation, took over meanwhile: this one's files are stale.
41  if (run !== refreshRun || (await $.session.id()) !== conversation) return
42  paths = keepKnown(written, knownPaths(recent, conversation))
43  contents.clear()
44  await writeStore($, rememberPaths(recent, conversation, paths))
45  $.ui.invalidate('ui.render')
46}
47
48function refreshSoon($) {
49  pendingRefresh?.cancel()
50  pendingRefresh = $.clock.after(REFRESH_DELAY_MS, () => refresh($))
51}
52
53// A shell command cannot add a tab, but may change the file on screen.
54function redraw($) {
55  contents.clear()
56  $.ui.invalidate('ui.render')
57}
58
59const findPane = async ($) => (await $.ui.panes()).find((pane) => pane.id === PANE)
60
61async function readContent($, path) {
62  if (!(await $.fs.exists(path))) return { kind: 'deleted' }
63  try {
64    return { kind: 'text', text: await $.fs.read(path) }
65  } catch {
66    return { kind: 'unreadable' }
67  }
68}
69
70async function contentOf($, path) {
71  if (!contents.has(path)) contents.set(path, await readContent($, path))
72  return contents.get(path)
73}
74
75export function register(on) {
76  on('session.start', async ($, e, next) => {
77    await $.command.register({ name: 'check-docs', description: "Show this conversation's markdown files in a pane" })
78    if (await findPane($)) await refresh($)
79    return next(e)
80  })
81
82  // A /clear, resume or fork changes the conversation, and with it the tabs; a
83  // compaction keeps the conversation, and the pick.
84  on('classic.SessionStart', async ($, e, next) => {
85    if (e.source !== 'compact') await update($, selected, () => null)
86    if (await findPane($)) await refresh($)
87    return next(e)
88  })
89
90  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
91    const result = await next(e)
92    if (await findPane($)) refreshSoon($)
93    return result
94  })
95
96  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
97    const result = await next(e)
98    if (await findPane($)) redraw($)
99    return result
100  })
101
102  on('command.run', { command: 'check-docs' }, async ($) => {
103    if ((await findPane($))?.isShown) {
104      await $.ui.close({ id: PANE })
105      return {}
106    }
107    await refresh($)
108    await $.ui.open({ id: PANE, title: 'Check docs', focus: true, closeOnEscape: true })
109    return {}
110  })
111
112  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
113    if (e.requestId !== PANE) return next(e)
114    const elements = await $.ui.resolve(e)
115    const path = shownPath(await read($, selected))
116    return drawPane(elements, {
117      paths,
118      labels: tabLabels(paths),
119      selected: path,
120      content: path ? await contentOf($, path) : null,
121      onSelect: (picked) => update($, selected, () => picked),
122      onStep: (step) => update($, selected, (picked) => stepPath(paths, shownPath(picked), step)),
123    })
124  })
125}
126
hooks/docs.js 65 lines
1// The markdown files a conversation wrote, rebuilt from its own history, so a
2// resume, fork, /clear or switch shows that conversation's files.
3
4const WRITING_TOOLS = ['Write', 'Edit']
5const KEPT_CONVERSATIONS = 20 // bounds the store, which holds 4 MiB in all
6const isMarkdown = (path) => typeof path === 'string' && path.toLowerCase().endsWith('.md')
7// A call still in flight has no text yet: it may still be denied or fail.
8const isAnswered = (use) => use.text !== undefined && !use.isError
9const isDocWrite = (use) => WRITING_TOOLS.includes(use.tool) && isAnswered(use) && isMarkdown(use.input?.file_path)
10
11// Newest first: every path in the order it was last written, a subagent's writes
12// placed where that agent ran. readRows(agentId?) answers the main history, or that
13// agent's; anything but an array (a refusal) counts as no rows.
14export async function collectDocPaths(readRows) {
15  const written = []
16  // A forked agent's history holds the call that spawned it: each agent is read once.
17  const seen = new Set()
18  const rowsOf = async (agentId) => {
19    const answer = await readRows(agentId)
20    return Array.isArray(answer) ? answer : []
21  }
22  const readSpawned = async (uses) => {
23    const spawned = [...new Set(uses.map((use) => use.agentId).filter((id) => id && !seen.has(id)))]
24    spawned.forEach((id) => seen.add(id))
25    return new Map(await Promise.all(spawned.map(async (id) => [id, await rowsOf(id)])))
26  }
27  const walk = async (rows) => {
28    const uses = rows.flatMap((row) => row.toolUses ?? [])
29    const agentRows = await readSpawned(uses)
30    for (const use of uses) {
31      if (agentRows.has(use.agentId)) await walk(agentRows.get(use.agentId))
32      else if (isDocWrite(use)) written.push(use.input.file_path)
33    }
34  }
35  await walk(await rowsOf())
36  return [...new Set(written.reverse())]
37}
38
39// A compaction, or a history past the engine's row cap, drops early writes; the
40// files known before stay, behind those the history still shows.
41export const keepKnown = (written, known) => [...written, ...known.filter((path) => !written.includes(path))]
42
43// The store keeps the latest conversations' files as [{ conversation, paths }], newest first.
44const entriesOf = (recent) => (Array.isArray(recent) ? recent : [])
45export const knownPaths = (recent, conversation) => entriesOf(recent).find((entry) => entry.conversation === conversation)?.paths ?? []
46export const rememberPaths = (recent, conversation, paths) =>
47  [{ conversation, paths }, ...entriesOf(recent).filter((entry) => entry.conversation !== conversation)].slice(0, KEPT_CONVERSATIONS)
48
49const pathParts = (path) => path.split(/[\\/]/)
50const baseName = (path) => pathParts(path).at(-1)
51const withParent = (path) => pathParts(path).slice(-2).join('/')
52
53export function tabLabels(paths) {
54  const count = new Map()
55  for (const path of paths) count.set(baseName(path), (count.get(baseName(path)) ?? 0) + 1)
56  return new Map(paths.map((path) => [path, count.get(baseName(path)) > 1 ? withParent(path) : baseName(path)]))
57}
58
59export function stepPath(paths, current, step) {
60  if (paths.length === 0) return null
61  const at = paths.indexOf(current)
62  if (at === -1) return paths[0]
63  return paths[(at + step + paths.length) % paths.length]
64}
65
hooks/view.js 49 lines
1// Draws the pane: a row of tabs, the key hint, then the selected file as markdown.
2
3const PANE_TEXT_LIMIT = 100_000 // the most text a pane draws, tabs and path included
4const HINT_ROOM = 200 // the key hint, with room to spare
5const CUT_NOTE = '\n\n_File cut: too long to show in full._'
6
7const MESSAGES = {
8  deleted: 'This file was deleted.',
9  unreadable: 'This file could not be read.',
10}
11
12function readerText(content, room) {
13  if (content.kind !== 'text') return MESSAGES[content.kind]
14  if (content.text.length <= room) return content.text
15  return content.text.slice(0, Math.max(0, room - CUT_NOTE.length)) + CUT_NOTE
16}
17
18const readerRoom = (labels, selected) => PANE_TEXT_LIMIT - HINT_ROOM - selected.length - [...labels.values()].join('').length
19
20export function drawPane({ Box, Text, Button, Markdown }, { paths, labels, selected, content, onSelect, onStep }) {
21  const dim = (text) => Text({ dimColor: true, children: [text] })
22  if (paths.length === 0) return dim('No markdown files written in this conversation yet.')
23
24  const tab = (path) =>
25    Button({
26      key: 'tab-' + path,
27      label: labels.get(path),
28      ...(path === selected ? { variant: 'primary' } : { dimColor: true }),
29      onPress: () => onSelect(path),
30    })
31  return Box({
32    flexDirection: 'column',
33    rowGap: 1,
34    children: [
35      Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: paths.map(tab) }),
36      Box({
37        flexDirection: 'row',
38        columnGap: 2,
39        children: [
40          Button({ key: 'next', label: 'next', hotkey: 'n', plain: true, onPress: () => onStep(1) }),
41          Button({ key: 'previous', label: 'previous', hotkey: 'p', plain: true, onPress: () => onStep(-1) }),
42        ],
43      }),
44      dim(selected),
45      Markdown({ key: 'reader', text: readerText(content, readerRoom(labels, selected)) }),
46    ],
47  })
48}
49
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'check-docs': { selected: string | null }
4  }
5}
6