SLOPSHOPPER

scope-guard

Notices when Claude drifts outside your request. Clear drift waits for your answer in a question dialog; milder drift is flagged above the prompt with a…

newbandguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/scope-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scope-guard
› 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 ⎿ scope-guard: Scope Guard is on: clear drift waits for your answer, milder drift is flagged above the prompt. ⎿ scope-guard: Task: "fix the failing auth test and add an audit log call" ⎿ scope-guard: Allowed for this task: nothing yet ⎿ scope-guard: Recent flags: ⎿ scope-guard: open create src/cache.ts: your request does not mention src/cache.ts, and Claude did not read or search i ⎿ scope-guard: asked delete build: your request does not mention build, and Claude did not read or search it; it deletes ▌ SCOPE Claude chose to create src/cache.ts, which looks outside your request. your request does not mention src/cache.ts, and Claude did not read or search it 7: Pull back 8: Fine 9: Allow src/ type the number, Enter ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ scope-guard: 1 flag 7 pull back 8 fine

Draws

Band
▌ SCOPE Claude chose to create src/cache.ts, which looks outside your request. your request does not mention src/cache.ts, and Claude did not read or search it 7: Pull back 8: Fine 9: Allow src/ type the number, Enter ⟨Claude Code's own drawing⟩
README

Scope Guard

Scope Guard notices when Claude starts to change things you did not ask for. Clear drift waits for your answer; milder drift is flagged above the prompt.

Scope Guard preview

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install scope-guard@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How it works

Scope Guard keeps your last three prompts as the task. Before each Edit, Write, NotebookEdit, and each shell command that deletes or moves files (rm, git rm, mv, git mv), it checks cheap rules first:

  • The file is not in your request, and Claude did not read or search it this turn.
  • The file is outside the project.
  • The change deletes or moves files.
  • The change rewrites most of a long file, or an edit removes 40 lines or more.
  • The file is the one that reaches the many-files limit for this turn (8 by default).

A file your request names, a file Claude already changed this turn, and scratch files under /tmp never count. When the rules find enough, Sonnet 5.5 reads your task, what Claude said just before the change, and the change itself, and decides: in scope, mild drift, or clear drift. If the model call fails, the rules decide alone.

How to use

Install it and work as usual. Nothing shows until Claude drifts.

  • /scope: show the task Scope Guard keeps, the paths you allowed, and the recent flags.
  • /scope off and /scope on: stop or start the checks. The choice stays for the next sessions.
  • /scope flag: flag drift above the prompt but never stop Claude.
  • /scope ask: stop clear drift with a question again (the default).

How to interact

On clear drift, Claude waits on Claude Code's own question dialog:

Scope
Claude wants to delete docs/old.md, outside your request. The request is about math.js only. Allow it?
❯ 1. Allow once
  2. Allow for this task
  3. Stop
  • Allow once lets this change run.
  • Allow for this task lets this file and its folder through until your next new request.
  • Stop refuses the change, and Claude reads why. Text typed under "Other" goes to Claude with the refusal.

Milder drift runs, and a band above the prompt flags it:

▌ SCOPE Claude chose to edit README.md, which looks outside your request.
  The README is related but the request was only about math.js.
7: Pull back  8: Fine  9: Allow the top folder  type the number, Enter
  • 7 Pull back tells Claude to stay in scope and revert the change if the request does not need it. During a turn the note reaches Claude mid-turn; after the turn it becomes your next prompt.
  • 8 Fine clears the flag.
  • 9 Allow this folder clears every flag in that folder and stops new ones there for this task.

Type the number and press Enter. When the prompt is empty and Claude is not streaming text, the number alone works too. The status line shows how many flags are open.

Settings

Change these in /plugin:

  • mode (ask): ask, flag, or off, as with the commands above.
  • confirmModel (claude-sonnet-5-5): the model that confirms a drift the rules found.
  • manyFiles (8): how many distinct files Claude may change in one turn before that counts as drift.

Most changes never reach the model. Each change the rules flag costs one short confirm call at medium effort, and Claude waits for it (20 seconds at most) before the change runs.

See the marketplace README for configuration, privacy, updates, and removal.

Source 9 files
hooks/register.ts 77 lines
1import type { PluginOptions, Register } from 'claude-code'
2
3import { installBand } from './band'
4import { installGuard } from './guard'
5import { scopeReport, statusText } from './scope/messages'
6import { configureScope, openFlags, resetScope, scopeView, setMode, setRoot } from './scope/state'
7import type { ScopeMode, ScopeSettings } from './scope/types'
8
9const MODE_KEY = 'mode'
10const MODES: readonly ScopeMode[] = ['ask', 'flag', 'off']
11
12function modeFrom(value: unknown): ScopeMode | undefined {
13  return MODES.find(mode => mode === value)
14}
15
16function settingsFrom(options: PluginOptions): ScopeSettings {
17  const model = options.confirmModel
18  const manyFiles = Number(options.manyFiles)
19  return {
20    mode: modeFrom(options.mode) ?? 'ask',
21    confirmModel: typeof model === 'string' && model.trim() !== '' ? model.trim() : 'claude-sonnet-5-5',
22    manyFiles: Number.isFinite(manyFiles) && manyFiles >= 2 ? Math.round(manyFiles) : 8,
23  }
24}
25
26function modeForAction(action: string): ScopeMode | undefined {
27  if (action === 'on') {
28    const configured = scopeView().settings.mode
29    return configured === 'off' ? 'ask' : configured
30  }
31  return modeFrom(action)
32}
33
34export const register: Register = (on, options) => {
35  resetScope()
36  configureScope(settingsFrom(options))
37  installGuard(on)
38  installBand(on)
39
40  on('session.start', async ($, e, next) => {
41    setRoot(e.cwd)
42    await $.command.register({
43      name: 'scope',
44      description: 'Scope Guard: show the task, allowed paths, and recent flags. on, off, ask, or flag sets the mode.',
45      argumentHint: '[on|off|ask|flag]',
46      immediate: true,
47    })
48    const saved = modeFrom(await $.store.get(MODE_KEY))
49    if (saved) {
50      setMode(saved)
51    }
52    return next(e)
53  })
54
55  on('command.run', { command: 'scope' }, async ($, e) => {
56    const action = e.args.trim().toLowerCase()
57    const mode = modeForAction(action)
58    if (mode) {
59      setMode(mode)
60      await $.store.set(MODE_KEY, mode)
61    } else if (action !== '') {
62      return { text: `Unknown option "${action}". Use /scope, /scope on, /scope off, /scope ask, or /scope flag.` }
63    }
64    $.ui.status(mode === 'off' ? undefined : statusText(openFlags().length))
65    $.ui.invalidate('ui.render')
66    const view = scopeView()
67    return {
68      text: scopeReport({
69        mode: view.mode,
70        prompts: view.prompts,
71        allowed: [...view.allowedPaths, ...[...view.allowedFolders].map(folder => `${folder}/`)],
72        flags: view.flags,
73      }),
74    }
75  })
76}
77
hooks/band.tsx 102 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { CHOICE_DIGITS, CHOICE_LABELS, flagLine, pullBackNote, statusText } from './scope/messages'
4import { allowFolder, openFlags, recordPrompt, resolveFlag, resolveFolder, scopeView } from './scope/state'
5import type { BandChoice, Flag } from './scope/types'
6
7const AMBER = '#f5a524'
8const CHOICES: readonly BandChoice[] = ['pull', 'fine', 'folder']
9
10function latestFlag(): Flag | undefined {
11  return openFlags().at(-1)
12}
13
14function choiceForDigit(text: string): BandChoice | undefined {
15  const typed = text.trim()
16  return CHOICES.find(choice => CHOICE_DIGITS[choice] === typed)
17}
18
19function folderLabel(flag: Flag): string {
20  return flag.folder === '.' ? 'Allow the top folder' : `Allow ${flag.folder}/`
21}
22
23async function pullBack($: EngineInterface, options: { flag: Flag }): Promise<void> {
24  const note = pullBackNote(options.flag)
25  if (!scopeView().isWorking) {
26    await $.prompt.submit({ text: note })
27    return
28  }
29  const appended = await $.session
30    .append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
31    .catch(() => undefined)
32  $.ui.toast(appended === undefined || appended.deny !== undefined ? 'Claude could not take the note mid-turn. Run /scope after the turn.' : 'Asked Claude to pull back')
33}
34
35async function perform($: EngineInterface, options: { choice: BandChoice; flag: Flag }): Promise<void> {
36  const { choice, flag } = options
37  if (choice === 'pull') {
38    resolveFlag({ id: flag.id, status: 'pulled' })
39    await pullBack($, { flag })
40  } else if (choice === 'fine') {
41    resolveFlag({ id: flag.id, status: 'fine' })
42  } else {
43    allowFolder(flag.folder)
44    resolveFolder(flag.folder)
45    $.ui.toast(`Scope Guard allows ${flag.folder === '.' ? 'the top folder' : `${flag.folder}/`} for this task`)
46  }
47  $.ui.status(statusText(openFlags().length))
48  $.ui.invalidate('ui.render')
49}
50
51export function installBand(on: On): void {
52  on('prompt.submit', async ($, e, next) => {
53    const flag = latestFlag()
54    const choice = e.origin.kind === 'composer' && flag ? choiceForDigit(e.text) : undefined
55    if (flag && choice === 'pull' && !scopeView().isWorking) {
56      resolveFlag({ id: flag.id, status: 'pulled' })
57      $.ui.status(statusText(openFlags().length))
58      $.ui.invalidate('ui.render')
59      return next({ ...e, text: pullBackNote(flag) })
60    }
61    if (flag && choice) {
62      await perform($, { choice, flag })
63      return { drop: `Scope Guard: ${CHOICE_LABELS[choice]}` }
64    }
65    const text = e.text.trim()
66    if (e.origin.kind === 'composer' && text !== '' && !text.startsWith('/')) {
67      recordPrompt({ text, isNewTask: e.turnId === undefined })
68      $.ui.status(statusText(openFlags().length))
69      $.ui.invalidate('ui.render')
70    }
71    return next(e)
72  })
73
74  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
75    const below = await next(e)
76    const flag = latestFlag()
77    if (!flag || e.props.hasSurvey) {
78      return below
79    }
80    const { Box, Text, Button } = $.ui.resolve(e)
81    const more = openFlags().length - 1
82    const labels: Record<BandChoice, string> = { ...CHOICE_LABELS, folder: folderLabel(flag) }
83    return (
84      <Box flexDirection="column">
85        <Text>
86          <Text color={AMBER} bold>▌ SCOPE </Text>
87          <Text>{flagLine(flag)}</Text>
88          {more > 0 && <Text dimColor>{`  +${more} more`}</Text>}
89        </Text>
90        <Text dimColor italic>{`  ${flag.reason}`}</Text>
91        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
92          {CHOICES.map(choice => (
93            <Button key={choice} plain hotkey={CHOICE_DIGITS[choice]} label={labels[choice]} onPress={() => perform($, { choice, flag })} />
94          ))}
95          <Text dimColor>type the number, Enter</Text>
96        </Box>
97        {below}
98      </Box>
99    )
100  })
101}
102
hooks/guard.ts 190 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { CONFIRM_SYSTEM, confirmPrompt, parseVerdict } from './scope/confirm'
4import { ASK_OPTIONS, askQuestion, denyReason, statusText } from './scope/messages'
5import { folderOf, relativeTo } from './scope/paths'
6import { actionOf, assess, commandChange, CONFIRM_SCORE, editChange, notebookChange, rulesVerdict, writeChange } from './scope/rules'
7import type { RuleContext } from './scope/rules'
8import {
9  addFlag,
10  allowFolder,
11  allowPath,
12  endTurn,
13  isAllowed,
14  isSeen,
15  markManyFilesFlagged,
16  noteEdited,
17  noteNarration,
18  noteSeen,
19  openFlags,
20  scopeView,
21  startTurn,
22  taskText,
23} from './scope/state'
24import type { Assessment, Change, Decision, FlagStatus } from './scope/types'
25
26const CONFIRM_TIMEOUT_MS = 20000
27
28type Outcome = { deny?: string; isError?: boolean }
29type Answer = { kind: 'once' } | { kind: 'task' } | { kind: 'stop'; typed: string | undefined } | { kind: 'dismissed' }
30
31function ruleContext(): RuleContext {
32  const view = scopeView()
33  return {
34    root: view.root,
35    task: taskText(),
36    isSeen,
37    isAllowed,
38    editedPaths: view.editedPaths,
39    isManyFilesFlagged: view.isManyFilesFlagged,
40    manyFiles: view.settings.manyFiles,
41  }
42}
43
44function relativePaths(change: Change): string[] {
45  return change.paths.map(path => relativeTo({ path, root: scopeView().root }))
46}
47
48function isApplied(ran: Outcome): boolean {
49  return ran.deny === undefined && ran.isError !== true
50}
51
52function remember(options: { change: Change; reason: string; status: FlagStatus }): void {
53  const [first = ''] = relativePaths(options.change)
54  addFlag({ path: relativePaths(options.change).join(', '), folder: folderOf(first), action: actionOf(options.change), reason: options.reason, status: options.status })
55}
56
57async function confirm($: EngineInterface, options: { change: Change; assessment: Assessment }): Promise<Decision> {
58  const { change, assessment } = options
59  const fallback: Decision = {
60    verdict: rulesVerdict(assessment.score),
61    reason: assessment.signals.map(signal => signal.text).join('; '),
62    isConfirmed: false,
63  }
64  const view = scopeView()
65  const result = await $.model
66    .complete({
67      model: view.settings.confirmModel,
68      effort: 'medium',
69      system: CONFIRM_SYSTEM,
70      prompt: confirmPrompt({ task: taskText(), narration: view.narration, change, action: actionOf(change), signals: assessment.signals }),
71      maxTokens: 300,
72      timeoutMs: CONFIRM_TIMEOUT_MS,
73    })
74    .catch(() => undefined)
75  const parsed = result?.isAnswered ? parseVerdict(result.text) : undefined
76  return parsed ? { verdict: parsed.verdict, reason: parsed.reason || fallback.reason, isConfirmed: true } : fallback
77}
78
79async function askPerson($: EngineInterface, options: { change: Change; reason: string }): Promise<Answer> {
80  const question = askQuestion({ action: actionOf(options.change), paths: relativePaths(options.change), reason: options.reason })
81  const answer = await $.ui.ask(question, { options: ASK_OPTIONS, header: 'Scope' }).catch(() => undefined)
82  if (answer === undefined) {
83    return { kind: 'dismissed' }
84  }
85  if (answer === 'Allow once') {
86    return { kind: 'once' }
87  }
88  if (answer === 'Allow for this task') {
89    return { kind: 'task' }
90  }
91  return { kind: 'stop', typed: answer === 'Stop' ? undefined : answer }
92}
93
94async function guarded<Result extends Outcome>($: EngineInterface, options: { change: Change; run: () => Promise<Result> }): Promise<Result | { deny: string }> {
95  const { change } = options
96  const view = scopeView()
97  if (view.mode === 'off') {
98    return options.run()
99  }
100  const assessment = assess({ change, context: ruleContext() })
101  if (assessment.signals.some(signal => signal.kind === 'many-files')) {
102    markManyFilesFlagged()
103  }
104  let decision: Decision | undefined
105  if (assessment.score >= CONFIRM_SCORE) {
106    decision = await confirm($, { change, assessment })
107  }
108  if (decision?.verdict === 'clear' && view.mode === 'ask') {
109    const answer = await askPerson($, { change, reason: decision.reason })
110    if (answer.kind === 'stop') {
111      remember({ change, reason: decision.reason, status: 'stopped' })
112      return { deny: denyReason({ reason: decision.reason, typed: answer.typed }) }
113    }
114    if (answer.kind === 'task') {
115      relativePaths(change).forEach(path => {
116        allowPath(path)
117        allowFolder(folderOf(path))
118      })
119    }
120    if (answer.kind !== 'dismissed') {
121      remember({ change, reason: decision.reason, status: 'asked' })
122      decision = undefined
123    }
124  }
125  const ran = await options.run()
126  if (!isApplied(ran)) {
127    return ran
128  }
129  noteEdited(change.paths)
130  if (decision && decision.verdict !== 'in-scope') {
131    remember({ change, reason: decision.reason, status: 'open' })
132    $.ui.status(statusText(openFlags().length))
133    $.ui.invalidate('ui.render')
134  }
135  return ran
136}
137
138export function installGuard(on: On): void {
139  on('tool.call', async ($, e, next) => {
140    if (e.tool === 'Read') {
141      const ran = await next(e)
142      noteSeen(e.file_path)
143      return ran
144    }
145    if (e.tool === 'Bash') {
146      const change = commandChange(e.command)
147      if (change) {
148        return guarded($, { change, run: () => next(e) })
149      }
150      const ran = await next(e)
151      noteSeen(ran.deny !== undefined || ran.isError === true ? e.command : `${e.command}\n${ran.result.stdout}`)
152      return ran
153    }
154    if (e.tool === 'Edit') {
155      return guarded($, { change: editChange({ filePath: e.file_path, oldText: e.old_string, newText: e.new_string }), run: () => next(e) })
156    }
157    if (e.tool === 'NotebookEdit') {
158      return guarded($, { change: notebookChange({ filePath: e.notebook_path, mode: e.edit_mode }), run: () => next(e) })
159    }
160    if (e.tool === 'Write') {
161      const original = scopeView().mode === 'off' ? undefined : await $.fs.read(e.file_path).catch(() => undefined)
162      return guarded($, { change: writeChange({ filePath: e.file_path, content: e.content, original }), run: () => next(e) })
163    }
164    return next(e)
165  })
166
167  on('turn.step', async function* ($, e, next) {
168    const stream = next(e)
169    for await (const chunk of stream) {
170      if (e.agentId === undefined && chunk.kind === 'text') {
171        noteNarration(chunk.text)
172      }
173      yield chunk
174    }
175    return await stream.result
176  })
177
178  on('turn.start', ($, e, next) => {
179    startTurn()
180    return next(e)
181  })
182
183  on('turn.complete', ($, e, next) => {
184    if (e.agentId === undefined) {
185      endTurn()
186    }
187    return next(e)
188  })
189}
190
hooks/scope/messages.ts 71 lines
1import type { BandChoice, Flag, ScopeMode } from './types'
2
3export const ASK_OPTIONS = ['Allow once', 'Allow for this task', 'Stop'] as const
4
5export const CHOICE_LABELS: Record<BandChoice, string> = {
6  pull: 'Pull back',
7  fine: 'Fine',
8  folder: 'Allow this folder',
9}
10
11export const CHOICE_DIGITS: Record<BandChoice, string> = { pull: '7', fine: '8', folder: '9' }
12
13const MODE_TEXT: Record<ScopeMode, string> = {
14  ask: 'on: clear drift waits for your answer, milder drift is flagged above the prompt',
15  flag: 'on in flag mode: drift is flagged above the prompt and never stops Claude',
16  off: 'off: nothing is checked until you run /scope on',
17}
18
19function excerpt(options: { text: string; length: number }): string {
20  const flat = options.text.replace(/\s+/g, ' ').trim()
21  return flat.length > options.length ? `${flat.slice(0, options.length - 1)}…` : flat
22}
23
24export function statusText(openCount: number): string | undefined {
25  if (openCount === 0) {
26    return undefined
27  }
28  return `${openCount} flag${openCount === 1 ? '' : 's'}  7 pull back  8 fine`
29}
30
31export function askQuestion(options: { action: string; paths: readonly string[]; reason: string }): string {
32  const target = excerpt({ text: options.paths.join(', '), length: 80 })
33  const reason = options.reason === '' ? '' : ` ${excerpt({ text: options.reason, length: 140 }).replace(/[.?!]$/, '')}.`
34  return `Claude wants to ${options.action} ${target}, outside your request.${reason} Allow it?`
35}
36
37export function denyReason(options: { reason: string; typed: string | undefined }): string {
38  const said = options.typed === undefined ? '' : ` The user said: "${options.typed}".`
39  return `Scope Guard: the user stopped this change because it is outside their request (${options.reason}).${said} Stay inside the request, and ask the user before you touch files it does not mention.`
40}
41
42export function pullBackNote(flag: Flag): string {
43  return `Scope Guard: the user asks you to stay inside their request. You chose to ${flag.action} ${flag.path} (${flag.reason}). If the request does not need that change, revert it, then continue with the request only.`
44}
45
46export function flagLine(flag: Flag): string {
47  return `Claude chose to ${flag.action} ${flag.path}, which looks outside your request.`
48}
49
50export function scopeReport(options: {
51  mode: ScopeMode
52  prompts: readonly string[]
53  allowed: readonly string[]
54  flags: readonly Flag[]
55}): string {
56  const [latest] = options.prompts.slice(-1)
57  const earlier = options.prompts.length - 1
58  const lines = [
59    `Scope Guard is ${MODE_TEXT[options.mode]}.`,
60    latest === undefined
61      ? 'Task: nothing recorded yet. Send a request first.'
62      : `Task: "${excerpt({ text: latest, length: 160 })}"${earlier > 0 ? ` (and ${earlier} earlier prompt${earlier === 1 ? '' : 's'})` : ''}`,
63    `Allowed for this task: ${options.allowed.length === 0 ? 'nothing yet' : options.allowed.join(', ')}`,
64  ]
65  if (options.flags.length === 0) {
66    return [...lines, 'Flags: none yet.'].join('\n')
67  }
68  const recent = options.flags.slice(-6).map(flag => `  ${flag.status.padEnd(7)} ${flag.action} ${flag.path}: ${flag.reason}`)
69  return [...lines, 'Recent flags:', ...recent].join('\n')
70}
71
hooks/scope/state.ts 145 lines
1import { baseName, isUnderFolder, pathTokens, relativeTo } from './paths'
2import type { Flag, FlagStatus, ScopeMode, ScopeSettings } from './types'
3
4const KEPT_PROMPTS = 3
5const KEPT_FLAGS = 20
6const NARRATION_CHARACTERS = 1200
7const SCANNED_OUTPUT_CHARACTERS = 6000
8
9type ScopeState = {
10  root: string
11  settings: ScopeSettings
12  mode: ScopeMode
13  prompts: string[]
14  seenPaths: Set<string>
15  editedPaths: Set<string>
16  isManyFilesFlagged: boolean
17  allowedPaths: Set<string>
18  allowedFolders: Set<string>
19  narration: string
20  flags: Flag[]
21  nextFlagId: number
22  isWorking: boolean
23}
24
25function initialState(): ScopeState {
26  return {
27    root: '',
28    settings: { mode: 'ask', confirmModel: 'claude-sonnet-5-5', manyFiles: 8 },
29    mode: 'ask',
30    prompts: [],
31    seenPaths: new Set(),
32    editedPaths: new Set(),
33    isManyFilesFlagged: false,
34    allowedPaths: new Set(),
35    allowedFolders: new Set(),
36    narration: '',
37    flags: [],
38    nextFlagId: 1,
39    isWorking: false,
40  }
41}
42
43let scope = initialState()
44
45export function resetScope(): void {
46  scope = initialState()
47}
48
49export function scopeView(): Readonly<ScopeState> {
50  return scope
51}
52
53export function configureScope(settings: ScopeSettings): void {
54  scope.settings = settings
55  scope.mode = settings.mode
56}
57
58export function setRoot(root: string): void {
59  scope.root = root
60}
61
62export function setMode(mode: ScopeMode): void {
63  scope.mode = mode
64}
65
66export function taskText(): string {
67  return scope.prompts.join('\n\n')
68}
69
70export function recordPrompt(options: { text: string; isNewTask: boolean }): void {
71  if (options.isNewTask) {
72    scope.allowedPaths = new Set()
73    scope.allowedFolders = new Set()
74    scope.flags = scope.flags.map(flag => (flag.status === 'open' ? { ...flag, status: 'dropped' } : flag))
75  }
76  scope.prompts = [...scope.prompts, options.text.trim()].slice(-KEPT_PROMPTS)
77}
78
79export function startTurn(): void {
80  scope.seenPaths = new Set()
81  scope.editedPaths = new Set()
82  scope.isManyFilesFlagged = false
83  scope.narration = ''
84  scope.isWorking = true
85}
86
87export function endTurn(): void {
88  scope.isWorking = false
89}
90
91export function noteNarration(text: string): void {
92  scope.narration = (scope.narration + text).slice(-NARRATION_CHARACTERS)
93}
94
95export function noteSeen(text: string): void {
96  pathTokens(text.slice(0, SCANNED_OUTPUT_CHARACTERS)).forEach(token => {
97    scope.seenPaths.add(relativeTo({ path: token, root: scope.root }))
98  })
99}
100
101export function isSeen(path: string): boolean {
102  return scope.seenPaths.has(path) || [...scope.seenPaths].some(seen => seen.endsWith(`/${path}`) || baseName(seen) === baseName(path))
103}
104
105export function noteEdited(paths: readonly string[]): void {
106  paths.forEach(path => scope.editedPaths.add(relativeTo({ path, root: scope.root })))
107}
108
109export function markManyFilesFlagged(): void {
110  scope.isManyFilesFlagged = true
111}
112
113export function isAllowed(path: string): boolean {
114  return scope.allowedPaths.has(path) || [...scope.allowedFolders].some(folder => isUnderFolder({ path, folder }))
115}
116
117export function allowPath(path: string): void {
118  scope.allowedPaths.add(path)
119}
120
121export function allowFolder(folder: string): void {
122  scope.allowedFolders.add(folder)
123}
124
125export function addFlag(options: Omit<Flag, 'id'>): Flag {
126  const flag = { ...options, id: scope.nextFlagId }
127  scope.nextFlagId += 1
128  scope.flags = [...scope.flags, flag].slice(-KEPT_FLAGS)
129  return flag
130}
131
132export function openFlags(): Flag[] {
133  return scope.flags.filter(flag => flag.status === 'open')
134}
135
136export function resolveFlag(options: { id: number; status: FlagStatus }): void {
137  scope.flags = scope.flags.map(flag => (flag.id === options.id ? { ...flag, status: options.status } : flag))
138}
139
140export function resolveFolder(folder: string): void {
141  scope.flags = scope.flags.map(flag =>
142    flag.status === 'open' && isUnderFolder({ path: flag.path, folder }) ? { ...flag, status: 'allowed' } : flag,
143  )
144}
145
hooks/scope/types.ts 38 lines
1export type ScopeMode = 'ask' | 'flag' | 'off'
2
3export type Verdict = 'in-scope' | 'mild' | 'clear'
4
5export type SignalKind = 'unmentioned' | 'outside' | 'delete' | 'move' | 'many-files' | 'rewrite'
6
7export type Signal = { kind: SignalKind; weight: number; text: string }
8
9export type ChangeKind = 'edit' | 'create' | 'write' | 'delete' | 'move'
10
11export type Change = {
12  kind: ChangeKind
13  tool: string
14  paths: readonly string[]
15  removedLines: number
16  replacedShare: number
17  originalLines: number
18}
19
20export type Assessment = { signals: readonly Signal[]; score: number }
21
22export type Decision = { verdict: Verdict; reason: string; isConfirmed: boolean }
23
24export type FlagStatus = 'open' | 'pulled' | 'fine' | 'allowed' | 'stopped' | 'asked' | 'dropped'
25
26export type Flag = {
27  id: number
28  path: string
29  folder: string
30  action: string
31  reason: string
32  status: FlagStatus
33}
34
35export type ScopeSettings = { mode: ScopeMode; confirmModel: string; manyFiles: number }
36
37export type BandChoice = 'pull' | 'fine' | 'folder'
38
hooks/scope/confirm.ts 48 lines
1import type { Change, Signal, Verdict } from './types'
2
3const VERDICTS: readonly Verdict[] = ['in-scope', 'mild', 'clear']
4
5export const CONFIRM_SYSTEM = [
6  'You check whether one file change by a coding agent stays inside what the user asked for.',
7  'Reply with JSON only: {"verdict": "in-scope" | "mild" | "clear", "reason": "<one short sentence for the user>"}.',
8  'in-scope: the request needs the change, or the user plainly invited it.',
9  'mild: related to the request but beyond it, for example a tidy-up or an extra file the user may accept.',
10  'clear: unrelated to the request, or destructive (a delete, a large rewrite, a file outside the project) without the user asking.',
11  'Write the reason in plain words, 20 words or fewer, without dashes.',
12].join('\n')
13
14export function confirmPrompt(options: {
15  task: string
16  narration: string
17  change: Change
18  action: string
19  signals: readonly Signal[]
20}): string {
21  const { change } = options
22  return [
23    `User request:\n${options.task.slice(-2000) || '(none recorded)'}`,
24    `What Claude said just before the change:\n${options.narration.slice(-800) || '(nothing)'}`,
25    `The change: ${change.tool} will ${options.action} ${change.paths.join(', ')}.`,
26    `Signals the cheap rules found: ${options.signals.map(signal => signal.text).join('; ')}.`,
27  ].join('\n\n')
28}
29
30export function parseVerdict(text: string): { verdict: Verdict; reason: string } | undefined {
31  const match = text.match(/\{[\s\S]*\}/)
32  if (!match) {
33    return undefined
34  }
35  let parsed: unknown
36  try {
37    parsed = JSON.parse(match[0])
38  } catch {
39    return undefined
40  }
41  if (typeof parsed !== 'object' || parsed === null || !('verdict' in parsed)) {
42    return undefined
43  }
44  const verdict = VERDICTS.find(candidate => candidate === parsed.verdict)
45  const reason = 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason.trim().replace(/[\u2013\u2014]/g, ',') : ''
46  return verdict ? { verdict, reason } : undefined
47}
48
hooks/scope/paths.ts 66 lines
1const TEMPORARY_ROOTS = ['/tmp/', '/private/tmp/', '/var/folders/']
2const GENERIC_STEMS = new Set(['index', 'main', 'mod', 'lib', 'src', 'test', 'tests', 'utils', 'readme'])
3const PATH_TOKEN = /[A-Za-z0-9_@~+.\-/]+/g
4
5export function relativeTo(options: { path: string; root: string }): string {
6  const trimmed = options.path.replace(/^\.\//, '')
7  const prefix = options.root.endsWith('/') ? options.root : `${options.root}/`
8  return options.root !== '' && trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed
9}
10
11export function folderOf(path: string): string {
12  const slash = path.lastIndexOf('/')
13  return slash <= 0 ? '.' : path.slice(0, slash)
14}
15
16export function baseName(path: string): string {
17  return path.slice(path.lastIndexOf('/') + 1)
18}
19
20function stemOf(path: string): string {
21  const base = baseName(path)
22  const dot = base.indexOf('.', 1)
23  return dot === -1 ? base : base.slice(0, dot)
24}
25
26export function isTemporary(path: string): boolean {
27  return TEMPORARY_ROOTS.some(temporary => path.startsWith(temporary))
28}
29
30export function isOutside(options: { path: string; root: string }): boolean {
31  const { path, root } = options
32  if (path.startsWith('..')) {
33    return true
34  }
35  if (!path.startsWith('/') || root === '') {
36    return false
37  }
38  const isUnderRoot = path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
39  return !isUnderRoot && !isTemporary(path)
40}
41
42export function isMentioned(options: { path: string; text: string }): boolean {
43  const text = options.text.toLowerCase()
44  const path = options.path.toLowerCase()
45  const base = baseName(path)
46  const stem = stemOf(path)
47  if (text.includes(path) || text.includes(base)) {
48    return true
49  }
50  return stem.length >= 3 && !GENERIC_STEMS.has(stem) && new RegExp(`\\b${stem.replace(/[^a-z0-9]/g, '.')}\\b`).test(text)
51}
52
53export function isUnderFolder(options: { path: string; folder: string }): boolean {
54  return options.folder === '.' ? !options.path.includes('/') : options.path === options.folder || options.path.startsWith(`${options.folder}/`)
55}
56
57export function pathTokens(text: string): string[] {
58  return (text.match(PATH_TOKEN) ?? [])
59    .map(token => token.replace(/^\.\//, '').replace(/[.\-/]+$/, ''))
60    .filter(token => token.includes('/') || /\.[A-Za-z0-9]{1,8}$/.test(token))
61}
62
63export function lineCount(text: string): number {
64  return text === '' ? 0 : text.split('\n').length
65}
66
hooks/scope/rules.ts 116 lines
1import { isMentioned, isOutside, isTemporary, lineCount, relativeTo } from './paths'
2import type { Assessment, Change, ChangeKind, Signal, Verdict } from './types'
3
4export const CONFIRM_SCORE = 2
5const CLEAR_SCORE = 4
6const LARGE_EDIT_LINES = 40
7const REWRITE_SHARE = 0.6
8const REWRITE_MINIMUM_LINES = 20
9const FILE_COMMAND = /(?:^|[;&|(]\s*|\s)(?:sudo\s+)?(git\s+)?(rm|mv)\s+([^;&|]*)/g
10
11export type RuleContext = {
12  root: string
13  task: string
14  isSeen: (path: string) => boolean
15  isAllowed: (path: string) => boolean
16  editedPaths: ReadonlySet<string>
17  isManyFilesFlagged: boolean
18  manyFiles: number
19}
20
21function changeFor(options: { kind: ChangeKind; tool: string; paths: readonly string[] }): Change {
22  return { ...options, removedLines: 0, replacedShare: 0, originalLines: 0 }
23}
24
25export function editChange(options: { filePath: string; oldText: string; newText: string }): Change {
26  const removedLines = Math.max(0, lineCount(options.oldText) - lineCount(options.newText))
27  return { ...changeFor({ kind: 'edit', tool: 'Edit', paths: [options.filePath] }), removedLines }
28}
29
30export function notebookChange(options: { filePath: string; mode: string | undefined }): Change {
31  return changeFor({ kind: options.mode === 'delete' ? 'delete' : 'edit', tool: 'NotebookEdit', paths: [options.filePath] })
32}
33
34export function writeChange(options: { filePath: string; content: string; original: string | undefined }): Change {
35  if (options.original === undefined) {
36    return changeFor({ kind: 'create', tool: 'Write', paths: [options.filePath] })
37  }
38  const kept = new Set(options.content.split('\n').map(line => line.trim()))
39  const originalLines = options.original.split('\n').map(line => line.trim()).filter(line => line !== '')
40  const replaced = originalLines.filter(line => !kept.has(line)).length
41  return {
42    ...changeFor({ kind: 'write', tool: 'Write', paths: [options.filePath] }),
43    originalLines: originalLines.length,
44    replacedShare: originalLines.length === 0 ? 0 : replaced / originalLines.length,
45  }
46}
47
48export function commandChange(command: string): Change | undefined {
49  const matches = [...command.matchAll(FILE_COMMAND)]
50  const paths = matches.flatMap(match => (match[3] ?? '').split(/\s+/).filter(word => word !== '' && !word.startsWith('-')))
51  if (paths.length === 0) {
52    return undefined
53  }
54  const isDelete = matches.some(match => match[2] === 'rm')
55  const named = paths.map(path => path.replace(/^['"]|['"]$/g, '')).filter(path => !isTemporary(path))
56  return named.length === 0 ? undefined : changeFor({ kind: isDelete ? 'delete' : 'move', tool: 'Bash', paths: named })
57}
58
59function pathSignals(options: { change: Change; context: RuleContext }): Signal[] {
60  const { change, context } = options
61  return change.paths.flatMap((path): Signal[] => {
62    const relative = relativeTo({ path, root: context.root })
63    if (isTemporary(path) || context.isAllowed(relative) || context.editedPaths.has(relative)) {
64      return []
65    }
66    if (isOutside({ path, root: context.root })) {
67      return [{ kind: 'outside', weight: 3, text: `${relative} is outside the project` }]
68    }
69    if (isMentioned({ path: relative, text: context.task })) {
70      return []
71    }
72    const isSeen = context.isSeen(relative)
73    return [{ kind: 'unmentioned', weight: isSeen ? 1 : 2, text: isSeen ? `your request does not mention ${relative}` : `your request does not mention ${relative}, and Claude did not read or search it` }]
74  })
75}
76
77function kindSignals(options: { change: Change; context: RuleContext }): Signal[] {
78  const { change, context } = options
79  const signals: Signal[] = []
80  if (change.kind === 'delete') {
81    signals.push({ kind: 'delete', weight: 3, text: `it deletes ${change.paths.join(', ')}` })
82  }
83  if (change.kind === 'move') {
84    signals.push({ kind: 'move', weight: 2, text: `it moves ${change.paths.join(', ')}` })
85  }
86  const isRewrite = change.kind === 'write' && change.originalLines >= REWRITE_MINIMUM_LINES && change.replacedShare >= REWRITE_SHARE
87  if (isRewrite) {
88    signals.push({ kind: 'rewrite', weight: 2, text: `it rewrites ${Math.round(change.replacedShare * 100)}% of a ${change.originalLines}-line file` })
89  }
90  if (change.kind === 'edit' && change.removedLines >= LARGE_EDIT_LINES) {
91    signals.push({ kind: 'rewrite', weight: 2, text: `it removes ${change.removedLines} lines` })
92  }
93  const fresh = change.paths.map(path => relativeTo({ path, root: context.root })).filter(path => !context.editedPaths.has(path))
94  if (!context.isManyFilesFlagged && fresh.length > 0 && context.editedPaths.size + fresh.length >= context.manyFiles) {
95    signals.push({ kind: 'many-files', weight: 2, text: `it is file ${context.editedPaths.size + fresh.length} Claude changed this turn` })
96  }
97  return signals
98}
99
100export function assess(options: { change: Change; context: RuleContext }): Assessment {
101  const signals = [...pathSignals(options), ...kindSignals(options)]
102  return { signals, score: signals.reduce((total, signal) => total + signal.weight, 0) }
103}
104
105export function rulesVerdict(score: number): Verdict {
106  if (score >= CLEAR_SCORE) {
107    return 'clear'
108  }
109  return score >= CONFIRM_SCORE ? 'mild' : 'in-scope'
110}
111
112export function actionOf(change: Change): string {
113  const verbs: Record<ChangeKind, string> = { edit: 'edit', create: 'create', write: 'rewrite', delete: 'delete', move: 'move' }
114  return verbs[change.kind]
115}
116