SLOPSHOPPER

repo-docs

Local semantic doc search, installed-package lookup and JS/TS file-impact tools for one repository. Shared by dev-core and orchestrate.

newrowsguardtoaststatusprocess
v0.9.9no licenseupdated 2026-10-08josippapez/ai-setup/claude/plugins/repo-docs
A shopper browsing a rack in a slop shop
README

repo-docs

Local semantic doc search, installed-package lookup and JS/TS file-impact tools for one repository. Split out of dev-core so dev-core and orchestrate share one MCP server and one tool namespace instead of each bundling an identical copy.

**Any plugin that uses its tools must declare repo-docs as a dependency in its own README/skill and tell the user to install it if mcp__plugin_repo-docs_repo-docs__* is not callable.** claude/install.sh installs it automatically alongside dev-core and orchestrate.

Layout

  • .mcp.json — the repo-docs server (runtime/): find_docs, list_docs, read_doc, find_libs, get_file_dependents, get_blast_radius. Markdown conventions, installed packages, and which JS/TS files import a file (directly or transitively, resolving tsconfig paths aliases and workspace package names).
  • hooks/ — dependency setup, index lifecycle.
  • commands/ — /reindex and /repo-docs-ignore.

Dependencies auto-install

opencode/plugins/dev-core runs a copy of runtime/. Only lib/platform.cjs (the .claude/.opencode folder and the model cache), lib/context.cjs and standalone-mcp.cjs differ; opencode/plugins/dev-core/repo-docs-sync.test.cjs fails when any other file drifts, so copy changed runtime files across.

No manual npm install. A SessionStart hook (hooks/hooks.json) runs npm install into the plugin's persistent data dir (${CLAUDE_PLUGIN_DATA}/node_modules) on first session and again whenever package.json changes; the MCP server resolves them via NODE_PATH. The first session may take a moment while @huggingface/transformers installs (the bge-small model is ~128 MB); later sessions are instant (deps persist across plugin updates). Embedding/reranker models are cached in a shared dir — ~/.claude/repo-docs-models by default, override with the REPO_DOCS_MODELS_DIR env var.

Docs index warms on connect

The MCP pre-embeds the repo's Markdown in the background when it connects (fire-and-forget, incremental via an mtime cache), so the first find_docs doesn't pay the indexing cost. find_docs runs a chunked hybrid search (BM25 keyword + dense bge-small embeddings) and returns, per file, the best-matching chunk with its section anchor and a snippet. Each chunk is embedded twice, on its own and with its doc path and heading breadcrumb in front, on the GPU (WebGPU, 16 texts a run) when onnxruntime can load it and on one CPU thread otherwise; the two rankings are fused, and a cross-encoder (bge-reranker-base) votes on the top 10. The vote adds about half a second per call; pass rerank: false, or set RERANK_ENABLED=0 for every call, to skip it. A query that finds the model not loaded yet waits up to 5 s for it (a load takes about half a second once the model is downloaded). If it is still not ready, or before the first index build, find_docs answers with a keyword scorer and its header says why and when to call it again. read_doc returns the raw file by default, so find_docs line numbers line up; compact: true returns a minified read. Force a rebuild of changed files any time with /reindex or node runtime/tools/build-semantic-index.cjs <repo-root> (delete .claude/repo-docs/ first for a full rebuild).

At the end of a turn that touched a Markdown file, the mod in hooks/reindex.ts runs hooks/reindex-on-edit.cjs, which asks the running server, over a local socket (.claude/repo-docs/inject.sock), to re-embed changed docs, so mid-session doc edits are searchable without a reconnect.

Removed in 0.3.0

get_file_dependents and get_blast_radius came back in 0.5.0 after CodeGraph replaced them: on a measured impact task in a TypeScript monorepo, CodeGraph listed 9 of 16 affected files and get_blast_radius listed all 16. Use them for the file list before a move, rename, delete or API change. CodeGraph itself was dropped from the plugin in 0.6.0. The proactive doc-pointer injection (UserPromptSubmit and PostToolBatch hooks) and the one-shot Grep/Glob reminder were removed after measuring 1,879 injections across 39 sessions with zero read_doc follow-ups.

Reap on exit

A SessionEnd hook (hooks/reap-mcp-on-exit.cjs) kills this session's own standalone-mcp.cjs process on exit — Claude Code doesn't always reap plugin MCP servers, so they'd otherwise accumulate across sessions.

Tests

node --test claude/plugins/repo-docs/hooks/*.test.cjs claude/plugins/repo-docs/runtime/lib/*.test.cjs claude/plugins/repo-docs/runtime/tools/*.test.cjs

Mod

hooks/status.ts shows "repo-docs: indexing docs…" with the build percentage while .claude/repo-docs/index-build.lock exists, and a toast when the build finishes.

  • hooks/transcript.tsx draws the /repo-docs:reindex Bash call as "Reindex repo docs" and its result as a one-line count of re-embedded, unchanged and skipped docs.
  • hooks/grep-nudge.ts adds a reminder to try find_docs after a Grep or Bash rg/grep aimed at docs/ or Markdown, at most twice a session and never after find_docs has run. It skips .orchestration, .claude/ and node_modules, which find_docs does not index.
  • hooks/reindex.ts replaces the old PostToolUse reindex hook: when a turn ends, if it touched a markdown file through Edit, Write or a Bash command naming one, it asks the running server to re-embed changed docs once.
Source 5 files
hooks/register.tsx 17 lines
1import type { Register } from 'claude-code'
2
3import { registerGrepNudge } from './grep-nudge'
4import { registerReindex } from './reindex'
5import { registerStatus } from './status'
6import { registerTranscript } from './transcript'
7
8export const register: Register = on => {
9  registerGrepNudge(on)
10  registerReindex(on)
11  registerStatus(on)
12  registerTranscript(on)
13  // find_docs was left behind ToolSearch and the model grepped docs instead; listing it up
14  // front lets it be called straight away.
15  on('tool.describe', { tool: 'mcp__plugin_repo-docs_repo-docs__find_docs' }, ($, e) => ({ description: e.description, isDeferred: false }))
16}
17
hooks/grep-nudge.ts 75 lines
1import type { On } from 'claude-code'
2
3// Since find_docs was pinned (2026-10-02), real sessions still ran 113 docs greps to 18
4// find_docs calls, mostly on task prompts mid-session. This reminds the model at the grep.
5const FIND_DOCS = 'mcp__plugin_repo-docs_repo-docs__find_docs'
6const MAX_NUDGES = 2
7
8const SEARCHER = /(^|[\s|;&(])(rg|grep|ag)\s/
9const DOCS_TARGET = /(^|[\s'"=/])docs(\/|[\s'"]|$)|\.mdx?\b|--type[= ]md\b|\s-t\s?md\b|README/i
10// find_docs does not index these, so grepping them is the right call.
11const UNINDEXED = /\.orchestration|\.claude\/|node_modules/
12
13export const NUDGE =
14  'repo-docs: that searched the docs with a text pattern. find_docs searches every doc by meaning, so it also finds docs that describe the topic in other words. Run find_docs with a plain description of the task before more doc greps.'
15
16// Only a topic search gets the reminder: plain words like "shopify cli" or "digest|drift". An
17// exact string (`MODEL_ID =`, `offsets.json`, a hex colour, a URL) is a lookup grep does well:
18// on 30 real docs greps followed by opening a doc, find_docs ranked that doc top 3 only 4 times.
19export const isTopicPattern = (pattern: string) => {
20  const words = pattern.split(/\\?\||\s+/).filter(Boolean)
21  return words.length > 0 && words.every(w => /^[A-Za-z][a-z]+(-[a-z]+)*$/.test(w)) && (words.length > 1 || words[0].length >= 4)
22}
23
24// The first argument after the search command that is not a flag, unquoted.
25const patternOf = (segment: string) => {
26  const args = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
27  const at = args.findIndex(a => /^(rg|grep|ag)$/.test(a))
28  for (let i = at + 1; i > 0 && i < args.length; i++) {
29    if (args[i] === '-e') return args[i + 1]?.replace(/^(["'])(.*)\1$/, '$2') ?? ''
30    if (!args[i].startsWith('-')) return args[i].replace(/^(["'])(.*)\1$/, '$2')
31  }
32  return ''
33}
34
35// The docs path has to follow the search command within one pipeline segment, so prose that
36// mentions "rg" and "docs" (a heredoc, a prompt string) does not count. A `|` inside quotes is
37// a pattern alternation, not a pipe.
38export const isBashDocsGrep = (command: string) =>
39  (command.match(/(?:"[^"]*"|'[^']*'|[^|;&\n"'])+/g) ?? []).some(part => {
40    const at = part.search(SEARCHER)
41    return at >= 0 && DOCS_TARGET.test(part.slice(at)) && isTopicPattern(patternOf(part.slice(at)))
42  }) && !UNINDEXED.test(command)
43
44export const isGrepToolDocsSearch = (input: { pattern?: string; path?: string; glob?: string; type?: string }) => {
45  const scopes = [input.path ?? '', input.glob ?? '']
46  const isDocs = scopes.some(s => /(^|\/)docs(\/|$)|\.mdx?\b|README/i.test(s)) || input.type === 'md' || input.type === 'markdown'
47  return isDocs && isTopicPattern(input.pattern ?? '') && !scopes.some(s => UNINDEXED.test(s))
48}
49
50export function registerGrepNudge(on: On) {
51  let hasCalledFindDocs = false
52  let nudges = 0
53
54  const nudge = <R extends { deny?: string; context?: readonly string[] }>(ran: R): R => {
55    if (hasCalledFindDocs || nudges >= MAX_NUDGES || ran.deny !== undefined) return ran
56    nudges++
57    return { ...ran, context: [...(ran.context ?? []), NUDGE] }
58  }
59
60  on('tool.call', { tool: FIND_DOCS }, ($, e, next) => {
61    hasCalledFindDocs = true
62    return next(e)
63  })
64
65  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66    const ran = await next(e)
67    return isBashDocsGrep(e.command) ? nudge(ran) : ran
68  })
69
70  on('tool.call', { tool: 'Grep' }, async ($, e, next) => {
71    const ran = await next(e)
72    return isGrepToolDocsSearch(e) ? nudge(ran) : ran
73  })
74}
75
hooks/reindex.ts 37 lines
1import type { On } from 'claude-code'
2
3// Ask the running repo-docs server to re-embed changed docs once a turn ends, if the turn
4// touched a markdown file. The old PostToolUse hook only saw Edit and Write, but most doc
5// edits go through Bash (sed, heredocs, scripts), so they were never reindexed. A rebuild
6// parses the whole index, so it runs once per turn, not once per edit.
7
8const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
9const DOC = /\.mdx?\b/i
10
11let isDirty = false
12
13export function registerReindex(on: On) {
14  on('tool.call', async ($, e, next) => {
15    const ran = await next(e)
16    const input = e as unknown as Record<string, unknown>
17    const subject = String(input.file_path ?? input.notebook_path ?? (e.tool === 'Bash' ? input.command : '') ?? '')
18    if ((EDIT_TOOLS.includes(e.tool) || e.tool === 'Bash') && DOC.test(subject)) isDirty = true
19
20    return ran
21  })
22
23  on('turn.complete', async ($, e, next) => {
24    if (e.agentId === undefined && isDirty) {
25      isDirty = false
26      const cwd = await $.session.cwd()
27      // Started off the turn's own dispatch so the turn does not wait on the rebuild.
28      // The script handles the socket, its 2 s debounce lock, and a missing server.
29      $.clock.after(0, () => {
30        void $.process.run(['node', `${$.plugin.root}/hooks/reindex-on-edit.cjs`, '--now', cwd])
31      })
32    }
33
34    return next(e)
35  })
36}
37
hooks/status.ts 36 lines
1import type { EngineInterface, On } from 'claude-code'
2
3// The MCP server holds this lock for the whole index build and removes it when done,
4// and keeps "<done> <total>" in the progress file while it runs.
5const LOCK = '.claude/repo-docs/index-build.lock'
6const PROGRESS = '.claude/repo-docs/index-build.progress'
7const POLL_MS = 3000
8
9async function poll($: EngineInterface, wasIndexing: boolean) {
10  const isIndexing = await $.fs.exists(LOCK)
11  if (isIndexing) {
12    const [done, total] = ((await $.fs.exists(PROGRESS)) ? await $.fs.read(PROGRESS) : '').split(' ').map(Number)
13    // The engine prefixes the plugin name, so the text starts at the verb.
14    $.ui.status(total ? `indexing docs ${Math.floor(((done ?? 0) * 100) / total)}% (${done}/${total})` : 'indexing docs…')
15  }
16  if (!isIndexing && wasIndexing) {
17    $.ui.status(undefined)
18    $.ui.toast('repo-docs: doc index updated')
19  }
20
21  return isIndexing
22}
23
24export function registerStatus(on: On) {
25  on('session.start', async ($, e, next) => {
26    let isIndexing = await poll($, false)
27    $.clock.every(POLL_MS, () => {
28      void poll($, isIndexing).then(now => {
29        isIndexing = now
30      })
31    })
32
33    return next(e)
34  })
35}
36
hooks/transcript.tsx 49 lines
1import type { On } from 'claude-code'
2
3// The /repo-docs:reindex command runs the builder through Bash and prints one
4// "repo_docs_index updated=N unchanged=N skipped=N cache=PATH" line.
5const BUILDER = 'build-semantic-index.cjs'
6const SUMMARY = /repo_docs_index updated=(\d+) unchanged=(\d+) skipped=(\d+) cache=(\S+)(.*)/
7
8const isReindex = (input: unknown) =>
9  typeof input === 'object' && input !== null && 'command' in input && String(input.command).includes(BUILDER)
10
11export function registerTranscript(on: On) {
12  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
13    if (e.props.tool !== 'Bash' || !isReindex(e.props.input)) return next(e)
14    const { Box, Text } = $.ui.resolve(e)
15
16    return (
17      <Box marginX={1} gap={1}>
18        <Text color={e.props.isErrored ? 'red' : e.props.isRunning ? 'gray' : 'green'}>●</Text>
19        <Text bold>Reindex repo docs</Text>
20        {e.props.isRunning && <Text dimColor>embedding changed docs…</Text>}
21      </Box>
22    )
23  })
24
25  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
26    if (e.props.tool !== 'Bash' || e.props.isErrored) return next(e)
27    const stdout = typeof e.props.output === 'object' && e.props.output !== null && 'stdout' in e.props.output ? String(e.props.output.stdout) : ''
28    const match = stdout.match(SUMMARY)
29    if (!match) return next(e)
30    const [, updated, unchanged, skipped, cache, note] = match
31    const { Box, Text } = $.ui.resolve(e)
32    const folder = (cache ?? '').replace(/\/\.claude\/repo-docs\/[^/]+$/, '').split('/').pop()
33
34    return (
35      <Box marginX={1} flexDirection="column" paddingLeft={2}>
36        <Box gap={2}>
37          <Text color="green">✓ {folder}</Text>
38          <Text bold color={Number(updated) > 0 ? 'cyan' : undefined}>
39            {updated} re-embedded
40          </Text>
41          <Text dimColor>{unchanged} unchanged</Text>
42          {Number(skipped) > 0 && <Text color="yellow">{skipped} skipped</Text>}
43        </Box>
44        {note?.trim() && <Text color="yellow">{note.trim()}</Text>}
45      </Box>
46    )
47  })
48}
49