Shows you, above the prompt, the critical decisions the agent made without being asked: red to rethink, yellow to know, each with a rethink button. Works out…

A Claude Code mod that keeps you in the loop when the agent makes a call you never asked for. It works for any kind of project: code, an article, a book, a dataset.
record_decision with the decision, the reason and the alternative it ruled out.⚠️ Critical decisions: 1 to rethink · 1 to know [ok] (or /critical-ok)
[14:40] I made orders.status nullable; it was required before. — old rows have no status ← red
[rethink] [not critical] (migrations)
[14:32] I rounded tax to 2 decimals, which you had not specified. — the invoice shows 2 ← yellow
[rethink] [not critical] (pricing)
At the prompt of a Claude Code terminal session:
/plugin install critical-decisions --marketplace Mucida/critical-decisions
From a shell:
claude plugin install critical-decisions --marketplace Mucida/critical-decisions
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with Mucida/critical-decisions, then Add plugin.
It works without any setup. The agent is told to record a decision only when you would likely want to undo or rethink it if you saw it, and, when in doubt, not to record. That holds when it made the decision on its own and it:
CLAUDE.md);A choice you left to the agent ("you decide") still counts when it changes the result: you delegated it, so you get told what it picked.
Naming, wording, formatting, refactors that keep behaviour, steps that follow straight from your request and choices that follow the project's conventions are not critical. Related choices go in one record.
For example, you ask for a paragraph on wind power and the agent brings in solar panels as a comparison:
⚠️ Critical decisions: 1 to rethink [ok] (or /critical-ok)
[10:05] I added a comparison with solar panels; you asked only about wind power. — it gives a reference point
[rethink] [not critical] (general)
If it records something you do not care about, press not critical. The mod keeps the last 10 such records per project and shows them to the agent as examples of what not to record.
The first time it runs in a project, a line above the prompt says the default protection is on and how to make it yours.
The agent writes each decision as a plain sentence in your language, saying it was its own call and what it replaced. It judges by itself whether a decision is critical: the mod gives it the criteria and shows you what it records. It does not run a second model over the agent's work, so a decision the agent does not see as critical can go unrecorded. Marking areas (below) narrows that gap.
Not sure what to write? Let the agent propose it:
/critical-areas auto
The agent uses what it already knows of the project, its instructions included, saves up to 6 areas and shows them to you. /critical-areas clear undoes it.
Or tell it what is critical for you, in your own words and in any language:
/critical-areas pricing and tax rules, anything in src/billing, the orders.status flow, SQL migrations
A small model (Haiku) turns the text into areas, each with keywords and path globs, and shows you the result:
Critical in this project: "pricing and tax rules, anything in src/billing, ..."
• pricing — keywords: unit_price, tax_rate, discount · paths: src/billing/**
• order_status — keywords: orders.status, OrderStatus
• migrations — paths: **/*.sql, migrations/
A writing project works the same way:
/critical-areas no topic or source I did not ask for, nothing published without me, anything in drafts/final
Your areas add to the default protection; they do not replace it. Naming real identifiers, files or folders gives sharper rules. Run /critical-areas again with new text to replace the rules.
The configuration is saved per project directory and kept across sessions.
| Command | What it does |
|---|---|
/critical-areas auto | The agent proposes the areas and saves them |
/critical-areas <text> | Describe what is critical; replaces the current rules |
/critical-areas | Show the current rules and what was marked as not critical |
/critical-areas clear | Remove the rules and the not-critical examples (limpar works too) |
/critical-list | List every decision recorded in this session |
/critical-ok | Dismiss the band (same as its ok button) |
Edit, Write, MultiEdit and NotebookEdit calls, shell commands and MCP tool calls.It warns; it never blocks an edit.
/critical-areas <text>, through your own Claude Code session. /critical-areas auto and rethink send one message to your own agent, only when you ask. Nothing else is sent anywhere.claude plugin validate .
claude plugin test .
See CHANGELOG.md. Bugs and ideas: issues.
MIT
hooks/register.tsx 494 lines1import type { Register } from 'claude-code'
2
3const PLUGIN = 'critical-decisions'
4const DEFAULT_TOOL = `mcp__${PLUGIN}__record_decision`
5const SAVE_TOOL = `mcp__${PLUGIN}__save_critical_areas`
6
7// What the person marked as critical in this project, in their words and as
8// the areas, keywords and paths extracted from them.
9export type Area = { name: string; keywords: string[]; paths: string[] }
10export type Config = { description: string; areas: Area[] }
11// undo: the person may want to take it back (red). know: worth knowing (yellow).
12export type Level = 'undo' | 'know'
13type Decision = { id: number; time: string; level: Level; area: string; decision: string; reason: string; alternative: string }
14// Records the person marked as not critical: shown to the agent so it stops recording their like.
15type Learned = { area: string; decision: string }
16const LEARNED_MAX = 10
17
18// Edits are matched by path and keyword. Shell commands and MCP tools (publishing,
19// sending, writing to a database) by keyword in what they were given.
20const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
21const BAND_ROWS = 3
22const CLEAR = new Set(['clear', 'reset', 'limpar', 'apagar', 'borrar'])
23
24const storeKey = (cwd: string) => `config:${cwd}`
25const noticeKey = (cwd: string) => `notice-seen:${cwd}`
26const learnedKey = (cwd: string) => `learned:${cwd}`
27
28function escapeRe(s: string): string {
29 return s.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
30}
31
32// `src/billing/**`, `*.sql`, `migrations/` → a regex tested against the path.
33export function globToRegExp(glob: string): RegExp {
34 let g = glob.trim().replace(/^\.\//, '')
35 if (g.endsWith('/')) g += '**'
36 const body = g
37 .split('**')
38 .map(part => part.split('*').map(escapeRe).join('[^/]*'))
39 .join('.*')
40 return new RegExp(`(^|/)${body}$`, 'i')
41}
42
43export function keywordRegExp(word: string): RegExp {
44 const w = escapeRe(word.trim())
45 const start = /^\w/.test(word.trim()) ? '\\b' : ''
46 return new RegExp(`${start}${w}`, 'i')
47}
48
49function editedText(input: Record<string, unknown>): string {
50 const parts: string[] = []
51 for (const k of ['new_string', 'content', 'new_source']) if (typeof input[k] === 'string') parts.push(input[k] as string)
52 if (Array.isArray(input.edits))
53 for (const ed of input.edits as Record<string, unknown>[]) if (typeof ed?.new_string === 'string') parts.push(ed.new_string)
54 return parts.join('\n')
55}
56
57function watchedText(tool: string, input: Record<string, unknown>): string | undefined {
58 if (EDIT_TOOLS.has(tool)) return editedText(input)
59 if (tool === 'Bash') return String(input.command ?? '')
60 if (tool.startsWith('mcp__') && !tool.startsWith(`mcp__${PLUGIN}__`)) {
61 const { tool: _t, ...rest } = input
62 return JSON.stringify(rest)
63 }
64 return undefined
65}
66
67export function touchedAreas(config: Config | undefined, tool: string, input: Record<string, unknown>): string[] {
68 if (!config) return []
69 const text = watchedText(tool, input)
70 if (text === undefined) return []
71 const path = EDIT_TOOLS.has(tool) ? String(input.file_path ?? input.notebook_path ?? '') : ''
72 const hits: string[] = []
73 for (const a of config.areas) {
74 const byPath = path !== '' && a.paths.some(p => p && globToRegExp(p).test(path))
75 const byWord = text !== '' && a.keywords.some(k => k && keywordRegExp(k).test(text))
76 if (byPath || byWord) hits.push(a.name)
77 }
78 return hits
79}
80
81// Turns the person's free text into areas. The model only proposes; the
82// person sees the result and can run /critical-areas again.
83const EXTRACT_SYSTEM = `You turn a person's description of what is critical in their project into detection rules for an AI agent working on it. The project may be code, writing (articles, books, docs), data or anything else.
84Answer with JSON only, no prose, no code fence:
85{"areas":[{"name":"short_snake_case","keywords":["identifier or term"],"paths":["glob"]}]}
86- 1 to 8 areas.
87- keywords: identifiers, names, commands or domain terms likely to appear in the text, code or commands that touch the area (e.g. "unit_price", "git push", "publish"). Prefer specific terms over common words. Up to 12 per area.
88- paths: globs relative to the project root (e.g. "src/billing/**", "**/*.sql", "drafts/"). Only when the description names or implies files or folders; otherwise [].
89- The description may be in any language. Area names in English snake_case; keywords as they would appear in the work: identifiers exactly as written, domain terms in the description's language and, when the work is likely in English, their English form too.
90- Never invent identifiers the description gives no hint of.`
91
92// Areas from the model's JSON or from save_critical_areas' input; undefined when none is usable.
93export function areasFrom(list: unknown): Area[] | undefined {
94 if (!Array.isArray(list)) return undefined
95 const strs = (v: unknown) => (Array.isArray(v) ? v.filter(x => typeof x === 'string' && x.trim()).map(x => (x as string).trim()) : [])
96 const areas = list
97 .map((a: any) => ({ name: String(a?.name ?? '').trim(), keywords: strs(a?.keywords), paths: strs(a?.paths) }))
98 .filter(a => a.name && (a.keywords.length || a.paths.length))
99 return areas.length ? areas : undefined
100}
101
102export function parseAreas(text: string): Area[] | undefined {
103 const m = text.match(/\{[\s\S]*\}/)
104 if (!m) return undefined
105 try {
106 return areasFrom((JSON.parse(m[0]) as { areas?: unknown }).areas)
107 } catch {
108 return undefined
109 }
110}
111
112
113function describeConfig(c: Config | undefined, learned: Learned[]): string {
114 const lines = c
115 ? [
116 `Critical in this project: "${c.description}"`,
117 ...c.areas.map(a => {
118 const bits = [a.keywords.length ? `keywords: ${a.keywords.join(', ')}` : '', a.paths.length ? `paths: ${a.paths.join(', ')}` : '']
119 return `• ${a.name} — ${bits.filter(Boolean).join(' · ')}`
120 }),
121 '',
122 '/critical-areas <text> or /critical-areas auto replaces it · /critical-areas clear removes it',
123 ]
124 : [
125 'Default protection: the agent records decisions it makes without being asked that you would likely want to undo or rethink.',
126 'Nothing specific to this project yet. To make it yours:',
127 ' /critical-areas auto the agent proposes areas from what it knows of this project',
128 ' /critical-areas <your words>, e.g. /critical-areas pricing and tax rules, anything in src/billing',
129 ' /critical-areas no topic or source I did not ask for, nothing published without me',
130 ]
131 if (learned.length) lines.push('', `Marked as not critical (${learned.length}/${LEARNED_MAX}):`, ...learned.map(l => `• (${l.area}) ${l.decision}`))
132 return lines.join('\n')
133}
134
135// Always in the system prompt: what counts as critical by default, in any kind of
136// project. The working agent judges against it; the mod reads no files and calls no model.
137const DEFAULT_CRITERIA = [
138 'Record a decision only when the user would likely want to undo or rethink it if they saw it. When in doubt, do not record. Usually a task has none or one.',
139 'It qualifies when you made it on your own, without the user asking for it literally, and it:',
140 '- adds, removes or swaps something the user did not ask for: a topic, source, claim, comparison, feature, dependency or step;',
141 '- picks a value, rule, formula, default, structure or schema the user did not specify, where another reasonable choice existed;',
142 "- departs from or works around the user's instructions, or the project's own instructions you were given (such as CLAUDE.md);",
143 '- is hard to undo or reaches outside this session: deleting, overwriting, publishing, sending, deploying, migrating data, spending money;',
144 '- fills a gap in the request with an assumption that changes the result.',
145 'A choice the user left to you ("you decide", "pick one") still qualifies when it changes the result: they delegated it, so tell them what you picked.',
146 "Not critical: naming, wording and formatting, refactors that keep behaviour, steps that follow directly from the request, and choices that follow the project's conventions.",
147 'Group related choices into one record. Judge what matters with what you know of this project, its instructions included.',
148 'level "undo" when the user may well want it taken back (it changes scope, a rule or the result, or is hard to undo); "know" when it is a reasonable choice they should just be aware of.',
149].join('\n')
150
151export function systemSection(c: Config | undefined, learned: Learned[] = []): string {
152 const marked = c
153 ? [
154 `The user also marked these as critical in this project: "${c.description}".`,
155 `Areas: ${c.areas.map(a => a.name).join(', ')}. A decision of yours that touches them qualifies even when small.`,
156 ]
157 : ['The user has not said what is critical in this project, so these defaults are all there is.']
158 const taught = learned.length
159 ? ['The user marked these earlier records as not critical; do not record their like:', ...learned.map(l => `- (${l.area}) ${l.decision}`)]
160 : []
161 return [
162 '# Critical decisions',
163 DEFAULT_CRITERIA,
164 ...marked,
165 ...taught,
166 'When a decision qualifies, call the record_decision tool with it, the reason, the alternative you ruled out and its level, before your final reply, then mention it in the reply. Listing it only in your reply is not enough: the user sees what you record.',
167 ].join('\n')
168}
169
170// After the first write of a turn with nothing recorded: where a decision is made, the
171// agent tends to tell it in prose instead of recording it. One short line per turn at most.
172function writeNudge(tool: string): string {
173 return `[${PLUGIN}] If what you just wrote carries a choice the user did not make (a value, rule, scope or assumption that changes the result, including one they left to you), call ${tool} now, before your reply; a decision told only in prose does not reach the user's band. Otherwise ignore this.`
174}
175
176function reminder(areas: string[], tool: string): string {
177 return `[${PLUGIN}] You touched what the user marked as critical (${areas.join(', ')}). If this was your own decision, not something the user literally asked for, call ${tool} with the decision, the reason and the alternative you ruled out, and say so in your reply. If it was asked for literally, carry on without recording.`
178}
179
180const LEVEL_LABEL: Record<Level, string> = { undo: 'to rethink', know: 'to know' }
181
182function report(list: Decision[]): string {
183 if (!list.length) return 'No critical decisions recorded in this session.'
184 return list
185 .map((d, i) => `${i + 1}. [${d.time}] ${LEVEL_LABEL[d.level]} (${d.area}) ${d.decision}\n because: ${d.reason}\n ruled out: ${d.alternative || '—'}`)
186 .join('\n')
187}
188
189// Red first, newest first within each level.
190export function bandOrder<T extends { level: Level }>(list: T[]): T[] {
191 const newest = [...list].reverse()
192 return [...newest.filter(d => d.level === 'undo'), ...newest.filter(d => d.level === 'know')]
193}
194
195function rethinkPrompt(d: Decision): string {
196 return `Rethink this decision you made: "${d.decision}" (because ${d.reason}). Undo it if you can, propose other ways, and ask me before picking one.`
197}
198
199const AUTO_PROMPT = `Based on what you already know of this project and its instructions, propose up to 6 areas where a decision you make on your own would be critical for me (rules, money, data, publishing, scope or anything this project treats as important). For each: a short snake_case name, keywords as they appear in the work, and path globs when it lives in specific files. Save them with the save_critical_areas tool (load it with ToolSearch if it is not listed), with a one-line description in my language. Then show me the areas and tell me /critical-areas clear undoes it. Do not read new files just for this.`
200
201export const register: Register = on => {
202 let config: Config | undefined
203 let cwd = ''
204 let toolName = DEFAULT_TOOL
205 let nextId = 1
206 const decisions: Decision[] = []
207 let pending: Decision[] = [] // on the band until the person dismisses them
208 let learned: Learned[] = []
209 let notice = false // the first-use line: default protection, how to make it yours
210 // true when the notice was up: the caller saves that it was seen
211 const dismissNotice = () => {
212 const was = notice
213 notice = false
214 return was
215 }
216 const warned = new Set<string>() // per turn
217 let nudged = false // per turn: the write nudge went out, or a decision was recorded
218
219 on('session.start', async ($, e, next) => {
220 const r = await next(e)
221 cwd = e.cwd
222 config = ((await $.store.get(storeKey(cwd))) as Config | undefined) ?? undefined
223 learned = ((await $.store.get(learnedKey(cwd))) as Learned[] | undefined) ?? []
224 notice = !config && !(await $.store.get(noticeKey(cwd)))
225 const reg = (await $.tool.register({
226 name: 'record_decision',
227 description:
228 'Record a critical decision you made on your own, mid-task, that the user did not literally ask for (see "Critical decisions" in the system prompt). The user sees it right away above the prompt. Call it once per decision, then mention the decision in your reply.',
229 isDeferred: false,
230 inputSchema: {
231 type: 'object',
232 properties: {
233 area: {
234 type: 'string',
235 description: 'One of the areas the user marked as critical, or "general" when only the default criteria apply. Example: "pricing".',
236 },
237 decision: {
238 type: 'string',
239 description:
240 'One plain sentence for the user, in the language they write in, making clear it was your call and what it replaced: something left open, or a plan you changed. Examples: "I picked a 40% margin myself, since you did not give one." / "I closed access on every table, not just the 14 in the report."',
241 },
242 reason: {
243 type: 'string',
244 description: 'Why, in a few words, in the same language; shown right after the decision. Example: "it is common in 3D printing and covers machine wear".',
245 },
246 alternative_ruled_out: {
247 type: 'string',
248 description:
249 'What the user asked for, or what you would otherwise have done. Listed by /critical-list. Example: "only the paragraph on wind power, as asked".',
250 },
251 level: {
252 type: 'string',
253 enum: ['undo', 'know'],
254 description:
255 '"undo" when the user may well want it taken back (it changes scope, a rule or the result, or is hard to undo); "know" when it is a reasonable choice they should be aware of. Default "know".',
256 },
257 },
258 required: ['area', 'decision', 'reason'],
259 },
260 })) as { tool?: string } | undefined
261 if (reg?.tool) toolName = reg.tool // the name the agent sees; DEFAULT_TOOL unless the plugin is renamed
262 await $.tool.register({
263 name: 'save_critical_areas',
264 description:
265 'Save the areas that are critical in this project, replacing the current ones. Use it only when the user asked for it (/critical-areas auto).',
266 isDeferred: true,
267 inputSchema: {
268 type: 'object',
269 properties: {
270 description: { type: 'string', description: 'One line, in the user\'s language. Example: "preços e impostos, migrations, publicação".' },
271 areas: {
272 type: 'array',
273 description: '1 to 6 areas.',
274 items: {
275 type: 'object',
276 properties: {
277 name: { type: 'string', description: 'snake_case. Example: "pricing".' },
278 keywords: { type: 'array', items: { type: 'string' }, description: 'Terms as they appear in the work. Example: ["unit_price", "tax_rate"].' },
279 paths: { type: 'array', items: { type: 'string' }, description: 'Globs from the project root, or []. Example: ["src/billing/**"].' },
280 },
281 required: ['name', 'keywords', 'paths'],
282 },
283 },
284 },
285 required: ['description', 'areas'],
286 },
287 })
288 await $.command.register({
289 name: 'critical-areas',
290 description: 'Say what is critical in this project ("auto": the agent proposes it; no text: show it; "clear": remove it)',
291 })
292 await $.command.register({ name: 'critical-list', description: 'List the critical decisions recorded in this session' })
293 await $.command.register({ name: 'critical-ok', description: 'Dismiss the critical decisions shown above the prompt' })
294 return r
295 })
296
297 on('command.run', { command: 'critical-areas' }, async ($, e) => {
298 const args = e.args.trim()
299 if (!args) return { text: describeConfig(config, learned) }
300 if (CLEAR.has(args.toLowerCase())) {
301 config = undefined
302 learned = []
303 await $.store.delete(storeKey(cwd))
304 await $.store.delete(learnedKey(cwd))
305 return { text: 'Removed, along with what was marked as not critical. Only the default protection is on in this project now.' }
306 }
307 if (args.toLowerCase() === 'auto') {
308 // A command's hook holds the turn: submit once it has returned.
309 $.clock.after(1, () => void $.prompt.submit({ text: AUTO_PROMPT, asUser: true }))
310 return { text: 'Asking the agent to propose the critical areas of this project.' }
311 }
312 const r = await $.model.complete({ model: 'haiku', system: EXTRACT_SYSTEM, prompt: args, maxTokens: 1500, timeoutMs: 30000 })
313 const areas = r.isAnswered ? parseAreas(r.text) : undefined
314 if (!areas) {
315 const why = r.isAnswered ? 'the answer could not be read as areas' : `the model call failed (${r.reason})`
316 return { text: `Nothing saved: ${why}. Try again, naming files, folders or identifiers, e.g. /critical-areas price and tax in src/billing/**, the orders.status column` }
317 }
318 config = { description: args, areas }
319 await $.store.set(storeKey(cwd), config)
320 if (dismissNotice()) await $.store.set(noticeKey(cwd), true)
321 $.ui.invalidate('ui.render')
322 return { text: `Saved for ${cwd}.\n\n${describeConfig(config, learned)}` }
323 })
324
325 on('prompt.compose', async (_$, e, next) => {
326 const r = await next(e)
327 return { ...r, sections: [...r.sections, { id: `${PLUGIN}:rules`, text: systemSection(config, learned), scope: 'session' as const }] }
328 })
329
330 on('command.run', { command: 'critical-list' }, () => ({ text: report(decisions) }))
331
332 on('command.run', { command: 'critical-ok' }, async $ => {
333 const n = pending.length
334 pending = []
335 if (dismissNotice()) await $.store.set(noticeKey(cwd), true)
336 $.ui.invalidate('ui.render')
337 return { text: n ? `${n} decision(s) dismissed. /critical-list still lists them all.` : 'Nothing to dismiss.' }
338 })
339
340 // The band stays until dismissed: a toast goes away by itself, this does not.
341 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
342 if (e.props.hasSurvey || (pending.length === 0 && !notice)) return next(e)
343 const { Box, Text, Button } = $.ui.resolve(e)
344 if (pending.length === 0)
345 return (
346 <Box flexDirection="column">
347 {await next(e)}
348 <Box>
349 <Text color="yellow">🛡 critical-decisions: default protection is on. </Text>
350 <Text dimColor>To make it yours: /critical-areas auto, or /critical-areas <your words> </Text>
351 <Button
352 key="ok"
353 label="ok"
354 role="dismiss"
355 onPress={async () => {
356 if (dismissNotice()) await $.store.set(noticeKey(cwd), true)
357 $.ui.invalidate('ui.render')
358 }}
359 />
360 </Box>
361 </Box>
362 )
363 const ordered = bandOrder(pending)
364 const shown = ordered.slice(0, BAND_ROWS)
365 const hidden = ordered.length - shown.length
366 const toUndo = pending.filter(d => d.level === 'undo').length
367 const toKnow = pending.length - toUndo
368 const counts = [toUndo ? `${toUndo} ${LEVEL_LABEL.undo}` : '', toKnow ? `${toKnow} ${LEVEL_LABEL.know}` : ''].filter(Boolean).join(' · ')
369 const drop = (id: number) => {
370 pending = pending.filter(d => d.id !== id)
371 $.ui.invalidate('ui.render')
372 }
373 return (
374 <Box flexDirection="column">
375 {await next(e)}
376 <Box>
377 <Text color={toUndo ? 'red' : 'yellow'} bold>⚠️ Critical decisions: {counts} </Text>
378 <Button
379 key="ok"
380 label="ok"
381 role="dismiss"
382 onPress={() => {
383 pending = []
384 $.ui.invalidate('ui.render')
385 }}
386 />
387 <Text dimColor> (or /critical-ok)</Text>
388 </Box>
389 {shown.map(d => (
390 <Box key={`row-${d.id}`} flexDirection="column">
391 <Text color={d.level === 'undo' ? 'red' : 'yellow'}>
392 {' '}[{d.time}] {d.decision} <Text dimColor>— {d.reason}</Text>
393 </Text>
394 <Box>
395 <Text>{' '}</Text>
396 <Button
397 key={`rethink-${d.id}`}
398 label="rethink"
399 onPress={() => {
400 drop(d.id)
401 void $.prompt.submit({ text: rethinkPrompt(d), asUser: true })
402 }}
403 />
404 <Text> </Text>
405 <Button
406 key={`not-critical-${d.id}`}
407 label="not critical"
408 onPress={async () => {
409 drop(d.id)
410 learned = [...learned, { area: d.area, decision: d.decision.slice(0, 160) }].slice(-LEARNED_MAX)
411 await $.store.set(learnedKey(cwd), learned)
412 }}
413 />
414 <Text dimColor> ({d.area})</Text>
415 </Box>
416 </Box>
417 ))}
418 {hidden ? <Text dimColor> +{hidden} more: /critical-list</Text> : null}
419 </Box>
420 )
421 })
422
423 // The agent declares a decision: warn the person and keep it on the band.
424 on('tool.call', { tool: DEFAULT_TOOL }, ($, e) => {
425 const i = e as unknown as { area?: string; decision?: string; reason?: string; alternative_ruled_out?: string; level?: string }
426 if (!String(i.decision ?? '').trim() || !String(i.reason ?? '').trim())
427 return {
428 result: 'Nothing recorded: "decision" and "reason" must each be one non-empty sentence. Call record_decision again with both.',
429 isError: true,
430 } as any
431 const d: Decision = {
432 id: nextId++,
433 time: new Date().toTimeString().slice(0, 5),
434 level: i.level === 'undo' ? 'undo' : 'know',
435 area: String(i.area || 'general'),
436 decision: String(i.decision ?? ''),
437 reason: String(i.reason ?? ''),
438 alternative: String(i.alternative_ruled_out ?? ''),
439 }
440 decisions.push(d)
441 nudged = true
442 pending.push(d)
443 $.ui.toast(`${d.level === 'undo' ? '🔴' : '🟡'} Critical decision [${d.area}] ${d.decision} — because ${d.reason}`, { timeoutMs: 20000 })
444 $.ui.status(`⚠️ critical decision: ${d.decision.slice(0, 60)}`)
445 $.ui.invalidate('ui.render')
446 return { result: `Decision recorded (${decisions.length} this session) and the user was warned. Mention it in your reply.` }
447 })
448
449 // The agent saves the areas it proposed after /critical-areas auto.
450 on('tool.call', { tool: SAVE_TOOL }, async ($, e) => {
451 const i = e as unknown as { description?: string; areas?: unknown }
452 const areas = areasFrom(i.areas)
453 const description = String(i.description ?? '').trim()
454 if (!areas || !description)
455 return {
456 result:
457 'Nothing saved: give a one-line "description" and 1 to 6 "areas", each with a name and at least one keyword or path. Call save_critical_areas again.',
458 isError: true,
459 } as any
460 config = { description, areas: areas.slice(0, 6) }
461 await $.store.set(storeKey(cwd), config)
462 if (dismissNotice()) await $.store.set(noticeKey(cwd), true)
463 $.ui.toast(`🛡 Critical areas saved: ${config.areas.map(a => a.name).join(', ')}`, { timeoutMs: 8000 })
464 $.ui.invalidate('ui.render')
465 return { result: `Saved. Show the user these areas and say /critical-areas clear undoes it.\n\n${describeConfig(config, learned)}` }
466 })
467
468 // An edit touched a critical area: warn the person and remind the agent to declare.
469 on('tool.call', async ($, e, next) => {
470 const areas = touchedAreas(config, e.tool, e as unknown as Record<string, unknown>).filter(a => !warned.has(a))
471 const r = await next(e)
472 if (r.deny !== undefined || r.isError === true) return r
473 const notes: string[] = []
474 if (areas.length) {
475 areas.forEach(a => warned.add(a))
476 $.ui.toast(`⚠️ Critical area touched: ${areas.join(', ')}`, { timeoutMs: 8000 })
477 notes.push(reminder(areas, toolName))
478 }
479 const writes = EDIT_TOOLS.has(e.tool) || (e.tool.startsWith('mcp__') && !e.tool.startsWith(`mcp__${PLUGIN}__`))
480 if (writes && !nudged) {
481 nudged = true
482 notes.push(writeNudge(toolName))
483 }
484 if (!notes.length) return r
485 return { ...r, context: [...(r.context ?? []), ...notes] } as typeof r
486 })
487
488 on('turn.complete', async (_$, e, next) => {
489 warned.clear()
490 nudged = false
491 return next(e)
492 })
493}
494