SLOPSHOPPER

critical-decisions

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…

bandguardcommandtoaststatusprompt
★ 12v0.3.3MITupdated 2026-10-20Mucida/critical-decisions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · critical-decisions
› 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 › /critical-areas ⎿ critical-decisions: Default protection: the agent records decisions it makes without being asked that you would likely want to und ⎿ critical-decisions: Nothing specific to this project yet. To make it yours: ⎿ critical-decisions: /critical-areas auto the agent proposes areas from what it knows of this project ⎿ critical-decisions: /critical-areas <your words>, e.g. /critical-areas pricing and tax rules, anything in src/billing ⎿ critical-decisions: /critical-areas no topic or source I did not ask for, nothing published without me ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

critical-decisions

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.

  • The agent decides something on its own that you did not literally ask for: it calls record_decision with the decision, the reason and the alternative it ruled out.
  • Recorded decisions show in a band above the prompt: red for what you may want to take back, yellow for what is worth knowing. The band stays until you dismiss it, across turns, so a decision does not scroll away unseen.
  • Each decision has two buttons: rethink sends it back to the agent ("undo it if you can, propose other ways, ask me first"); not critical takes it off and teaches the agent not to record its like again.
  • Something you marked as critical is touched (a file, a term, a command): you get a toast, and the agent is reminded to declare whether it was its own decision.
⚠️ 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)

Install

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.

Default protection

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:

  • adds, removes or swaps something you did not ask for: a topic, source, claim, comparison, feature, dependency or step;
  • picks a value, rule, formula, default, format, structure or schema you did not specify;
  • departs from your instructions, or from the project's own instructions it already has (such as CLAUDE.md);
  • is hard to undo or reaches outside the session: deleting, overwriting, publishing, sending, deploying, migrating data, spending money;
  • fills a gap in your request with an assumption that changes the result.

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.

Configure

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.

CommandWhat it does
/critical-areas autoThe agent proposes the areas and saves them
/critical-areas <text>Describe what is critical; replaces the current rules
/critical-areasShow the current rules and what was marked as not critical
/critical-areas clearRemove the rules and the not-critical examples (limpar works too)
/critical-listList every decision recorded in this session
/critical-okDismiss the band (same as its ok button)

How it detects

  • Recorded decisions: the agent's own judgement, against the default criteria and your areas, both in its system prompt. This is what catches a decision no keyword names, such as an extra topic in an article.
  • Toasts for your areas: successful Edit, Write, MultiEdit and NotebookEdit calls, shell commands and MCP tool calls.
  • Paths: an edit counts when its file path matches one of an area's globs.
  • Keywords: an edit counts when its new text contains one of an area's keywords, matched case-insensitively from the start of a word, in code and prose alike. A shell command or MCP call counts when what it was given contains one.
  • How often: at most once per area per turn.

It warns; it never blocks an edit.

Access

  • Model: one Haiku call each time you run /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.
  • Tokens: about 450 tokens of criteria in the system prompt, cached with it, plus up to about 200 for the not-critical examples. After the first edit or MCP write of a turn, one hidden line (about 60 tokens) reminds the agent to record a choice it made there, since agents tend to tell decisions in prose instead; it is skipped once a decision is recorded that turn. Plus a reminder when an edit touches an area you marked.
  • Storage: writes your rules, the not-critical examples and that you saw the first-use line to the plugin's own store, keyed by project directory.
  • Files and shell: reads no files and runs no commands. When the default criteria point the agent to your project's instructions, those are the ones Claude Code already gave the agent; the mod does not open them.

Develop

claude plugin validate .
claude plugin test .

Changelog

See CHANGELOG.md. Bugs and ideas: issues.

License

MIT

Source 1 files
hooks/register.tsx 494 lines
1import 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 &lt;your words&gt;  </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