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

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.
.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.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.
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.
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.
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.
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
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.hooks/register.tsx 17 lines1import 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}
17hooks/grep-nudge.ts 75 lines1import 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}
75hooks/reindex.ts 37 lines1import 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}
37hooks/status.ts 36 lines1import 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}
36hooks/transcript.tsx 49 lines1import 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