Set the folder you are working in; when Claude edits a file outside it, you get a toast and Claude gets a short note to check the edit was intended. Never…

Install: /plugin install scope-watch --marketplace samuelbfernandes/moddeck
Tell Claude which folder you are working in; when it edits a file outside that folder, you see it at once and Claude is asked to check the edit was intended.
/scope-watch <folder> sets the folder, relative to the session's folder (./src/, src and src/ are the same). It is remembered per project: each session folder keeps its own.scope-watch: R/other.R is outside src;scope-watch: R/other.R is outside the folder the person is working in (src). Check this edit was intended. Both come once per path per session, so a series of edits to one file is flagged once. Every outside edit is counted./scope-watch src/analysis watch this folder (remembered for this project) /scope-watch the folder and the count of edits outside it this session /scope-watch off stop watching (forgets the folder for this project)
A folder that does not exist yet is accepted, with a warning; a path that is a file is refused.
/scope-watch off, or /plugin disable scope-watch. With no folder set it does nothing.
{ plugin: 'scope-watch', key: 'folder' } (string or null), { plugin: 'scope-watch', key: 'outside' } (number of edits outside this session), { plugin: 'scope-watch', key: 'paths' } (the distinct outside paths, at most 50) and { plugin: 'scope-watch', key: 'root' } (the session folder they belong to). $.store holds one key per project, folder:<session folder>.
sed -i, a script, git checkout) or an MCP tool are not seen.. and ..; links are not followed. Case is ignored for Windows paths only, so on a case-insensitive macOS disk Src/ and src/ count as different folders.claude plugin test), POSIX and Windows-style paths; not yet checked by hand in a live session.Adapted from scope-watch in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: TypeScript in the house style, the folder set by command and remembered per project instead of typed into a pane each session, paths normalised for POSIX and Windows, a toast and a model note once per path, a count the side bar can read, and tests. The upstream licence is in LICENSE.
hooks/register.tsx 139 lines1// scope-watch: `/scope-watch <folder>` names the folder the person is working in (kept per project). When Claude then
2// edits a file outside it (Edit, Write, MultiEdit, NotebookEdit), the person gets a toast and the model a short note
3// asking it to check the edit was intended, each once per path. `/scope-watch off` clears the folder.
4// FAIL OPEN: it never blocks or changes an edit; it looks only after the edit ran, and any error leaves the result as is.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { absolute, isInside, normalize, relativeTo, unquote } from './lib'
9
10type Dollar = EngineInterface
11
12const COMMAND = 'scope-watch'
13const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14const MAX_PATHS = 50
15
16const folder = atom({ plugin: 'scope-watch', key: 'folder' } as const, null)
17const outside = atom({ plugin: 'scope-watch', key: 'outside' } as const, 0)
18const paths = atom({ plugin: 'scope-watch', key: 'paths' } as const, [])
19const root = atom({ plugin: 'scope-watch', key: 'root' } as const, '')
20
21const storeKey = (cwd: string) => `folder:${normalize(cwd)}`
22
23/** Loads the folder stored for the session's current folder, once per change of folder. Returns the cwd. */
24async function ensureLoaded($: Dollar): Promise<string> {
25 const cwd = normalize(await $.session.cwd())
26 if ((await read($, root)) === cwd) return cwd
27 const stored = await $.store.get(storeKey(cwd))
28 await update($, root, () => cwd)
29 await update($, folder, () => (typeof stored === 'string' && stored !== '' ? stored : null))
30 await update($, outside, () => 0)
31 await update($, paths, () => [])
32
33 return cwd
34}
35
36async function status($: Dollar): Promise<string> {
37 await ensureLoaded($)
38 const watched = await read($, folder)
39 if (watched === null) return 'scope-watch · no folder set\nUse /scope-watch <folder> to name the folder you are working in.'
40 const count = await read($, outside)
41 const seen = await read($, paths)
42 const lines = [`scope-watch · watching ${watched} · ${count} edit${count === 1 ? '' : 's'} outside it this session`]
43 if (seen.length > 0) lines.push(`Outside: ${seen.slice(-5).join(', ')}${seen.length > 5 ? ` and ${seen.length - 5} more` : ''}`)
44
45 return lines.join('\n')
46}
47
48async function setFolder($: Dollar, raw: string): Promise<string> {
49 const typed = unquote(raw)
50 if (/[\u0000-\u001f\u007f]/.test(typed)) return 'scope-watch · a folder may not hold control characters'
51 const cwd = await ensureLoaded($)
52 const full = absolute(cwd, typed)
53 let warning = ''
54 try {
55 const stat = await $.fs.stat(full)
56 if (stat.kind !== 'dir') return `scope-watch · ${typed} is not a folder; nothing changed`
57 } catch {
58 warning = '\nIt does not exist yet; edits will be compared with it anyway.'
59 }
60 const shown = relativeTo(cwd, full)
61 await $.store.set(storeKey(cwd), shown)
62 await update($, folder, () => shown)
63 await update($, outside, () => 0)
64 await update($, paths, () => [])
65
66 return `scope-watch · watching ${shown}${warning}`
67}
68
69async function run($: Dollar, args: string): Promise<string> {
70 const word = args.trim()
71 if (word === '' || word.toLowerCase() === 'status') return status($)
72 if (word.toLowerCase() === 'off') {
73 const cwd = await ensureLoaded($)
74 await $.store.delete(storeKey(cwd))
75 await update($, folder, () => null)
76 await update($, outside, () => 0)
77 await update($, paths, () => [])
78
79 return 'scope-watch · off (no folder set)'
80 }
81
82 return setFolder($, word)
83}
84
85/** After an edit ran: the note for the model when the file lies outside the folder and was not noted before. */
86async function noteFor($: Dollar, input: Record<string, unknown>): Promise<string | undefined> {
87 const target = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
88 if (target === undefined || target === '') return undefined
89 const cwd = await ensureLoaded($)
90 const watched = await read($, folder)
91 if (watched === null) return undefined
92 const file = absolute(cwd, target)
93 if (isInside(file, absolute(cwd, watched))) return undefined
94 await update($, outside, n => n + 1)
95 const shown = relativeTo(cwd, file)
96 const seen = await read($, paths)
97 if (seen.includes(shown)) return undefined // toast and note once per path
98 await update($, paths, list => [...list, shown].slice(-MAX_PATHS))
99 $.ui.toast(`scope-watch: ${shown} is outside ${watched}`, { timeoutMs: 6000 })
100
101 return `scope-watch: ${shown} is outside the folder the person is working in (${watched}). Check this edit was intended.`
102}
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 try {
107 await ensureLoaded($)
108 } catch {
109 // loaded again on first use
110 }
111 try {
112 await $.command.register({ name: COMMAND, description: 'Name the folder you work in; edits outside it are flagged', argumentHint: '[folder|off|status]' })
113 } catch {
114 // fail-open: the session goes on without the command
115 }
116
117 return next(e)
118 })
119
120 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({
121 text: 'scope-watch · that did not work, nothing changed',
122 }))
123
124 // Watches only, after the edit ran: never blocks, never changes the edit or its result beyond one added note.
125 on('tool.call', async ($, e, next) => {
126 if (!EDIT_TOOLS.has(String(e.tool))) return next(e)
127 const ran = await next(e)
128 if (ran.deny !== undefined || ran.isError === true) return ran
129 try {
130 const note = await noteFor($, e as unknown as Record<string, unknown>)
131 if (note !== undefined) return { ...ran, context: [...(ran.context ?? []), note] } as typeof ran
132 } catch {
133 // a scope problem never reaches the model
134 }
135
136 return ran
137 }).catch(($, e, next) => next(e))
138}
139hooks/lib.ts 70 lines1// scope-watch: pure path arithmetic (no engine access), for POSIX and Windows paths alike.
2
3/** True for `/x`, `\\server\x`, `C:\x` and `C:/x`. */
4export const isAbsolute = (path: string): boolean => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path)
5
6/** True when the path is spelled the Windows way (a drive letter or a UNC share), where case does not matter. */
7export const isWindowsPath = (path: string): boolean => /^(?:[A-Za-z]:|[\\/]{2}[^\\/])/.test(path)
8
9/** Forward slashes, `.` and `..` folded, no trailing slash, drive letter upper-cased. A relative path stays relative. */
10export function normalize(path: string): string {
11 const s = path.trim().replace(/\\/g, '/')
12 let root = ''
13 let rest = s
14 const drive = /^([A-Za-z]):(\/|$)/.exec(s)
15 if (drive) {
16 root = `${drive[1]!.toUpperCase()}:/`
17 rest = s.slice(drive[0].length)
18 } else if (s.startsWith('//')) {
19 root = '//'
20 rest = s.slice(2)
21 } else if (s.startsWith('/')) {
22 root = '/'
23 rest = s.slice(1)
24 }
25 const parts: string[] = []
26 for (const seg of rest.split('/')) {
27 if (seg === '' || seg === '.') continue
28 if (seg === '..') {
29 if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
30 else if (root === '') parts.push('..')
31 continue
32 }
33 parts.push(seg)
34 }
35
36 return root + parts.join('/') || '.'
37}
38
39/** `path` made absolute against `cwd` (when relative) and normalised. */
40export function absolute(cwd: string, path: string): string {
41 return normalize(isAbsolute(path) ? path : `${cwd}/${path}`)
42}
43
44const same = (a: string, b: string, isFolded: boolean) => (isFolded ? a.toLowerCase() === b.toLowerCase() : a === b)
45
46/** True when `file` is `folder` or lies beneath it (both absolute and normalised). */
47export function isInside(file: string, folder: string): boolean {
48 const isFolded = isWindowsPath(folder)
49 if (same(file, folder, isFolded)) return true
50 const prefix = folder.endsWith('/') ? folder : `${folder}/`
51
52 return same(file.slice(0, prefix.length), prefix, isFolded)
53}
54
55/** `path` (absolute, normalised) relative to `cwd` when it lies beneath it; else as it is. */
56export function relativeTo(cwd: string, path: string): string {
57 const base = normalize(cwd)
58 if (same(path, base, isWindowsPath(base))) return '.'
59
60 return isInside(path, base) ? path.slice((base.endsWith('/') ? base : `${base}/`).length) : path
61}
62
63/** The path as typed, without matching quotes around it. */
64export function unquote(raw: string): string {
65 const path = raw.trim()
66 const m = /^(["'])(.*)\1$/.exec(path)
67
68 return m ? m[2]! : path
69}
70types/index.d.ts 15 lines1declare module 'claude-code' {
2 interface PluginState {
3 'scope-watch': {
4 /** The folder being watched, relative to the session's folder (or absolute when outside it); null when none is set. */
5 folder: string | null
6 /** File edits outside that folder this session (every edit counted, repeats included). */
7 outside: number
8 /** The distinct paths edited outside the folder this session, as shown (relative where possible), oldest first, at most 50. */
9 paths: string[]
10 /** The session folder the watched folder belongs to (the `$.store` key is `folder:` + this). */
11 root: string
12 }
13 }
14}
15