From 4935a15cf13a9d3d08f8d0ab7182fe526ae8b8db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 06:04:45 +0000 Subject: [PATCH] Add a docs-lookup subagent for wide documentation lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A question that spans a drei helper, the R3F hook under it and the zustand store behind that pulls three index listings and several page bodies into whatever the session was actually working on. `docs-lookup` reads those in its own context and reports back. It is deliberately not the default path. One index plus one page is smaller than the round trip through another agent, and a summary of a signature is the failure mode this component exists to prevent — so the agent is told to quote verbatim and link, and SKILL.md keeps the lookup inline unless the reading is wide (3+ pages, or 2+ libraries). The agent carries no copy of the coverage table: it preloads the `docs` skill, which stays the single place recording which libraries the server serves and when that was last checked. Extends the test harness to agents, covering what fails silently rather than loudly: `tools` entries must use the scoped `mcp__plugin____` form derived from the manifests, and must name real built-in tools — a list resolving to nothing stops the agent launching. Preloaded skills must exist, since a missing one is skipped with only a debug-log warning. `hooks`, `mcpServers` and `permissionMode` must be absent, since plugin agents ignore them. And the coverage table must stay in one file. Each invariant was mutation-checked: breaking it fails the test that claims to guard it, and no other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TnvYiz7aGhnBTK2tzExHis --- README.md | 9 +++- agents/docs-lookup.md | 43 +++++++++++++++ skills/docs/SKILL.md | 15 ++++++ test/agents.test.mjs | 123 ++++++++++++++++++++++++++++++++++++++++++ test/skills.test.mjs | 8 ++- 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 agents/docs-lookup.md create mode 100644 test/agents.test.mjs diff --git a/README.md b/README.md index cfa299a..b30c9ce 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ Nothing to invoke. Ask normally: and Claude reads the docs index, fetches the page, and answers with a link. `/pmndrs:docs` runs the same lookup on demand. +A question that spans several pages — a drei helper, the R3F hook under it, and +the zustand store behind that — goes to the `pmndrs:docs-lookup` agent instead, +which reads the pages in its own context and reports back the signatures. One +page stays inline: the round trip costs more than the index it would save. + The docs server advertises eleven libraries but only serves four — react-three-fiber, drei, zustand, and the pmndrs/docs site itself. The other seven (a11y, react-postprocessing, uikit, xr, prai, viverse, leva) publish no @@ -45,6 +50,7 @@ Components, which is what grows: | | | |---|---| | `skills/docs/SKILL.md` | when to look things up, and how — index resource first, then `get_page_content` | +| `agents/docs-lookup.md` | the read-only subagent for wide lookups; quotes pages verbatim, cannot edit files | And the plumbing, which mostly doesn't: @@ -71,7 +77,8 @@ checks the things that fail silently at runtime rather than loudly at load: that a component addressing a bundled MCP server uses the scoped name it registers under once installed, that the plugin name still matches between the two manifests it is derived from, that no server is declared and then used by -nothing, and that this README lists every component shipped. +nothing, that an agent's `tools` entries resolve to real tools, and that this +README lists every component shipped. That last one is the rule to keep as components accumulate: a capability nobody can find is a capability nobody uses, so the README table is enforced rather diff --git a/agents/docs-lookup.md b/agents/docs-lookup.md new file mode 100644 index 0000000..58a771a --- /dev/null +++ b/agents/docs-lookup.md @@ -0,0 +1,43 @@ +--- +name: docs-lookup +description: Reads pages from docs.pmnd.rs and reports back what they actually say. Use for a wide lookup — a question spanning three or more pages, or two or more libraries — so the index listings and page bodies stay out of the main context. A single page is cheaper to read inline. +model: haiku +maxTurns: 12 +skills: + - docs +tools: ListMcpResourcesTool, ReadMcpResourceTool, mcp__plugin_pmndrs_docs__get_page_content +color: cyan +--- + +You look things up in the official pmndrs documentation and report what the +pages say. You do not write code, review it, or give an opinion on the caller's +approach — you are the reading half of someone else's task. + +The `docs` skill is preloaded above: it lists which libraries the server +actually serves and how to walk the index. Follow it. If it did not load, read +`docs:///index` first anyway — paths are not guessable and an invented one +just fails. + +The MCP server is bundled by this plugin, so it registers under the scoped name +`plugin:pmndrs:docs`. Pass that as the `server` argument to the resource tools; +`ListMcpResourcesTool` with no argument shows the live name if it ever differs. + +## What to return + +The caller will write code from your answer and will not see the pages you +read. Anything you compress, they compress too. + +- Quote signatures, prop names, type parameters and option keys **verbatim**. + Never paraphrase an API. A prop you rename in passing becomes a bug in their + file. +- Link every page you used: `https://docs.pmnd.rs/`. +- Keep the prose around the quotes short. Answer the question asked; skip the + tour of the rest of the page. +- If the docs do not cover it, say exactly that and stop. Do not fill the gap + from memory — the caller can do that themselves, and they need to know the + answer is not from the docs. +- If the index for a library comes back empty, report it as uncovered and point + at `https://github.com/pmndrs/`. Do not retry the page fetch. + +When several parts of the question are independent, read the pages for all of +them before answering, and group the answer part by part. diff --git a/skills/docs/SKILL.md b/skills/docs/SKILL.md index a0a96ea..80e087b 100644 --- a/skills/docs/SKILL.md +++ b/skills/docs/SKILL.md @@ -32,6 +32,21 @@ case-sensitive. Indexes are small (16–134 lines); reading one is cheap and usually enough to tell whether the docs cover the question at all. +## When to hand it off + +Do the lookup inline by default. One index plus one page is smaller than the +round trip through another agent, and quoting from a page you read yourself is +more faithful than quoting a summary of it. + +Hand off to the `docs-lookup` agent when the reading is wide: three or more +pages, or two or more libraries in one question — a drei helper plus the R3F +hook it wraps plus the zustand store behind them. There the index listings and +page bodies would crowd out the code you are actually working on, and the agent +reads them in its own context and reports back. + +Whatever it reports is a doc quote, not an answer: check it still addresses the +question, and keep its links. + ## What the server actually serves Four libraries, despite the eleven its tool schema advertises: diff --git a/test/agents.test.mjs b/test/agents.test.mjs new file mode 100644 index 0000000..82241d5 --- /dev/null +++ b/test/agents.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readJson, agents, skills, asList } from './helpers.mjs' + +const plugin = readJson('.claude-plugin', 'plugin.json') +const mcp = readJson('.mcp.json') + +// mcp__plugin____, with any character +// outside A-Za-z0-9_- replaced by _. +const scope = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_') +const serverKeys = Object.keys(mcp.mcpServers ?? {}) +const prefixes = serverKeys.map((key) => `mcp__plugin_${scope(plugin.name)}_${scope(key)}__`) + +// Built-in tools this plugin's agents are allowed to name. An unlisted entry is +// far more likely a typo than a tool we meant to grant: a tools list that +// resolves to nothing makes the agent fail to launch. +const BUILTIN = new Set([ + 'Agent', 'Bash', 'Edit', 'Glob', 'Grep', 'ListMcpResourcesTool', 'NotebookEdit', + 'Read', 'ReadMcpResourceTool', 'Skill', 'TodoWrite', 'WebFetch', 'WebSearch', 'Write', +]) + +const MODELS = new Set(['sonnet', 'opus', 'haiku', 'fable', 'inherit']) +const COLORS = new Set(['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']) + +// Ignored for plugin-shipped agents — present here they read as configuration +// that does something, and it does not. +const IGNORED_IN_PLUGINS = ['hooks', 'mcpServers', 'permissionMode'] + +const all = agents() + +test('the plugin ships at least one agent', () => { + assert.ok(all.length > 0) +}) + +for (const agent of all) { + const { fields, body, path } = agent + + test(`${path}: identity is well formed`, () => { + assert.match(fields.name, /^[a-z0-9][a-z0-9-]*$/) + assert.ok( + path.endsWith(`/${fields.name}.md`), + `file name should match the declared name (${fields.name})`, + ) + // The description is the whole basis on which Claude decides to delegate. + assert.ok(fields.description?.length > 60, 'description must say what it does and when to use it') + assert.ok(body.trim().length > 200, 'an agent needs a real system prompt') + }) + + test(`${path}: only uses front matter that plugin agents honour`, () => { + for (const field of IGNORED_IN_PLUGINS) { + assert.ok(!(field in fields), `${field} is ignored for plugin-shipped agents`) + } + if (fields.model) assert.ok(MODELS.has(fields.model) || fields.model.startsWith('claude-')) + if (fields.color) assert.ok(COLORS.has(fields.color)) + if (fields.isolation) assert.equal(fields.isolation, 'worktree') + if (fields.maxTurns) assert.match(String(fields.maxTurns), /^\d+$/) + }) + + test(`${path}: every tool name resolves`, () => { + const tools = asList(fields.tools) + assert.ok(tools.length > 0, 'an unrestricted docs agent would inherit Write and Edit') + + for (const tool of tools) { + if (!tool.startsWith('mcp__')) { + assert.ok(BUILTIN.has(tool.replace(/\(.*\)$/, '')), `unknown built-in tool: ${tool}`) + continue + } + // A bare server key never resolves for a plugin-bundled server, and the + // failure is silent until the agent runs with no docs access at all. + for (const key of serverKeys) { + assert.ok(!tool.startsWith(`mcp__${key}__`), `${tool} uses the bare server key, not the plugin scope`) + } + const prefix = prefixes.find((p) => tool.startsWith(p)) + assert.ok(prefix, `${tool} matches no bundled server; expected one of ${prefixes.join(', ')}`) + assert.ok(tool.length > prefix.length, `${tool} names no tool after the scope`) + } + }) + + test(`${path}: MCP tools it grants are ones the skills document`, () => { + const documented = skills().map((s) => s.body).join('\n') + for (const tool of asList(fields.tools)) { + const prefix = prefixes.find((p) => tool.startsWith(p)) + if (!prefix) continue + const short = tool.slice(prefix.length) + assert.ok(documented.includes(short), `${short} is granted but no SKILL.md explains it`) + } + }) + + test(`${path}: preloaded skills exist`, () => { + const available = new Map(skills().map((s) => [s.fields.name, s])) + for (const name of asList(fields.skills)) { + // A missing skill is skipped with a debug-log warning, so the agent would + // ship silently stripped of the protocol it is built around. + assert.ok(available.has(name.replace(/^.*:/, '')), `preloaded skill "${name}" does not exist`) + } + }) + + test(`${path}: does not restate what the skill owns`, () => { + // Which libraries the server actually serves lives in SKILL.md, with a + // checked-on date. A second copy here is a copy that goes stale unnoticed. + assert.doesNotMatch(body, /^\|.*\b(react-three-fiber|drei|zustand)\b/m, 'library table belongs in SKILL.md') + assert.doesNotMatch(body, /llms-full\.txt/, 'coverage caveats belong in SKILL.md') + }) + + test(`${path}: names the MCP server by its scoped form`, () => { + for (const key of serverKeys) { + const scoped = `plugin:${plugin.name}:${key}` + if (!body.includes(scoped)) continue + // If it explains the scoped name at all, it must not also hand out the + // bare key as if it were usable. + assert.doesNotMatch(body, new RegExp(`\`${key}\`\\s+(server|as the \`server\`)`)) + } + }) +} + +test('agent names do not collide with skill names', () => { + // Both surface under the same `pmndrs:` scoped identifier, one at `/` + // and one at `@`. Sharing a name makes the two indistinguishable in prose. + const skillNames = new Set(skills().map((s) => s.fields.name)) + for (const agent of all) { + assert.ok(!skillNames.has(agent.fields.name), `${agent.fields.name} is both an agent and a skill`) + } +}) diff --git a/test/skills.test.mjs b/test/skills.test.mjs index 35508a9..e4b3e60 100644 --- a/test/skills.test.mjs +++ b/test/skills.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { skills } from './helpers.mjs' +import { skills, agents } from './helpers.mjs' const all = skills() @@ -56,3 +56,9 @@ test('the docs skill states which libraries are served, and when that was checke assert.equal(date.getUTCDate(), Number(d), 'checked-on date is a real date') assert.ok(date.getTime() <= Date.now(), 'checked-on date is not in the future') }) + +test('the docs skill tells Claude when to delegate, and to an agent that exists', () => { + const names = agents().map((a) => a.fields.name) + const referenced = names.filter((name) => docs.body.includes(name)) + assert.ok(referenced.length > 0, `SKILL.md should route wide lookups to one of: ${names.join(', ')}`) +})