Warns when a turn writes sensitive files nobody asked for (lockfiles, manifests, CI, .env, migrations, infra) or too many files at once.

Tells you when a turn reaches further than you asked: it writes a sensitive file your prompt never named, or changes too many files at once.
▲ touched pnpm-lock.yaml (lockfile), .env (env) without being asked · /jrscope hides
One row on the left, only while there is something to act on.
At the end of each turn, the files Claude wrote (Write, Edit, NotebookEdit) are checked against the prompt that started the turn:
| Kind | Files |
|---|---|
| lockfile | package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock, Cargo.lock, poetry.lock, uv.lock, Gemfile.lock, composer.lock, go.sum |
| manifest | package.json, Cargo.toml, pyproject.toml, requirements*.txt, Gemfile, go.mod, composer.json |
| CI | .github/workflows/*, .gitlab-ci.yml, .circleci/*, Jenkinsfile, azure-pipelines.yml |
| env | .env, .env.* |
| migration | anything under migrations/, migration/ or migrate/ |
| infra | *.tf, *.tfvars, terraform/, infra/, k8s/, helm/, Dockerfile, docker-compose*.yml |
A sensitive file is fine when the prompt names it (its file name or its path). The warning shows as a toast and stays above the prompt until the next turn.
/jrscope hides or shows the band, in every session and across restarts; /jrscope on and /jrscope off set it./config)| Option | Default | |
|---|---|---|
| Too many files | 15 | Warn when one turn changes more files than this |
| Show the band | on | Keep the last warning above the prompt |
/plugin install jr-scope --marketplace jrlprost/claude-code-mods
Watches Write, Edit and NotebookEdit calls and reads the prompt of each turn. Never blocks a call. Runs nothing, sends nothing over the network. Remembers whether the band is hidden.
hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { judge, summary } from './scope'
5
6const warning = atom({ plugin: 'jr-scope', key: 'warning' } as const, null)
7const bandHidden = atom({ plugin: 'jr-scope', key: 'isBandHidden' } as const, false)
8const commands = { isRegistered: false }
9const band = { isLoaded: false }
10
11async function ensureCommand($: EngineInterface) {
12 if (commands.isRegistered) return
13 commands.isRegistered = true
14 await $.command.register({ name: 'jrscope', description: 'Show or hide the scope warning band: /jrscope toggles, /jrscope on or off' })
15}
16
17/** True while the band is hidden: one choice for every session, read from the store once per load. */
18async function isBandHidden($: EngineInterface) {
19 if (!band.isLoaded) {
20 band.isLoaded = true
21 if ((await $.store.get('isBandHidden')) === true) await update($, bandHidden, () => true)
22 }
23 return read($, bandHidden)
24}
25
26// The main loop's current turn: its prompt and the files written so far.
27const turn = { prompt: '', files: new Set<string>() }
28
29const touchedPath = (tool: string, input: Record<string, unknown>) => {
30 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') return typeof input.file_path === 'string' ? input.file_path : null
31 if (tool === 'NotebookEdit') return typeof input.notebook_path === 'string' ? input.notebook_path : null
32 return null
33}
34
35export const register: Register = (on, options) => {
36 const maxFiles = Number(options.maxFiles ?? 15)
37 const showBand = options.showBand !== false
38
39 on('session.start', async ($, e, next) => {
40 const started = await next(e)
41 await ensureCommand($)
42 return started
43 })
44
45 on('turn.start', async ($, e, next) => {
46 if (e.agentId === undefined) {
47 turn.prompt = e.text
48 turn.files = new Set()
49 await update($, warning, () => null)
50 }
51 return next(e)
52 })
53
54 on('tool.call', async ($, e, next) => {
55 const ran = await next(e)
56 const file = touchedPath(String(e.tool), e as unknown as Record<string, unknown>)
57 if (file && !ran.isError && ran.deny === undefined) turn.files.add(file)
58 return ran
59 }).catch(($, e, next) => next(e))
60
61 on('turn.complete', async ($, e, next) => {
62 const done = await next(e)
63 if (e.agentId !== undefined || turn.files.size === 0) return done
64 const root = await $.session.root()
65 const verdict = judge(turn.prompt, [...turn.files], root, maxFiles)
66 const text = summary(verdict, root)
67 if (text) {
68 $.ui.toast(`jr scope · ${text}`)
69 await update($, warning, () => text)
70 }
71 return done
72 })
73
74 on('command.run', { command: 'jrscope' }, async ($, e) => {
75 const arg = e.args.trim().toLowerCase()
76 const hide = arg === 'off' || arg === 'hide' ? true : arg === 'on' || arg === 'show' ? false : arg === '' ? !(await isBandHidden($)) : null
77 if (hide === null) return { text: 'jr scope · usage: /jrscope toggles the band, /jrscope on or off' }
78 await $.store.set('isBandHidden', hide)
79 await update($, bandHidden, () => hide)
80 return { text: hide ? 'jr scope · hidden (/jrscope to bring it back)' : 'jr scope · shown' }
81 })
82
83 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
84 const below = await next(e)
85 if (!commands.isRegistered) {
86 $.clock.after(0, () => {
87 void ensureCommand($)
88 })
89 }
90 // Never write state while drawing: the saved choice is loaded just after.
91 if (!band.isLoaded) {
92 $.clock.after(0, () => {
93 void isBandHidden($)
94 })
95 }
96 if (await read($, bandHidden)) return below
97 const text = await read($, warning)
98 if (!showBand || !text || e.props.hasSurvey) return below
99 const { Box, Text } = $.ui.resolve(e)
100 return (
101 <Box flexDirection="column">
102 <Text wrap="truncate-end">
103 <Text color="#ff8c42">{'▲ '}</Text>
104 <Text>{text}</Text>
105 <Text dimColor>{' · /jrscope hides'}</Text>
106 </Text>
107 {below}
108 </Box>
109 )
110 })
111}
112hooks/scope.ts 39 lines1/** What makes a file sensitive: changing it reaches beyond the code at hand. */
2export const SENSITIVE: { kind: string; test: RegExp }[] = [
3 { kind: 'lockfile', test: /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum|uv\.lock)$/ },
4 { kind: 'manifest', test: /(^|\/)(package\.json|Cargo\.toml|pyproject\.toml|requirements[^/]*\.txt|Gemfile|go\.mod|composer\.json)$/ },
5 { kind: 'CI', test: /(^|\/)(\.github\/workflows\/[^/]+|\.gitlab-ci\.yml|\.circleci\/[^/]+|Jenkinsfile|azure-pipelines\.yml)$/ },
6 { kind: 'env', test: /(^|\/)\.env(\.[^/]*)?$/ },
7 { kind: 'migration', test: /(^|\/)(migrations?|migrate)\// },
8 { kind: 'infra', test: /(\.tf$|\.tfvars$|(^|\/)(terraform|infra|k8s|helm)\/|(^|\/)(Dockerfile|docker-compose[^/]*\.ya?ml)$)/ },
9]
10
11export const sensitiveKind = (file: string) => SENSITIVE.find(s => s.test.test(file))?.kind ?? null
12
13const baseName = (file: string) => file.split('/').pop() ?? file
14
15/** A file counts as asked for when the prompt names it (its name, or its path from the project root). */
16export const isNamedIn = (prompt: string, file: string, root: string) => {
17 const rel = file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file
18 const p = prompt.toLowerCase()
19 return p.includes(baseName(file).toLowerCase()) || p.includes(rel.toLowerCase())
20}
21
22export type ScopeVerdict = { sensitive: { file: string; kind: string }[]; total: number; isTooMany: boolean }
23
24/** The turn's touched files judged against the prompt: sensitive files nobody asked for, and the count. */
25export const judge = (prompt: string, files: readonly string[], root: string, maxFiles: number): ScopeVerdict => {
26 const sensitive = files
27 .map(file => ({ file, kind: sensitiveKind(file) }))
28 .filter((f): f is { file: string; kind: string } => f.kind !== null && !isNamedIn(prompt, f.file, root))
29 return { sensitive, total: files.length, isTooMany: files.length > maxFiles }
30}
31
32export const summary = (v: ScopeVerdict, root: string) => {
33 const rel = (f: string) => (f.startsWith(`${root}/`) ? f.slice(root.length + 1) : f)
34 const parts: string[] = []
35 if (v.sensitive.length) parts.push(`touched ${v.sensitive.map(s => `${rel(s.file)} (${s.kind})`).join(', ')} without being asked`)
36 if (v.isTooMany) parts.push(`${v.total} files changed in one turn`)
37 return parts.join(' · ')
38}
39types/index.d.ts 9 lines1/** The last turn's scope warning, kept until the next turn starts. */
2export type JrScopeWarning = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'jr-scope': { warning: JrScopeWarning | null; isBandHidden: boolean }
7 }
8}
9