SLOPSHOPPER

instruction-workbench

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · instruction-workbench
│ ┃ Instruction Workbench ✕ › fix the failing auth test and add an audit log call │ ┃ INSTRUCTION WORKBENCH │ ┃ ⏺ Read(src/auth.ts) │ ┃ Selected files only · Review proposals · No ⎿ Read 6 lines │ ┃ model calls or file edits ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Instruction source: CLAUDE.md or .claude/rul ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 0/12 selected · Effective instruction │ ┃ loading is unverified ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ Verify sources and audit duplicates ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Report character offset: 5000 ⏎ Read page › /instruction-workbench │ ┃ │ ┃ [ Clear sources and report ] │ ┃ │ ┃ Select instruction files explicitly. No │ ┃ reads happen when opening this pane. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Instruction Workbench
INSTRUCTION WORKBENCH Selected files only · Review proposals · No model calls or file edits Instruction source: CLAUDE.md or .claude/rules/testing.md ⏎ 0/12 selected · Effective instruction loading is unverified [ Verify sources and audit duplicates ] Report character offset: 5000 ⏎ Read page [ Clear sources and report ] Select instruction files explicitly. No reads happen when opening this pane.
README

Instruction Workbench

<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.

Install

/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.

Commands

/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.

What the audit means

  • Duplicate groups match whole trimmed lines with whitespace collapsed, while retaining case, punctuation, and bullet structure. Candidate lines are 32–1,000 characters long.
  • Fenced code, recognized YAML frontmatter, comment-bearing lines, headings, tables, quotes, and short boilerplate are excluded. This conservative scanner is not a complete Markdown parser; indented code and complex markup may still produce candidates requiring review.
  • Findings include original file/line references. Same-file repetitions receive a consolidation review proposal. Cross-file repetitions explicitly require scope comparison: identical wording in different locations can be intentional.
  • Reports show up to 20 duplicate groups and 12 occurrences per group, with omitted counts. A small page may not contain all findings; pagination is explicit.
  • Scope labels describe directory location or the presence of a 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.

Alongside Claude's built-in audit

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.

Source 2 files
hooks/register.ts 169 lines
1import 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};
169
src/audit.ts 268 lines
1export 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