Review selected instruction sources and exact duplicate guidance locally, with source references and review-only proposals.

<img src="assets/icon.png" width="128" alt="Instruction Workbench: instruction cards on a drafting workbench" />
Experimental 0.1.0. A local review pane for explicitly selected instruction sources. Inspect source locations, character counts, fingerprints, and exact repeated guidance with line references. Nothing is edited or sent to a model automatically.
/plugin marketplace add GadDev/claude-mods
/plugin install instruction-workbench@gaddev-mods
/reload-plugins
/instruction-workbench
Use the pane to add a relative path, then choose Verify sources and audit duplicates. Opening or rendering the pane does not read source files. Native element contracts are tested on terminal, Desktop, and VS Code; see repository compatibility guidance for engine requirements. This is not a claude.ai browser integration.
/instruction-workbench add CLAUDE.md
/instruction-workbench add src/CLAUDE.md
/instruction-workbench add .claude/rules/testing.md
/instruction-workbench audit
/instruction-workbench page 5000
/instruction-workbench remove 2
/instruction-workbench clear
Paths may contain spaces. Select CLAUDE.md, CLAUDE.local.md, AGENTS.md, or Markdown files under a .claude/rules/ directory. All paths resolve inside the current working directory. Other hidden paths, traversal, binary content, outward symlinks, and duplicate physical sources are rejected. Files are limited to 64 KiB each, 12 selections, and 256 KiB total. Sources and reports live in memory and clear with the session.
audit and page re-read every selected source, verify its complete SHA-256 fingerprint and resolved location, and reject stale evidence, even if modification times are unchanged. Source changes, deletion, or workspace changes clear the selection. This is best-effort stat/read/stat verification, not an atomic filesystem snapshot. add and remove invalidate the old report. Pages contain at most 5,000 report characters plus an offset footer; use the next offset in the pane or command to continue.
paths frontmatter key. They do not evaluate glob patterns or prove that an instruction is loaded.There is no recursive discovery, import expansion, home/ancestor/managed-file access, settings inspection, semantic contradiction detection, tool registration, automatic prompt modification, file editing, or model call. The audit covers only sources the user selects. It cannot show the engine's complete instruction set. Use Claude's /context or /memory to inspect actual loading.
Claude already offers /doctor prompt-audit for a model-assisted review of outdated or conflicting instructions. Instruction Workbench provides deterministic, bounded, selected-file evidence in a local pane; it does not replace that audit.
Moving task-specific procedures into skills may reduce always-loaded guidance, but requires a separate human decision. Keep safety and project invariants where they are needed. Merely importing a separate instruction file organizes text without reducing what is loaded. This mod creates no skill files and applies no suggested changes. Source character counts are not loaded-context measurements or proven token savings.
hooks/register.ts 169 lines1import type { EngineInterface, Register } from 'claude-code';
2import { clean, Workbench } from '../src/audit.ts';
3
4const NAME = 'instruction-workbench';
5const HELP = '/instruction-workbench add <path> | remove <index> | audit | page [offset] | clear';
6interface State {
7 workbench: Workbench;
8 text: string;
9 message: string;
10 queue: Promise<void>;
11}
12const files = ($: EngineInterface) => ({
13 cwd: () => $.session.cwd(),
14 stat: (path: string) => $.fs.stat(path, { resolve: true }),
15 read: (path: string) => $.fs.read(path),
16});
17const schedule = <T>(state: State, work: () => Promise<T>): Promise<T> => {
18 const result = state.queue.then(work);
19 state.queue = result.then(
20 () => undefined,
21 () => undefined,
22 );
23 return result;
24};
25const act = ($: EngineInterface, state: State, kind: string, value = '') =>
26 schedule(state, async () => {
27 state.text = '';
28 try {
29 if (kind === 'add') {
30 await state.workbench.add(files($), value);
31 state.message =
32 'Source selected. Run audit to verify and compare selected instruction text.';
33 } else if (kind === 'remove') {
34 state.workbench.remove(Number(value));
35 state.message = 'Source removed. Run audit again.';
36 } else if (kind === 'clear') {
37 state.workbench.clear();
38 state.message = 'Sources and report cleared.';
39 } else if (kind === 'audit') {
40 if (value.trim()) throw new Error('audit takes no arguments.');
41 state.text = await state.workbench.audit(files($));
42 state.message = 'Selected sources reverified. Findings are review-only proposals.';
43 } else if (kind === 'page') {
44 state.text = await state.workbench.view(files($), Number(value || '0'));
45 state.message = 'Selected sources reverified before report retrieval.';
46 }
47 return { text: state.text || state.message };
48 } catch (error) {
49 state.message = error instanceof Error ? error.message : 'Instruction review failed.';
50 return { text: `Instruction Workbench: ${state.message}` };
51 } finally {
52 $.ui.invalidate('ui.render');
53 }
54 });
55
56export const register: Register = (on) => {
57 const state: State = {
58 workbench: new Workbench(),
59 text: '',
60 message: 'Select instruction files explicitly. No reads happen when opening this pane.',
61 queue: Promise.resolve(),
62 };
63 on('session.start', async ($, e, next) => {
64 await schedule(state, async () => {
65 state.workbench.clear();
66 state.text = '';
67 });
68 await $.command.register({
69 name: NAME,
70 description: 'Review selected instruction sources and exact duplicate guidance locally',
71 argumentHint: '[add|remove|audit|page|clear]',
72 });
73 return next(e);
74 });
75 on('session.end', async (_$, e, next) => {
76 await schedule(state, async () => {
77 state.workbench.clear();
78 state.text = '';
79 state.message = 'Sources cleared for this session.';
80 });
81 return next(e);
82 });
83 on('command.run', { command: NAME }, async ($, e) => {
84 if (!e.args.trim()) {
85 await $.ui.open({
86 id: NAME,
87 title: 'Instruction Workbench',
88 focus: true,
89 closeOnEscape: true,
90 });
91 return {};
92 }
93 const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(e.args.trim());
94 const kind = match?.[1] ?? '';
95 if (!['add', 'remove', 'audit', 'page', 'clear'].includes(kind)) return { text: HELP };
96 if (kind === 'clear' && match?.[2]?.trim()) return { text: HELP };
97 return act($, state, kind, match?.[2] ?? '');
98 }).catch(() => ({
99 text: 'Instruction Workbench could not complete the review. No edits were applied.',
100 }));
101 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
102 if (
103 e.requestId !== NAME ||
104 (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode')
105 )
106 return next(e);
107 const { Box, Text, Input, Button, Code } = $.ui.resolve(e);
108 const run = (kind: string, value = '') => {
109 void act($, state, kind, value);
110 };
111 return Box({
112 flexDirection: 'column',
113 gap: 1,
114 children: [
115 Text({ bold: true, children: ['INSTRUCTION WORKBENCH'] }),
116 Text({
117 children: ['Selected files only · Review proposals · No model calls or file edits'],
118 }),
119 Input({
120 key: 'instruction-path',
121 label: 'Instruction source',
122 placeholder: 'CLAUDE.md or .claude/rules/testing.md',
123 submitLabel: 'Add',
124 onSubmit: (value) => run('add', value),
125 }),
126 Text({
127 children: [
128 `${state.workbench.sources.length}/12 selected · Effective instruction loading is unverified`,
129 ],
130 }),
131 ...state.workbench.sources.map((source, index) =>
132 Box({
133 flexDirection: 'column',
134 gap: 1,
135 children: [
136 Text({
137 children: [
138 `${index + 1}. ${clean(source.path)} · ${source.text.length} characters`,
139 source.scope,
140 ],
141 }),
142 Button({
143 key: `remove-${index + 1}`,
144 label: `Remove source ${index + 1}`,
145 onPress: () => run('remove', String(index + 1)),
146 }),
147 ],
148 }),
149 ),
150 Button({
151 key: 'audit',
152 label: 'Verify sources and audit duplicates',
153 onPress: () => run('audit'),
154 }),
155 Input({
156 key: 'report-offset',
157 label: 'Report character offset',
158 placeholder: '5000',
159 submitLabel: 'Read page',
160 onSubmit: (value) => run('page', value),
161 }),
162 Button({ key: 'clear', label: 'Clear sources and report', onPress: () => run('clear') }),
163 Text({ children: [state.message] }),
164 ...(state.text ? [Code({ source: state.text, language: 'text', wrap: 'wrap' })] : []),
165 ],
166 });
167 });
168};
169src/audit.ts 268 lines1export const LIMITS = { sources: 12, fileBytes: 65_536, totalBytes: 262_144, page: 5000 } as const;
2export interface Stat {
3 kind: string;
4 size: number;
5 mtimeMs: number;
6 realPath?: string;
7}
8export interface Files {
9 cwd(): Promise<string>;
10 stat(path: string): Promise<Stat>;
11 read(path: string): Promise<string>;
12}
13export interface Source {
14 path: string;
15 root: string;
16 realPath: string;
17 text: string;
18 hash: string;
19 bytes: number;
20 scope: string;
21}
22export interface Occurrence {
23 path: string;
24 line: number;
25 scope: string;
26}
27export interface Duplicate {
28 text: string;
29 occurrences: Occurrence[];
30}
31
32// These labels describe file location, never the engine's effective instruction set.
33export function scope(path: string, text: string): string {
34 const rule = path.match(/^(.*?)(?:\.claude\/rules\/)/);
35 if (rule) {
36 const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1];
37 return `${rule[1] || './'} rules; ${frontmatter && /^paths\s*:/m.test(frontmatter) ? 'paths frontmatter present; matching unverified' : 'no recognized paths frontmatter; loading unverified'}`;
38 }
39 const directory = path.slice(0, path.lastIndexOf('/') + 1) || './';
40 return `${directory} directory guidance; loading unverified`;
41}
42
43export function validatePath(value: string): string {
44 const path = value.trim();
45 /* oxlint-disable no-control-regex -- Reject control characters in user-selected paths. */
46 if (!path || path.length > 300 || /[\\:\x00-\x1f\x7f]/.test(path) || path.startsWith('/'))
47 throw new Error(
48 'Use a relative instruction path with forward slashes (at most 300 characters).',
49 );
50 /* oxlint-enable no-control-regex */
51 const parts = path.split('/');
52 if (
53 parts.some(
54 (part) =>
55 !part || part === '.' || part === '..' || (part.startsWith('.') && part !== '.claude'),
56 )
57 )
58 throw new Error('Traversal and hidden directories other than .claude are excluded.');
59 const name = parts.at(-1);
60 if (
61 !['CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md'].includes(name ?? '') &&
62 !/(?:^|\/)\.claude\/rules\/.+\.md$/.test(path)
63 )
64 throw new Error('Select CLAUDE.md, CLAUDE.local.md, AGENTS.md, or a .claude/rules/*.md file.');
65 return path;
66}
67
68export function clean(value: string): string {
69 /* oxlint-disable no-control-regex -- Strip terminal controls only in presentation; source fingerprints retain original bytes. */
70 return value
71 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
72 .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
73 /* oxlint-enable no-control-regex */
74}
75
76export async function fingerprint(text: string): Promise<string> {
77 const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
78 return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, '0')).join('');
79}
80
81async function read(files: Files, path: string): Promise<Source> {
82 const root = await files.stat(await files.cwd());
83 if (root.kind !== 'dir' || !root.realPath)
84 throw new Error('Working directory cannot be resolved.');
85 const base = root.realPath.replace(/\\/g, '/').replace(/\/+$/, '');
86 const requested = `${base}/${path}`;
87 const before = await files.stat(requested);
88 const realPath = before.realPath;
89 if (!realPath || !realPath.replace(/\\/g, '/').startsWith(`${base}/`))
90 throw new Error('Instruction source resolves outside the working directory.');
91 if (before.kind !== 'file' || before.size < 0 || before.size > LIMITS.fileBytes)
92 throw new Error('Select a text file no larger than 64 KiB.');
93 const text = await files.read(realPath);
94 const bytes = new TextEncoder().encode(text).length;
95 if (bytes > LIMITS.fileBytes || text.includes('\0'))
96 throw new Error('Instruction source is oversized or contains binary data.');
97 const after = await files.stat(requested);
98 if (
99 after.kind !== 'file' ||
100 after.realPath !== realPath ||
101 before.size !== after.size ||
102 before.mtimeMs !== after.mtimeMs
103 )
104 throw new Error('Instruction source changed while being read.');
105 return {
106 path,
107 root: root.realPath,
108 realPath,
109 text,
110 bytes,
111 hash: await fingerprint(text),
112 scope: scope(path, text),
113 };
114}
115
116// Exact text with whitespace collapsed. Never infer semantic equivalence or resolve conflicts.
117export function duplicates(sources: readonly Source[]): Duplicate[] {
118 const groups = new Map<string, Occurrence[]>();
119 for (const source of sources) {
120 let fence: { character: string; length: number } | undefined;
121 let comment = false;
122 let frontmatter = false;
123 for (const [index, raw] of source.text.split(/\r?\n/).entries()) {
124 const line = raw.trim();
125 if (index === 0 && line === '---') {
126 frontmatter = true;
127 continue;
128 }
129 if (frontmatter) {
130 if (line === '---') frontmatter = false;
131 continue;
132 }
133 const marker = /^(\x60{3,}|~{3,})/.exec(line)?.[1];
134 if (fence) {
135 if (
136 marker &&
137 marker[0] === fence.character &&
138 marker.length >= fence.length &&
139 line.slice(marker.length).trim() === ''
140 )
141 fence = undefined;
142 continue;
143 }
144 if (marker) {
145 fence = { character: marker[0] ?? '', length: marker.length };
146 continue;
147 }
148 if (comment || line.includes('<!--')) {
149 comment = !line.includes('-->');
150 continue;
151 }
152 if (!line || /^(?:#{1,6}\s|>\s|\||@|[-*_]{3,}$)/.test(line)) continue;
153 const text = line.replace(/\s+/g, ' ');
154 if (text.length < 32 || text.length > 1000) continue;
155 const occurrences = groups.get(text) ?? [];
156 occurrences.push({ path: source.path, line: index + 1, scope: source.scope });
157 groups.set(text, occurrences);
158 }
159 }
160 return [...groups]
161 .filter(([, entries]) => entries.length > 1)
162 .map(([text, occurrences]) => ({ text, occurrences }));
163}
164
165export function report(sources: readonly Source[]): string {
166 const findings = duplicates(sources);
167 const lines = [
168 'INSTRUCTION WORKBENCH — REVIEW ONLY',
169 `${sources.length} selected source(s), ${sources.reduce((sum, source) => sum + source.text.length, 0)} source characters. Not loaded-context or token savings.`,
170 'Coverage is selected files only. Imports are not followed. Parent/user/managed files, settings, effective loading, and semantic contradictions are unverified.',
171 '',
172 'SOURCES',
173 ...sources.flatMap((source) => [
174 `${clean(source.path)} · ${source.scope}`,
175 `SHA-256 ${source.hash} · ${source.text.length} characters`,
176 ]),
177 '',
178 `${findings.length} EXACT DUPLICATE GROUP(S)`,
179 'Whitespace-only normalization; code fences, recognized frontmatter, comment lines, headings, tables, quotes, and short lines excluded.',
180 ];
181 for (const [index, finding] of findings.slice(0, 20).entries()) {
182 const sameFile = new Set(finding.occurrences.map((o) => o.path)).size === 1;
183 lines.push('', `${index + 1}. ${clean(finding.text)}`);
184 lines.push(...finding.occurrences.slice(0, 12).map((o) => ` ${clean(o.path)}:${o.line}`));
185 if (finding.occurrences.length > 12)
186 lines.push(` ${finding.occurrences.length - 12} more occurrence(s) omitted.`);
187 lines.push(
188 sameFile
189 ? 'Proposal: review repeated lines in this file and retain each contextually necessary occurrence. This is not an approved deletion.'
190 : 'Proposal: compare the cited scopes before consolidating. Identical wording in different scopes can be intentional; no deletion is presumed safe.',
191 );
192 }
193 if (findings.length > 20)
194 lines.push(`${findings.length - 20} more group(s) omitted from this report.`);
195 lines.push(
196 '',
197 'NEXT REVIEW',
198 'Consider an on-demand skill for genuinely task-specific procedures. Preserve always-needed safety and project invariants. Moving text into an imported file alone does not reduce loaded context.',
199 'No edits were applied. Re-audit after any source change. This does not replace Claude /doctor prompt-audit or /context.',
200 );
201 return lines.join('\n');
202}
203
204export function page(text: string, offset = 0): string {
205 if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length)
206 throw new Error('Use an integer offset within the report.');
207 return `${text.slice(offset, offset + LIMITS.page)}\n[characters ${offset}–${Math.min(text.length, offset + LIMITS.page)} of ${text.length}; next offset ${Math.min(text.length, offset + LIMITS.page)}]`;
208}
209
210export class Workbench {
211 sources: Source[] = [];
212 private result = '';
213 clear(): void {
214 this.sources = [];
215 this.result = '';
216 }
217 async add(files: Files, value: string): Promise<void> {
218 this.result = '';
219 const path = validatePath(value);
220 if (this.sources.length >= LIMITS.sources)
221 throw new Error('Select at most 12 instruction sources.');
222 const source = await read(files, path);
223 if (this.sources.some((s) => s.root !== source.root)) {
224 this.clear();
225 throw new Error('Working directory changed. Add sources again.');
226 }
227 if (this.sources.some((s) => s.realPath === source.realPath))
228 throw new Error('This resolved source is already selected.');
229 if (this.sources.reduce((sum, s) => sum + s.bytes, source.bytes) > LIMITS.totalBytes)
230 throw new Error('Selected sources exceed 256 KiB.');
231 this.sources.push(source);
232 }
233 remove(index: number): void {
234 if (!Number.isSafeInteger(index) || index < 1 || index > this.sources.length)
235 throw new Error('Use a selected source index starting at 1.');
236 this.sources.splice(index - 1, 1);
237 this.result = '';
238 }
239 private async verify(files: Files): Promise<void> {
240 if (!this.sources.length) throw new Error('Add an instruction source first.');
241 try {
242 for (const source of this.sources) {
243 const current = await read(files, source.path);
244 if (
245 current.root !== source.root ||
246 current.realPath !== source.realPath ||
247 current.hash !== source.hash
248 )
249 throw new Error('Instruction sources changed. Add them again for a new review.');
250 }
251 } catch (error) {
252 this.clear();
253 throw error;
254 }
255 }
256 async audit(files: Files): Promise<string> {
257 this.result = '';
258 await this.verify(files);
259 this.result = report(this.sources);
260 return page(this.result);
261 }
262 async view(files: Files, offset = 0): Promise<string> {
263 if (!this.result) throw new Error('Run audit before paging the report.');
264 await this.verify(files);
265 return page(this.result, offset);
266 }
267}
268