SLOPSHOPPER

scope-watch

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…

newguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-13samuelbfernandes/moddeck/mods/scope-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scope-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /scope-watch ⎿ scope-watch: scope-watch · no folder set ⎿ scope-watch: Use /scope-watch <folder> to name the folder you are working in. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

scope-watch

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.

What it does

  • /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.
  • After every Edit, Write, MultiEdit or NotebookEdit call that succeeded, it compares the file's path with the folder. For a file outside it:
  • a toast: scope-watch: R/other.R is outside src;
  • a note the model reads after the result: 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.
  • Never blocks: the edit has already run when it looks, and its result goes back unchanged apart from that one note. A failed or refused edit, and every other tool, is left alone.

Use

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

Turn off

/scope-watch off, or /plugin disable scope-watch. With no folder set it does nothing.

State for the moddeck side bar

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

Limits

  • Only the four edit tools are watched. Files changed by a Bash command (sed -i, a script, git checkout) or an MCP tool are not seen.
  • Paths are compared as text after folding . 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.
  • The note costs a few tokens, once per outside path.
  • Tested with a mocked engine (claude plugin test), POSIX and Windows-style paths; not yet checked by hand in a live session.

Credit

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.

Source 3 files
hooks/register.tsx 139 lines
1// 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}
139
hooks/lib.ts 70 lines
1// 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}
70
types/index.d.ts 15 lines
1declare 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