Records what Claude Code composes for the model, without changing it (measurement tool for RFC-0001)

Claude Code showed my writing repo 101 skills. 94 didn't belong. This mod hides them with one line.
Quick start · How it works · Profiles · Limitations
<img src="assets/overview.svg" width="760" alt="You keep named profiles, such as writing, once in ~/.claude. A repo picks one with a one-line JSON file, and Claude then sees only the skills, agents, rules and tools that profile allows. The repo's own skills, agents and CLAUDE.md always stay.">
harness-scope is a Claude Code mod (a plugin whose code runs inside Claude Code, added in 2.1.287 as Claude Mods) that turns your global skills, agents, instruction files (CLAUDE.md and rules) and tools on or off per repo. You keep named profiles once in ~/.claude. A repo picks one with a one-line file, and Claude sees only what that profile allows. The repo's own skills, agents and CLAUDE.md always stay.
It is for Claude Code users whose harness (the CLAUDE.md, rules, skills and agents under ~/.claude, plus their plugins) follows them into repos where most of it does not belong. I built it to keep my coding setup out of my writing repo.
Measured on 2026-10-03 in my writing repo with the bundled writing profile (Claude Code 2.1.287, claude -p):
| Without harness-scope | With writing | |
|---|---|---|
| Skill listing | 101 skills, 26,551 chars | the repo's own 7, 1,623 chars |
| Agent listing | 38 types, 18,988 chars | the repo's own 7 + Explore, general-purpose; 4,134 chars |
Calling an off skill (tdd) | loads | refused, with the reason |
Your numbers depend on how many skills, plugins and MCP servers you have. To check yours, run /context in a repo and look at the skills it lists: if most of them have nothing to do with that repo, this is for you. If you have only a handful, you probably do not need it.
You need Claude Code 2.1.287 or later, where mods are on by default. No account or API key.
claude plugin install harness-scope --marketplace shimo4228/harness-scope
In a session, /plugin then names it on the line under the tabs, for example 1 mod active · harness-scope.
.claude/harness-scope.json. The writing profile ships with the mod; to write your own, see Profiles. { "profile": "writing" }
/clear. This line on screen means the profile is on: harness-scope: profile "writing" (bundled) on, selected by .claude/harness-scope.json — /harness-scope for details
While it is on, the status line under the prompt reads ⚠ harness-scope: profile "writing" on. Claude Code draws the ⚠ in front of a mod's status line; here it is not a warning. If that status line is missing, or says it is passing everything through, nothing is turned off. A mod that did not load cannot print a warning, so the missing status line is the signal.
After the first message, run /harness-scope to see what the profile turned off. From a test repo on Claude Code 2.1.294, with the name lists trimmed:
harness-scope: profile "writing" (bundled), selected by .claude/harness-scope.json
skills (allow): 91 off — adr-writer, archify, authorship-strategy, …
agents (allow): 30 off — adr-reviewer, architect, claude, …
agents kept: Explore, general-purpose
instructions: not in the profile
tools (deny): 4 off — EnterWorktree, ExitWorktree, LSP, NotebookEdit
To turn it off: delete .claude/harness-scope.json, then /clear. harness-scope writes no files.
It also lists patterns in your profile that matched nothing, and the repo's own skills it kept. The list stays on your screen and out of the conversation; only under claude -p, which has no screen, does it come back as the command's output.
To undo it in one repo, delete .claude/harness-scope.json; everywhere, run claude plugin uninstall harness-scope@harness-scope. The mod writes no files, so there is nothing else to clean up. Repos without the file are never changed.
Before each request, Claude Code assembles what Claude will see: your instruction files, the skill listing, the agent types it may call and the tool list. As a mod, harness-scope hooks into that step and removes whatever the profile turns off. It adds no instructions of its own. The only thing it blocks is a call to a skill or tool it removed, and the refusal tells Claude which profile turned it off. If the profile cannot be read, it changes nothing and says so on screen. Each hook and what it changes is listed in plugin/README.md.
A profile is a JSON file at ~/.claude/harness-scope/profiles/<name>.json. Each category takes either allow (keep only these) or deny (turn off only these). Names and paths accept * and ? globs. A category left out stays as it is. To see which names you can use, run /harness-scope names after one message: it lists the skills, agents and tools offered in that conversation, spelled the way a profile matches them, in any repo.
{
"skills": { "allow": ["writing-ecosystem", "prose-translation", "anthropic-skills:docx"] },
"agents": { "allow": ["Explore", "general-purpose"] },
"instructions": { "deny": ["~/.claude/rules/common/testing.md"] },
"tools": { "deny": ["LSP", "NotebookEdit", "mcp__claude_ai_Slack__*"] }
}
skills and agents match names as they appear in Claude's listings, whatever their source: your own, a plugin's (plugin:skill), built-in, or synced from claude.ai.instructions matches paths of your own instruction files, and ~/ works. A file pulled in with @ goes with the file that imported it.tools matches tool names, MCP tools included.Three starting points:
writing. It also keeps the Explore and general-purpose agents and turns off LSP, NotebookEdit, EnterWorktree and ExitWorktree.{ "skills": { "allow": ["prose-translation", "anthropic-skills:docx"] } }{ "instructions": { "deny": ["~/.claude/rules/common/testing.md"] }, "tools": { "deny": ["mcp__github__*"] } }A file in ~/.claude/harness-scope/profiles/ with the same name as a bundled profile takes precedence. If you switch configurations with CLAUDE_CONFIG_DIR, profiles are read from that directory instead (<dir>/harness-scope/profiles/). The mod does not read the variable; it finds the directory from where it is installed. The repo file is looked up in the session's root directory first, then in the git repo's root.
Claude Code shows every repo the same global harness. In my writing repo, the skill listing held 101 skills: the repo's own 7, and 94 from my global setup, plugins, built-ins and claude.ai, tdd among them.
The native settings can turn these off at project scope (checked on Claude Code 2.1.287 and 2.1.294). Each one is a denylist that names what to hide, written into each repo's .claude/settings.json:
{
"skillOverrides": { "tdd": "off", "adr-writer": "off", "…": "off" },
"enabledPlugins": { "codex@openai-codex": false, "…": false },
"permissions": { "deny": ["Agent(Plan)", "LSP", "…"] }
}
With harness-scope, each repo holds { "profile": "writing" } and the list lives once in the profile. What the native route lacks:
~/.claude later appears in every repo until you deny it there too.skillOverrides turns off your own, built-in and claude.ai-synced skills one by one, but a plugin's skills go on or off only with the whole plugin, its agents included.A harness-scope profile covers skills, agents, instruction files and tools together, so there is one place to look when something is off.
Turning off a few skills hardly shrinks the listing, because Claude Code fills it up to a budget and shortens descriptions to fit: on 2.1.287, turning off 3 skills took it only from 26,551 to 26,534 characters. An allowlist that turns off most of them shrinks it.
Other tools that narrow what Claude sees (as of 2026-10-03):
Whatever a profile says, harness-scope leaves these alone:
.claude/harness-scope.json. A broken file or an unknown profile name changes nothing, and a line on screen says why.The repo file can only name a profile. A cloned repo cannot define its own profile and use it to turn off your rules; it can only pick one of yours, and the line on screen tells you when it does.
A mod can do anything you can, so check what this one asks for before you install it: claude plugin validate on a clone's plugin/ folder prints a calls: line, and for harness-scope it lists only reads ($.fs.exists, $.fs.read, $.session.*), the on-screen line and status line ($.ui.log, $.ui.status) and the /harness-scope command. It reads the profile, the repo file, and the list of skills Claude Code has loaded with where each came from (to tell the repo's own skills apart). It reads no environment variables: it finds ~/.claude from where it is installed. It writes no files, makes no network requests, starts no processes and calls no model.
paths:, CLAUDE.md files in subdirectories) are not turned off.permissions.deny entry removes it completely, so copy the profile's tools.deny entries there when that matters./clear.The proposal and prior art, the design, and the measurements behind the numbers above (2026-10-03 on 2.1.287, 2026-10-08 on 2.1.294) are in Japanese.
To try the mod from a clone, start Claude Code with claude --plugin-dir <path to the clone>/plugin. Loaded that way, it cannot tell where ~/.claude is, so only the bundled profiles work until you set the configDir field with claude plugin configure harness-scope.
To work on it: npm ci, then .claude/verify.sh (Biome, TypeScript, claude plugin validate --strict, claude plugin test, npm audit).
CLAUDE_CONFIG_DIR moves skills and agents but leaves CLAUDE.md and rules behind; claudeMdExcludes covers the rest.harness-scope is a Claude Code mod (a plugin with a hooks module, the extension type added in Claude Code 2.1.287 and listed in its changelog as Claude Mods), for Claude Code users with a large global setup, that turns global skills, agents, instruction files (CLAUDE.md and rules) and tools on or off per repository through named profiles.
Why it exists. Claude Code shows every repository the same global harness (~/.claude plus installed plugins). The native project settings (skillOverrides, enabledPlugins, claudeMdExcludes, permissions.deny) are per-repo denylists: there is no allowlist, a plugin's skills switch only with the whole plugin (own, built-in and claude.ai-synced skills can be turned off one by one with skillOverrides, checked on 2.1.294), and nothing is shared across repos. harness-scope adds allowlists and denylists in shared, named profiles, chosen per repo by a one-line file.
Facts.
plugin/hooks/ (register.ts, profile.ts, listing.ts, instructions.ts, bundled.ts).claude plugin install harness-scope --marketplace shimo4228/harness-scope (or claude plugin marketplace add shimo4228/harness-scope, then claude plugin install harness-scope@harness-scope)..claude/harness-scope.json in the repository, containing only { "profile": "<name>" }. Looked up in the session root, then the git root.~/.claude/harness-scope/profiles/<name>.json, with optional skills, agents, instructions and tools, each { "allow": [...] } or { "deny": [...] } with * / ? globs. Bundled profile: writing (only the repo's own skills; agents Explore and general-purpose; tools LSP, NotebookEdit, EnterWorktree, ExitWorktree off).prompt.context removes the user's own instruction files; prompt.attachment filters the skill listing and the deferred tool list; agent.offer withholds agent types; tool.describe defers tools; tool.call refuses turned-off tools and skills. /harness-scope reports what was turned off; /harness-scope names lists the names offered; a status line shows whether a profile is on.Example. Measured 2026-10-03 on Claude Code 2.1.287 in the author's writing repository with the writing profile: the skill listing went from 101 skills (26,551 characters) to the repository's own 7 (1,623 characters); the agent listing from 38 types (18,988 characters) to 9 (4,134 characters); a Skill call to the turned-off tdd was refused with the reason.
Links. Proposal and prior art: rfcs/0001-prose-mod.md. Design: docs/plans/rfc-0001-r2-profile-allowlist.md. Measurements: docs/measurements/2026-10-03-phase0.md (2.1.287) and docs/measurements/2026-10-08-readme-checks.md (2.1.294: 20 of 20 loads, skillOverrides on built-in and synced skills). Plugin reference (hooks and data): plugin/README.md.
hooks/register.ts 122 lines1// Records what Claude Code composes for the model, unchanged (RFC-0001 Phase 0).
2// Every hook reads the result of next(e): prompt.compose's input carries no sections.
3// Output: JSON lines at $PROSE_PROBE_OUT, rewritten after each event; nothing is written when it is unset.
4import type { EngineInterface, On } from 'claude-code'
5
6type Row = Record<string, unknown>
7
8const rows: Row[] = []
9let outPath: string | undefined
10let outResolved = false
11let usageRecorded = false
12
13// Kinds whose full text the fixtures need; other attachments keep only their size.
14const FULL_TEXT = new Set([
15 'skill_listing',
16 'instructions',
17 'deferred_tools_delta',
18 'agent_listing_delta',
19 'nested_memory',
20])
21
22async function flush($: EngineInterface, row: Row): Promise<void> {
23 rows.push({ at: rows.length, ...row })
24 if (!outResolved) {
25 outPath = await $.env.get('PROSE_PROBE_OUT')
26 outResolved = true
27 }
28 if (outPath === undefined) return
29 await $.fs.write(outPath, `${rows.map((r) => JSON.stringify(r)).join('\n')}\n`)
30}
31
32export function register(on: On): void {
33 on('classic.SessionStart', async ($, e, next) => {
34 await flush($, { kind: 'session_start', source: e.source })
35 return next(e)
36 })
37
38 on('prompt.compose', async ($, e, next) => {
39 const r = await next(e)
40 await flush($, {
41 kind: 'compose',
42 model: e.model,
43 traits: e.traits,
44 outputStyle: e.outputStyle,
45 sections: r.sections.map((s) => ({ id: s.id, scope: s.scope, chars: s.text.length, text: s.text })),
46 })
47 return r
48 })
49
50 on('prompt.section', async ($, e, next) => {
51 const r = await next(e)
52 await flush($, { kind: 'section', id: e.name, chars: (r.text ?? '').length })
53 return r
54 })
55
56 on('prompt.context', async ($, e, next) => {
57 const r = await next(e)
58 await flush($, {
59 kind: 'context',
60 blocks: r.blocks.map((b) => ({ name: b.name, chars: b.text.length })),
61 instructionFiles:
62 r.instructionFiles?.map((f) => ({ path: f.path, kind: f.kind, parent: f.parent, chars: f.content.length })) ??
63 null,
64 })
65 return r
66 })
67
68 on('prompt.attachment', async ($, e, next) => {
69 const r = await next(e)
70 const text = r.text ?? ''
71 await flush($, {
72 kind: 'attachment',
73 type: e.type,
74 origin: e.origin.kind,
75 agentId: e.agentId,
76 chars: text.length,
77 text: FULL_TEXT.has(e.type) ? text : undefined,
78 })
79 return r
80 })
81
82 on('tool.describe', async ($, e, next) => {
83 const r = await next(e)
84 await flush($, {
85 kind: 'tool',
86 id: e.tool,
87 provider: e.provider,
88 isDeferredIn: e.isDeferred ?? false,
89 isDeferredOut: r.isDeferred,
90 chars: r.description.length,
91 })
92 return r
93 })
94
95 on('agent.offer', async ($, e, next) => {
96 const r = await next(e)
97 await flush($, {
98 kind: 'agent',
99 id: e.agent,
100 source: e.source,
101 provider: e.provider,
102 chars: e.description.length,
103 isOffered: r.isOffered,
104 })
105 return r
106 })
107
108 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
109 await flush($, { kind: 'skill_call', input: { ...e } })
110 return next(e)
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 if (!usageRecorded && e.agentId === undefined) {
115 usageRecorded = true
116 const usage = await $.session.usage({ breakdown: 'summary' })
117 await flush($, { kind: 'usage', context: usage.context })
118 }
119 return next(e)
120 })
121}
122