SLOPSHOPPER

work-folder

Denies a new agent working file (plan, evidence, prototype, scratch, report, probe test) outside .claude/work/, which is gitignored, with a per-repo allow list

newguard
v0.1.0no licenseupdated 2026-10-13cpeaustriajc/cj-stack/mods/work-folder
A shopper browsing a rack in a slop shop
README

work-folder

A mod (a plugin that adds a hook to Claude Code) that denies a new agent working file outside .claude/work/. Plans, evidence, prototypes and reports then stay out of your repo.

Why

Agents write plans, scratch notes, reports and probe tests into the repo as they work. These files end up in commits.

work-folder denies the write and tells Claude to use .claude/work/<topic>/ instead. That folder is gitignored.

Install

/plugin marketplace add cpeaustriajc/cj-stack
/plugin install work-folder@cj-stack

To see that it is on, run claude plugin list and look for work-folder. The mod is optional: cj-stack does not install it.

Example

  1. Claude tries to create reports/x.md with the Write tool.
  2. The hook finds that the file does not exist and that reports/ is a planning folder.
  3. The hook denies the write. Claude reads: "work-folder: reports/x.md looks like an agent working file (a reports/ or docs/ planning folder). Write it under .claude/work/<topic>/ instead; that folder is gitignored."
  4. Claude writes the file under .claude/work/ instead.

If you keep such files in the repo on purpose, add them to the allow list below.

What it does

The hook checks the Write and NotebookEdit tools. It judges the path from the nearest folder that holds a .git entry.

EventDecision
A new file matches a deny patternDeny, and name the pattern and the fix.
A new file matches an allow entryPass.
The file already existsPass.
The path is under a tool folder such as .claude/ or .github/Pass.
The path is outside any git repoPass.
The allow file is not validDeny, and name the file and the fix.
The file system check fails on a path that could matchDeny with "could not check".

The deny patterns are:

PatternExample
Prototype, scratch, recovery, spike or .local foldersprototypes/x/index.html
reports/ and planning folders under docs/docs/plans/x.md
Test artifact and evidence folders.e2e-evidence/run.sh
A loose .md, .html or .txt file at the repo rootvalidate.html
Findings, report, plan or numbered evidence file names107-EVIDENCE.md
Probe and temporary test file nameszz-probe-load.spec.ts

Root files such as README.md and CLAUDE.md pass. The full lists are in hooks/register.ts.

The allow list

To keep a file in the repo, create .claude/work-folder.json at the repo root:

{ "allow": ["path/prefix/", "glob/**/*.md"] }

An entry that ends with / is a path prefix. Any other entry is a glob.

Settings

No settings.

To turn the mod off, run claude plugin disable work-folder@cj-stack.

Limits

  • It checks only the Write and NotebookEdit tools. Edit, and a shell command that writes a file, pass.
  • It does not add .claude/work/ to .gitignore.
  • The file name patterns are a fixed list. A working file with another name passes.
  • If the file system check fails on a path that could match, the hook denies. Write under .claude/work/ or retry.

More

Source 1 files
hooks/register.ts 138 lines
1import type { Hook, Register } from 'claude-code'
2
3type Api = Parameters<Hook<'tool.call'>>[0]
4
5const MOD = 'work-folder'
6const CONFIG = '.claude/work-folder.json'
7
8const TOOL_FOLDERS = new Set([
9  '.claude', '.agents', '.agent', '.codex', '.cursor', '.gemini', '.grok', '.muse', '.qwen', '.kimi', '.kimi-code',
10  '.trae', '.codebuddy', '.comate', '.lingma', '.qoder', '.windsurf', '.devin', '.kiro', '.roo', '.cline',
11  '.clinerules', '.continue', '.junie', '.opencode', '.factory', '.augment', '.kilo', '.kilocode', '.github',
12  '.vscode', '.idea',
13])
14
15const ROOT_DOCS = new Set([
16  'readme', 'claude', 'claude.local', 'agents', 'agents.override', 'gemini', 'qwen', 'codebuddy', 'codebuddy.local',
17  'warp', 'changelog', 'contributing', 'security', 'code_of_conduct', 'license', 'notice', 'llms', 'llms-full',
18])
19
20const RULES: { why: string; test: (folders: string[], base: string, rel: string) => boolean }[] = [
21  {
22    why: 'a prototype, scratch, recovery, research-notes, spike or .local folder',
23    test: f => f.some(s => ['prototypes', '.scratch', '.recovery', 'research_notes'].includes(s) || s.startsWith('spike-') || s.endsWith('.local')),
24  },
25  {
26    why: 'a reports/ or docs/ planning folder',
27    test: (_f, _b, rel) => /^(reports|docs\/(stories|epics|research|perf|qa|implementation|plans|plan|notes))\//.test(rel),
28  },
29  {
30    why: 'a test artifact or evidence folder',
31    test: f => f.some(s => ['artifacts', '.e2e-evidence', 'test-results'].includes(s)),
32  },
33  {
34    why: 'a loose document at the repo root',
35    test: (f, b) => f.length === 0 && /\.(md|html|txt)$/.test(b) && !ROOT_DOCS.has(b.replace(/\.[^.]*$/, '').toLowerCase()),
36  },
37  {
38    why: 'a findings, report, plan or numbered evidence file name',
39    test: (_f, b) =>
40      /^\d+-[A-Z][A-Z0-9-]*\.md$/.test(b) ||
41      /^\d+-[a-z-]*verification\.json$/.test(b) ||
42      /^(findings|report|summary|scores)\.md$/i.test(b) ||
43      /-(report|plan|draft)\.md$/.test(b) ||
44      /-decisions-[^/]*\.md$/.test(b),
45  },
46  {
47    why: 'a probe or temporary test file name',
48    test: (_f, b) => b.startsWith('zz-probe-') || b.startsWith('test_zz_') || b.endsWith('_tmp.py'),
49  },
50]
51
52function reason(rel: string): string | undefined {
53  const segs = rel.split('/')
54  if (TOOL_FOLDERS.has(segs[0])) return undefined
55  const base = segs[segs.length - 1]
56  const folders = segs.slice(0, -1)
57  return RULES.find(r => r.test(folders, base, rel))?.why
58}
59
60// Every tail is a possible rel, as the root is unknown before fs; the claude-owned runner has no fs hook.
61function mayMatch(segs: string[]): boolean {
62  return segs.some(
63    (_s, i) => !TOOL_FOLDERS.has(segs[i - 1]) && !TOOL_FOLDERS.has(segs[i - 2]) && reason(segs.slice(i).join('/')) !== undefined,
64  )
65}
66
67function globToRegExp(glob: string): RegExp {
68  const src = glob
69    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
70    .replace(/\*\*\//g, '\u0000')
71    .replace(/\*\*/g, '\u0001')
72    .replace(/\*/g, '[^/]*')
73    .replace(/\u0000/g, '(?:.*/)?')
74    .replace(/\u0001/g, '.*')
75  return new RegExp(`^${src}$`)
76}
77
78function allowed(rel: string, allow: string[]): boolean {
79  return allow.some(a => (a.endsWith('/') ? rel.startsWith(a) : globToRegExp(a).test(rel)))
80}
81
82function parseAllow(text: string): string[] {
83  const data = JSON.parse(text) as unknown
84  if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('not a JSON object')
85  const allow = (data as { allow?: unknown }).allow
86  if (allow === undefined) return []
87  if (!Array.isArray(allow) || !allow.every(a => typeof a === 'string')) throw new Error('"allow" is not an array of strings')
88  return allow
89}
90
91async function judge($: Api, path: string): Promise<string | undefined> {
92  const abs = path.replace(/\\/g, '/')
93  const segs = abs.split('/').filter(Boolean)
94  if (!mayMatch(segs)) return undefined
95  if (await $.fs.exists(abs)) return undefined
96  let root: string | undefined
97  for (let i = segs.length - 1; i >= 0 && root === undefined; i--) {
98    const dir = `/${segs.slice(0, i).join('/')}`.replace(/^\/$/, '')
99    if (await $.fs.exists(`${dir}/.git`)) root = dir
100  }
101  if (root === undefined) return undefined
102  const rel = abs.slice(root.length + 1)
103  const why = reason(rel)
104  if (!why) return undefined
105  const config = `${root}/${CONFIG}`
106  let allow: string[] = []
107  if (await $.fs.exists(config)) {
108    const text = (await $.fs.read(config)) as string
109    try {
110      allow = parseAllow(text)
111    } catch (err) {
112      return `${MOD}: ${config} is not valid (${(err as Error).message}). Fix it to { "allow": ["path/prefix/", "glob/**/*.md"] }, or delete it.`
113    }
114  }
115  if (allowed(rel, allow)) return undefined
116  return (
117    `${MOD}: ${rel} looks like an agent working file (${why}). Write it under .claude/work/<topic>/ instead; ` +
118    `that folder is gitignored. If a person keeps this file in the repo, add it to "allow" in ${CONFIG}.`
119  )
120}
121
122export const register: Register = on => {
123  const guard = (path: string, why = 'no answer') =>
124    mayMatch(path.replace(/\\/g, '/').split('/').filter(Boolean))
125      ? { deny: `${MOD}: could not check ${path} (${why}). Write it under .claude/work/<topic>/ instead, or retry.` }
126      : undefined
127
128  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
129    const deny = await judge($, e.file_path)
130    return deny ? { deny } : next(e)
131  }).catch(($, e, next) => (next.called ? next(e) : (guard(e.file_path, next.error.message) ?? next(e))))
132
133  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
134    const deny = await judge($, e.notebook_path)
135    return deny ? { deny } : next(e)
136  }).catch(($, e, next) => (next.called ? next(e) : (guard(e.notebook_path, next.error.message) ?? next(e))))
137}
138