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…

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.

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.
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:
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.
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).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
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
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.
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.
hooks/register.ts 77 lines1import 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}
77hooks/band.tsx 102 lines1import 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}
102hooks/guard.ts 190 lines1import 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}
190hooks/scope/messages.ts 71 lines1import 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}
71hooks/scope/state.ts 145 lines1import { 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}
145hooks/scope/types.ts 38 lines1export 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'
38hooks/scope/confirm.ts 48 lines1import 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}
48hooks/scope/paths.ts 66 lines1const 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}
66hooks/scope/rules.ts 116 lines1import { 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