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

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.
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.
/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.
reports/x.md with the Write tool.reports/ is a planning folder..claude/work/ instead.If you keep such files in the repo on purpose, add them to the allow list below.
The hook checks the Write and NotebookEdit tools. It judges the path from the nearest folder that holds a .git entry.
| Event | Decision |
|---|---|
| A new file matches a deny pattern | Deny, and name the pattern and the fix. |
| A new file matches an allow entry | Pass. |
| The file already exists | Pass. |
The path is under a tool folder such as .claude/ or .github/ | Pass. |
| The path is outside any git repo | Pass. |
| The allow file is not valid | Deny, and name the file and the fix. |
| The file system check fails on a path that could match | Deny with "could not check". |
The deny patterns are:
| Pattern | Example |
|---|---|
Prototype, scratch, recovery, spike or .local folders | prototypes/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 root | validate.html |
| Findings, report, plan or numbered evidence file names | 107-EVIDENCE.md |
| Probe and temporary test file names | zz-probe-load.spec.ts |
Root files such as README.md and CLAUDE.md pass. The full lists are in hooks/register.ts.
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.
No settings.
To turn the mod off, run claude plugin disable work-folder@cj-stack.
.claude/work/ to .gitignore..claude/work/ or retry.hooks/register.ts 138 lines1import 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