SLOPSHOPPER

effort-picker

Sets each Claude request's effort level from the task, not the prompt: Claude picks it once it understands the work, and helpers get a level per job.

newguardtoastprompttoolagents
v1.0.0MITupdated 2026-10-25EfiPerez/claude-code-mods/effort-picker
A shopper browsing a rack in a slop shop
README

claude-code-mods

Claude Code mods for everyday workflows, by Efi Perez (@EfiPerez).

A mod is a Claude Code plugin with a hooks module: code that runs inside Claude Code on events such as a prompt being sent, a tool being called or a model request going out.

Install

Add the marketplace once:

/plugin marketplace add EfiPerez/claude-code-mods

Then install only the mods you want:

ModWhat it doesInstall command
effort-pickerSets each request's effort level from the task, not the prompt: Claude picks a level once it understands the work, and helpers get a level per job./plugin install effort-picker@efi-mods
plain-languageClaude's chat replies to you in plain, CEFR-level English (A2 or B1): no code unless asked, short task reports. Files, reviews and agent hand-offs keep full technical detail./plugin install plain-language@efi-mods

Alternatively download the repo and load a mod locally using claude --plugin-dir <downloaded-repo>/<mod> on a compatible Claude Code runtime. This uses the plugin loader, not ~/.claude/skills/.

Third-party marketplace auto-update is off by default. Run /plugin marketplace update or enable auto-update for this marketplace.

effort-picker

  • Every automatic prompt starts at a baseline effort (setting baseline, default high).
  • Once Claude understands the task it calls the tool mcp__effort-picker__set_effort {level, category} in the same message as its next tool calls. The mod then rewrites the effort of every later request in that loop. Categories (chat, lookup, trivial edit, contained change, multi-file change, unknown root cause, review/plan, strict rules, long agentic work, architecture/security/data, prior attempt failed) each carry a default level.
  • xhigh and max need your approval. The mod asks with a question card; a "no", a dismissed card or a headless run (claude -p) falls back to high. max is only accepted with the category prior_attempt_failed.
  • Helpers (subagents): the parent sets the Agent tool's effort per job. Start an Agent description with [effort-locked] to lock the child at that level.
  • Your manual choice wins for that prompt: /effort, ultrathink, a skill whose frontmatter declares effort:. Setting CLAUDE_CODE_EFFORT_LEVEL turns the mod off for the whole session.
  • A one-line footer under the answer shows the level when it changed.
  • Notices: a toast when the built-in effort rules are older than staleDays (default 90, 0 turns it off), or when a session runs on a Claude model the rules were not checked against. Either means: check for a plugin update.
  • Files it writes: a per-session log at ~/.claude/effort-picker/logs/<YYYY-MM-DD>_<session>.jsonl holding only levels, categories, ids and counts (never prompt or file text), and one $.store value (the day the stale notice was last shown).
  • The per-category defaults follow Anthropic's effort guidance (<https://platform.claude.com/docs/en/build-with-claude/effort>) as of the date in hooks/rubric.ts (checkedAt).

plain-language

  • Adds style rules for chat replies only: CEFR B1 by default, A2 via the cefrLevel setting. Files Claude writes, prompts for subagents or other AI tools, and subagent results keep full technical detail.
  • Two delivery paths: a system-prompt section (prompt.compose), and the same rules attached once as hidden context to the first accepted prompt of each conversation (again after /clear). On compaction the summarizer is asked to keep the rules.
  • Skipped on every path for headless runs (claude -p, the SDK); the system-prompt section is also skipped for --bare and teammates.

Prerequisites and supported surfaces

  • Requires Claude Code with the mods/hooks engine.
  • On machines with managed settings or a Team/Enterprise organization, the built-in cc-plugin-sec-default skips the user-mod events classic.*, prompt.section, prompt.context, prompt.compose, skill.prompt, attribution.text and settings.read. There is no user-side switch; an administrator must manage the plugin to lift it.
  • effort-picker uses none of those events.
  • plain-language loses its system-prompt section there and falls back to the first-prompt context path described above. The rules then reach the model as part of the first message's context rather than the system prompt.
  • VS Code panel support depends on the extension version. Question cards (effort-picker's approval) and toasts show in the panel; do not expect terminal/Desktop UI parity.
  • Tests are not included in this published copy. Engine-generated .claude-plugin/types/ declarations are regenerated at load time.

Licence

MIT — see LICENSE.

Credit

Built by Efi Perez.

Source 3 files
hooks/register.ts 837 lines
1import type { CommandInfo, EngineInterface, Register } from 'claude-code'
2import { update } from 'claude-code'
3import type {
4  EffortPickerCategory as Category,
5  EffortPickerLevel as Level,
6  EffortPickerLoop as LoopState,
7  EffortPickerManual as Manual,
8} from '../types'
9import { RUBRIC, type Rubric } from './rubric'
10
11// effort-picker: each Claude request runs at the effort level the TASK needs,
12// not the prompt. The model calls set_effort once it understands the task, in the
13// same message as its next real tool calls (zero added latency, R9); a
14// turn.step hook rewrites `effort` on every later request of that loop. Every
15// automatic main turn starts at the baseline B (R7). A manual choice (/effort,
16// ultrathink, a skill with `effort:` frontmatter, a user-named helper level)
17// wins for that prompt only (R5). Helpers: the parent sets the native Agent
18// `effort` per job (tool.describe addendum below); a child that can see
19// set_effort may refine (R3).
20//
21// Failure means "the mod stops changing levels", never "requests fail": every
22// own-code path is wrapped, and turn.step's .catch passes the step through on
23// the replay-safe `next` (one request, never two).
24
25export const LEVELS: readonly Level[] = ['low', 'medium', 'high', 'xhigh', 'max']
26
27// The category names are code (the closed enum in types/index.d.ts); this order
28// is the tool description's and the input schema's. Defaults and guidance per
29// category are data: hooks/rubric.ts.
30const CATEGORY_NAMES: Category[] = [
31  'chat_or_question',
32  'lookup_or_search',
33  'trivial_edit',
34  'contained_change',
35  'multi_file_change',
36  'unknown_root_cause',
37  'review_or_plan',
38  'strict_rules',
39  'long_agentic',
40  'architecture_security_data',
41  'prior_attempt_failed',
42]
43
44export const CATEGORIES: Readonly<Record<Category, { level: Level; label: string; when: string }>> = RUBRIC.categories
45
46export const TOOL_NAME = 'set_effort'
47export const TOOL_FULL = 'mcp__effort-picker__set_effort'
48
49export const TOOL_DESCRIPTION = [
50  'Sets the effort level (how hard you think) for your next model requests in this loop, from the TASK, not the prompt.',
51  'Call it once you understand the task (after reading any handoff, plan or file the prompt points to), in the SAME message as your next real tool calls, never as a step of its own. Skip it only when you answer without using any tool. Call it again only if the task category changes materially; never flip back and forth.',
52  '',
53  'Pick the category that fits; use its default level, or one step away when you have a concrete reason:',
54  ...CATEGORY_NAMES.map(c => `- ${c} -> ${CATEGORIES[c].level}: ${CATEGORIES[c].when}`),
55  '',
56  'Rules:',
57  '- `max` is allowed ONLY with category `prior_attempt_failed` ("on most workloads max adds significant cost for relatively small quality gains"); any other pairing is refused.',
58  '- `xhigh` and `max` need the user\'s approval: the call asks them, and if they say no (or nobody can answer) you get `high`. Do not ask again in the same prompt after a no.',
59  ...RUBRIC.rules.map(r => `- ${r}`),
60].join('\n')
61
62export const PROMPT_CONTEXT =
63  'Effort: once you understand the task - after reading any handoff, plan or file the prompt points to - call ' +
64  '`mcp__effort-picker__set_effort` in the same message as your next real tool calls, never as a step of its own. ' +
65  'Skip it only when you answer without using any tool. Call it again only if the task\'s difficulty changes materially.'
66
67export const AGENT_ADDENDUM =
68  '\n\neffort-picker: set `effort` on every Agent call from the effort-picker rubric (the set_effort tool\'s categories) ' +
69  'for the child\'s job (this is the explicit instruction that parameter requires). If the user named a level for this ' +
70  'agent, pass theirs and begin `description` with `[effort-locked] `.'
71
72// xhigh only with the user's approval (max sits above it, so it asks too).
73// A dismissed dialog or a -p run (ui.ask rejects) counts as "no".
74export const NEEDS_APPROVAL: readonly Level[] = ['xhigh', 'max']
75export const DECLINED_LEVEL: Level = 'high'
76
77async function userApproves($: EngineInterface, level: Level, why: string): Promise<boolean> {
78  const allow = `Allow ${level}`
79  try {
80    const answer = await $.ui.ask(`Claude wants ${level} effort (${why}). Allow it?`, {
81      options: [allow, `No, use ${DECLINED_LEVEL}`],
82      header: 'Effort',
83    })
84    return answer === allow
85  } catch {
86    return false
87  }
88}
89
90export const LOCK_MARK = '[effort-locked] '
91export const KEEP_IT = 'The user set the effort for this prompt; keep it.'
92const ULTRATHINK = /\bultrathink\b/i
93const FRONTMATTER = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/
94const EFFORT_KEY = /^effort\s*:/m
95
96const LOOPS = { plugin: 'effort-picker', key: 'loops' } as const
97const PENDING = { plugin: 'effort-picker', key: 'pendingManual' } as const
98const LOCKED = { plugin: 'effort-picker', key: 'lockedSpawns' } as const
99const SPAWNED = { plugin: 'effort-picker', key: 'spawned' } as const
100const SHOWN = { plugin: 'effort-picker', key: 'lastShownMain' } as const
101const DISABLED = { plugin: 'effort-picker', key: 'disabled' } as const
102const FAILURES = { plugin: 'effort-picker', key: 'failures' } as const
103
104const MAX_CONSECUTIVE_FAILURES = 2
105const LOG_PART_BYTES = 3_000_000 // $.fs.read refuses files over 4 MiB
106
107export const isLevel = (v: unknown): v is Level => typeof v === 'string' && (LEVELS as readonly string[]).includes(v)
108export const isCategory = (v: unknown): v is Category =>
109  typeof v === 'string' && Object.prototype.hasOwnProperty.call(CATEGORIES, v)
110
111// ---- rubric validation (phase 2 reuses it for a downloaded copy) ----
112
113const RUBRIC_TEXT_MAX = 300
114const RUBRIC_LIST_MAX = 10
115const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/
116const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/
117const RUBRIC_VERSION = /^\d{4}\.\d{2}\.\d{2}(\.\d+)?$/
118
119const isRecord = (v: unknown): v is Record<string, unknown> =>
120  typeof v === 'object' && v !== null && !Array.isArray(v)
121
122/**
123 * A non-empty single-line string under the cap, without the lock marker. The
124 * marker check is defense in depth only: a paraphrase gets past it.
125 */
126function rubricText(v: unknown): string | null {
127  if (typeof v !== 'string' || v.trim() === '' || v.length > RUBRIC_TEXT_MAX || CONTROL_CHARS.test(v)) return null
128  if (v.includes(LOCK_MARK) || v.toLowerCase().includes('effort-locked')) return null
129  return v
130}
131
132function rubricTexts(v: unknown, minLength: number): string[] | null {
133  if (!Array.isArray(v) || v.length < minLength || v.length > RUBRIC_LIST_MAX) return null
134  const out: string[] = []
135  for (const item of v) {
136    const s = rubricText(item)
137    if (s === null) return null
138    out.push(s)
139  }
140  return out
141}
142
143function isoDay(v: unknown): string | null {
144  if (typeof v !== 'string' || !ISO_DAY.test(v)) return null
145  const d = new Date(`${v}T00:00:00Z`)
146  return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v ? v : null
147}
148
149/**
150 * The rubric as a fresh copy of its known fields, or null when malformed:
151 * exactly the 11 category names, each level in LEVELS, `max` on
152 * prior_attempt_failed and nowhere else, capped single-line strings.
153 */
154export function validateRubric(x: unknown): Rubric | null {
155  try {
156    if (!isRecord(x) || x.schema !== 1) return null
157    const version = rubricText(x.version)
158    const checkedAt = isoDay(x.checkedAt)
159    const source = rubricText(x.source)
160    const checkedModels = rubricTexts(x.checkedModels, 1)
161    const rules = rubricTexts(x.rules, 0)
162    if (version === null || !RUBRIC_VERSION.test(version) || checkedAt === null) return null
163    if (source === null || !source.startsWith('https://') || checkedModels === null || rules === null) return null
164    if (!isRecord(x.categories)) return null
165    const given = Object.keys(x.categories)
166    if (given.length !== CATEGORY_NAMES.length || !given.every(k => (CATEGORY_NAMES as string[]).includes(k))) return null
167    const categories = {} as Record<Category, { level: Level; label: string; when: string }>
168    for (const name of CATEGORY_NAMES) {
169      const c = x.categories[name]
170      if (!isRecord(c) || !isLevel(c.level) || (c.level === 'max') !== (name === 'prior_attempt_failed')) return null
171      const label = rubricText(c.label)
172      const when = rubricText(c.when)
173      if (label === null || when === null) return null
174      categories[name] = { level: c.level, label, when }
175    }
176    return { schema: 1, version, checkedAt, source, checkedModels, categories, rules }
177  } catch {
178    return null
179  }
180}
181
182// A built-in rubric that fails validation leaves the mod inert, like any other failure.
183let rubricOk = false
184
185export function footerLine(level: Level | number, was: Level | number, category: Category | null): string {
186  const why = category === null ? '' : ` - ${CATEGORIES[category].label}`
187  return `Effort: ${level} (was ${was})${why}`
188}
189
190/** True when a SKILL.md / command file's frontmatter declares `effort:`. */
191export function declaresEffort(text: string): boolean {
192  const m = FRONTMATTER.exec(text)
193  return m !== null && EFFORT_KEY.test(m[1] ?? '')
194}
195
196const fresh = (turnId: string, manual: Manual = false): LoopState => ({
197  turnId,
198  manual,
199  desired: null,
200  applied: null,
201  appliedUnknown: false,
202  lastIncoming: null,
203})
204
205// ---- session-level facts (reset on reload; session.start fires again) ----
206
207let inert = false
208let baseline: Level = 'high'
209let sessionId = ''
210let projectSlug = ''
211let home = ''
212let logDay = ''
213
214// ---- log: enums, ids and counts only (§3.7); one file per session ----
215
216type LogRow = {
217  ts: number
218  sessionId: string
219  projectSlug: string
220  turnId: string
221  loop: 'main' | 'sub'
222  step: number | null
223  desired: Level | null
224  applied: Level | number | null
225  category: Category | null
226  manual: Manual
227  event: string
228  model?: string // model_unchecked rows only
229}
230
231let pendingRows: string[] = []
232let flushing: Promise<void> = Promise.resolve()
233
234async function log(
235  $: EngineInterface,
236  k: string,
237  row: Partial<Omit<LogRow, 'ts' | 'sessionId' | 'projectSlug' | 'loop'>> & { event: string },
238): Promise<void> {
239  try {
240    const full: LogRow = {
241      ts: await $.clock.now(),
242      sessionId,
243      projectSlug,
244      turnId: row.turnId ?? '',
245      loop: k === 'main' ? 'main' : 'sub',
246      step: row.step ?? null,
247      desired: row.desired ?? null,
248      applied: row.applied ?? null,
249      category: row.category ?? null,
250      manual: row.manual ?? false,
251      event: row.event,
252      ...(row.model !== undefined ? { model: row.model } : {}),
253    }
254    pendingRows.push(JSON.stringify(full))
255  } catch {
256    // logging never breaks the turn
257  }
258}
259
260function flush($: EngineInterface): Promise<void> {
261  flushing = flushing.then(async () => {
262    if (pendingRows.length === 0 || home === '' || sessionId === '') return
263    const rows = pendingRows
264    pendingRows = []
265    try {
266      const dir = `${home}/.claude/effort-picker/logs`
267      let part = 0
268      let path = `${dir}/${logDay}_${sessionId.slice(0, 8)}.jsonl`
269      while ((await $.fs.exists(path)) && (await $.fs.stat(path)).size > LOG_PART_BYTES) {
270        part += 1
271        path = `${dir}/${logDay}_${sessionId.slice(0, 8)}_p${part}.jsonl`
272      }
273      const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
274      await $.fs.write(path, before + rows.join('\n') + '\n')
275    } catch {
276      // a lost log batch is acceptable; the turn is not
277    }
278  })
279  return flushing
280}
281
282// ---- state helpers ----
283
284async function readLoops($: EngineInterface): Promise<Record<string, LoopState>> {
285  return (await $.state.get(LOOPS)).value ?? {}
286}
287
288async function patchLoop(
289  $: EngineInterface,
290  k: string,
291  change: (loop: LoopState | undefined) => LoopState | undefined,
292): Promise<void> {
293  await update($, LOOPS, loops => {
294    const next = { ...(loops ?? {}) }
295    const changed = change(next[k])
296    if (changed === undefined) delete next[k]
297    else next[k] = changed
298    return next
299  })
300}
301
302// ---- skill candidate rule (§3.4) ----
303
304async function exists($: EngineInterface, path: string): Promise<boolean> {
305  try {
306    return await $.fs.exists(path)
307  } catch {
308    return false
309  }
310}
311
312async function listDirs($: EngineInterface, path: string): Promise<string[]> {
313  try {
314    return (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => e.name)
315  } catch {
316    return []
317  }
318}
319
320/**
321 * Every file that could be the skill or command `name`. No API names the file
322 * the engine picked, so all candidates are read and any `effort:` wins.
323 */
324async function candidateFiles($: EngineInterface, name: string, pluginHint?: string): Promise<string[]> {
325  const colon = name.indexOf(':')
326  const plugin = colon >= 0 ? name.slice(0, colon) : pluginHint
327  const skill = colon >= 0 ? name.slice(colon + 1) : name
328  const paths: string[] = []
329  let root = ''
330  try {
331    root = await $.session.root()
332  } catch {
333    root = ''
334  }
335  if (colon < 0) {
336    if (root !== '') {
337      paths.push(`${root}/.claude/skills/${skill}/SKILL.md`, `${root}/.claude/commands/${skill}.md`)
338    }
339    if (home !== '') {
340      paths.push(`${home}/.claude/skills/${skill}/SKILL.md`, `${home}/.claude/commands/${skill}.md`)
341    }
342  }
343  if (plugin !== undefined && home !== '') {
344    const cache = `${home}/.claude/plugins/cache`
345    for (const marketplace of await listDirs($, cache)) {
346      const pluginDir = `${cache}/${marketplace}/${plugin}`
347      for (const version of await listDirs($, pluginDir)) {
348        paths.push(`${pluginDir}/${version}/skills/${skill}/SKILL.md`, `${pluginDir}/${version}/commands/${skill}.md`)
349      }
350    }
351  }
352  const dirs = (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS').catch(() => undefined)) ?? ''
353  for (const raw of dirs.split(';')) {
354    const dir = raw.trim().replace(/^~(?=[\\/]|$)/, home)
355    if (dir !== '') paths.push(`${dir}/skills/${skill}/SKILL.md`, `${dir}/commands/${skill}.md`)
356  }
357  const found: string[] = []
358  for (const p of paths) if (await exists($, p)) found.push(p)
359  return found
360}
361
362/**
363 * Manual unless every candidate file was found, read, and declares no
364 * `effort:`. Absence of evidence never counts as "no override" (§3.4).
365 */
366export async function skillIsManual($: EngineInterface, name: string, pluginHint?: string): Promise<boolean> {
367  const files = await candidateFiles($, name, pluginHint)
368  if (files.length === 0) return true
369  for (const f of files) {
370    try {
371      if (declaresEffort(await $.fs.read(f))) return true
372    } catch {
373      return true
374    }
375  }
376  return false
377}
378
379// ---- stale-rubric notice ----
380
381const STALE_DAY_KEY = 'staleNoticeDay'
382const STALE_DAYS_DEFAULT = 90
383const DAY_MS = 86_400_000
384let staleDays = STALE_DAYS_DEFAULT
385let staleShown = false
386let staleQuietDay = '' // another session showed it on this UTC day: recheck on the next
387
388/**
389 * One toast once the rubric's `checkedAt` is more than `staleDays` whole (UTC)
390 * days old. At most once per session; once per day per machine through
391 * `$.store`, which a probe (18/10/2026) showed is shared live across sessions
392 * but has no compare-and-set, so sessions starting at the same moment can each
393 * show it. A notice, not a gate. Fresh rubric: one clock read, no store I/O.
394 */
395async function staleNotice($: EngineInterface, turnId?: string): Promise<void> {
396  if (inert || staleShown || staleDays === 0) return
397  const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
398  const age = Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${RUBRIC.checkedAt}T00:00:00Z`)) / DAY_MS)
399  if (!(age > staleDays) || today === staleQuietDay) return
400  let claimed: unknown
401  try {
402    claimed = await $.store.get(STALE_DAY_KEY)
403  } catch {
404    claimed = undefined // unreadable counts as "not shown today"
405  }
406  if (claimed === today) {
407    staleQuietDay = today
408    return
409  }
410  staleShown = true
411  try {
412    await $.store.set(STALE_DAY_KEY, today)
413  } catch {
414    // a lost claim only risks a second notice today
415  }
416  $.ui.toast(`effort-picker: effort rules last checked ${RUBRIC.checkedAt} — check for a plugin update.`)
417  await log($, 'main', { turnId, event: 'stale_notice' })
418}
419
420// ---- model-coverage notice ----
421
422let warnedModels = new Set<string>()
423
424/**
425 * The bare Claude model id (`claude-opus-5-5`) from what a request carries:
426 * context markers (`[1m]`), provider prefixes (`us.anthropic.`), version and
427 * date suffixes (`-v1:0`, `@20260101`, `-20251001`) removed. Null for an id
428 * with no `claude-` in it (gateway alias, test model): coverage unknowable.
429 */
430export function normaliseModel(id: unknown): string | null {
431  if (typeof id !== 'string') return null
432  const m = /claude-[a-z0-9-]*/.exec(id.toLowerCase().replace(/\[[^\]]*\]/g, ''))
433  if (m === null) return null
434  let bare = m[0].replace(/-+$/, '')
435  for (let prev = ''; prev !== bare; ) {
436    prev = bare
437    bare = bare.replace(/-v\d+$/, '').replace(/-\d{8}$/, '')
438  }
439  return bare === 'claude' ? null : bare
440}
441
442/**
443 * One toast per model per session when a request runs on a Claude model the
444 * rubric was not checked against. Same switch as the stale notice
445 * (`staleDays: 0` turns both off). Synchronous up to the toast; the log row is
446 * fire-and-forget, so the step is never delayed.
447 */
448function modelNotice($: EngineInterface, k: string, turnId: string, step: number, model: unknown): void {
449  if (inert || staleDays === 0) return
450  const bare = normaliseModel(model)
451  if (bare === null || warnedModels.has(bare)) return
452  if (RUBRIC.checkedModels.some(m => normaliseModel(m) === bare)) return
453  warnedModels.add(bare)
454  try {
455    $.ui.toast(`effort-picker: effort rules were not checked for ${bare} — check for a plugin update.`)
456  } catch {
457    // a lost toast is a lost notice, never a failed step
458  }
459  void log($, k, { turnId, step, event: 'model_unchecked', model: bare }).catch(() => undefined)
460}
461
462// ---- the hooks ----
463
464export const register: Register = (on, options) => {
465  baseline = isLevel(options?.baseline) && options.baseline !== 'max' ? options.baseline : 'high'
466  const days = options?.staleDays
467  staleDays = typeof days === 'number' && Number.isFinite(days) && days >= 0 ? days : STALE_DAYS_DEFAULT
468  rubricOk = validateRubric(RUBRIC) !== null
469
470  on('session.start', async ($, e, next) => {
471    staleShown = false
472    staleQuietDay = ''
473    warnedModels = new Set()
474    try {
475      if (!rubricOk) $.ui.log('effort-picker: built-in rubric failed validation, not changing levels', { to: 'debug' })
476      inert = !rubricOk || (await $.env.get('CLAUDE_CODE_EFFORT_LEVEL')) !== undefined
477      home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
478      sessionId = await $.session.id()
479      const root = await $.session.root().catch(() => e.cwd)
480      projectSlug = root.replace(/[\\/]+$/, '').replace(/^.*[\\/]/, '')
481      logDay = new Date(await $.clock.now()).toISOString().slice(0, 10)
482      if (!inert) {
483        await $.tool.register({
484          name: TOOL_NAME,
485          description: TOOL_DESCRIPTION,
486          isDeferred: false,
487          inputSchema: {
488            type: 'object',
489            properties: {
490              level: { type: 'string', enum: [...LEVELS] },
491              category: { type: 'string', enum: CATEGORY_NAMES },
492            },
493            required: ['level', 'category'],
494            additionalProperties: false,
495          },
496        })
497      }
498    } catch (error) {
499      $.ui.log(`effort-picker: session start failed, staying at today's behaviour: ${String(error)}`, { to: 'debug' })
500    }
501    await staleNotice($).catch(() => undefined)
502    return next(e)
503  })
504
505  on('session.end', async ($, e, next) => {
506    await flush($)
507    return next(e)
508  })
509
510  // One fixed, byte-identical string per prompt: it never varies the cached prefix.
511  on('prompt.submit', async ($, e, next) => {
512    if (inert) return next(e)
513    return next({ ...e, context: [...(e.context ?? []), PROMPT_CONTEXT] })
514  })
515
516  // Manual signals typed as slash commands: consumed by the next main turn.start.
517  on('command.run', async ($, e, next) => {
518    if (!inert) {
519      try {
520        let kind: false | 'effort-command' | 'skill' = false
521        if (e.command === 'effort') kind = 'effort-command'
522        else {
523          const commands: CommandInfo[] = await $.command.list().catch(() => [])
524          const info = commands.find(c => c.name === e.command)
525          if (info === undefined || info.source === 'builtin') kind = 'skill' // unresolvable: err toward manual
526          else if (info.source !== 'mcp' && (await skillIsManual($, e.command, info.plugin))) kind = 'skill'
527        }
528        if (kind !== false) {
529          await $.state.set(PENDING, kind)
530          await log($, 'main', { event: 'pending-manual', manual: kind })
531        }
532      } catch {
533        // detection failure: leave the next turn automatic
534      }
535    }
536    return next(e)
537  })
538
539  // Main only. The types say a subagent's run raises no turn.start, but 2.1.296
540  // raises one carrying `agentId` (G2 capture 14/10/2026): treating it as a new
541  // main turn wiped the main loop mid-turn.
542  on('turn.start', async ($, e, next) => {
543    if (!inert && (e as { agentId?: string }).agentId === undefined) {
544      try {
545        const pending = (await $.state.get(PENDING)).value ?? false
546        if (pending !== false) await $.state.set(PENDING, false)
547        const manual: Manual = pending !== false ? pending : ULTRATHINK.test(e.text) ? 'ultrathink' : false
548        await patchLoop($, 'main', () => fresh(e.turnId, manual))
549        if (manual !== false) await log($, 'main', { turnId: e.turnId, event: 'manual', manual })
550      } catch {
551        // untracked turn: turn.step passes it through
552      }
553      // Every main turn, so a session open across the threshold still sees it.
554      await staleNotice($, e.turnId).catch(() => undefined)
555    }
556    return next(e)
557  })
558
559  on('turn.step', async function* ($, e, next) {
560    if (inert || e.effort === undefined) return yield* next(e)
561    const k = e.agentId ?? 'main'
562    try {
563      modelNotice($, k, e.turnId, e.index, e.model)
564    } catch {
565      // the notice never touches the step
566    }
567
568    // Decide what to send. Own-code failures fall back to passing the step through.
569    let send: Level | number = e.effort
570    let rewrite = false
571    let loopForLog: LoopState | null = null
572    try {
573      const loops = await readLoops($)
574      let loop = loops[k]
575      if (k === 'main') {
576        // Not this turn's (a fork, a turn that began before the mod loaded): untouched.
577        if (loop === undefined || loop.turnId !== e.turnId) return yield* next(e)
578      } else {
579        if (loop === undefined || loop.turnId !== e.turnId) loop = fresh(e.turnId)
580        const spawned = (await $.state.get(SPAWNED)).value ?? {}
581        if (spawned[k]?.locked === true) loop = { ...loop, manual: 'agent-request' }
582      }
583      // Extra skill detector: the engine's own value moved mid-loop with no explicit signal.
584      if (loop.manual === false && loop.lastIncoming !== null && loop.lastIncoming !== e.effort) {
585        const pending = (await $.state.get(PENDING)).value ?? false
586        loop = { ...loop, manual: pending === 'effort-command' ? 'effort-command' : 'skill' }
587        await log($, k, { turnId: e.turnId, step: e.index, event: 'unexplained-effort-change', manual: loop.manual })
588      }
589      const disabled = (await $.state.get(DISABLED)).value ?? null
590      if (disabled === null && loop.manual === false) {
591        const target = loop.desired?.level ?? (k === 'main' ? baseline : undefined)
592        if (target !== undefined) {
593          send = target
594          rewrite = true
595        }
596      }
597      const settled = loop
598      await patchLoop($, k, current => ({
599        ...(current !== undefined && current.turnId === settled.turnId ? current : settled),
600        turnId: settled.turnId,
601        manual: settled.manual,
602        lastIncoming: e.effort ?? null,
603      }))
604      loopForLog = settled
605    } catch {
606      send = e.effort
607      rewrite = false
608    }
609
610    // Exactly one `next` per hook run; chunks are delegated untouched.
611    let r
612    try {
613      r = yield* next(rewrite ? { ...e, effort: send } : e)
614    } catch (error) {
615      // Failed beneath (refused rewrite, API error): the sent value is unknown.
616      try {
617        await patchLoop($, k, current => (current === undefined ? current : { ...current, applied: null, appliedUnknown: true }))
618        if (rewrite) {
619          const failures = await update($, FAILURES, n => (n ?? 0) + 1)
620          if (failures >= MAX_CONSECUTIVE_FAILURES) await $.state.set(DISABLED, 'rewritten-steps-failed')
621        }
622        await log($, k, { turnId: e.turnId, step: e.index, event: rewrite ? 'rewrite-failed' : 'step-failed' })
623      } catch {
624        // nothing more to record
625      }
626      throw error
627    }
628
629    // Accounting: only a request that came back counts as applied.
630    try {
631      const known = r.stopReason !== null
632      await patchLoop($, k, current =>
633        current === undefined
634          ? current
635          : known
636            ? { ...current, applied: { level: send, step: e.index } }
637            : { ...current, applied: null, appliedUnknown: true },
638      )
639      if (rewrite && known) await $.state.set(FAILURES, 0)
640      await log($, k, {
641        turnId: e.turnId,
642        step: e.index,
643        event: known ? 'step' : 'step-unknown',
644        desired: loopForLog?.desired?.level ?? null,
645        category: loopForLog?.desired?.category ?? null,
646        applied: known ? send : null,
647        manual: loopForLog?.manual ?? false,
648      })
649    } catch {
650      // accounting failure never touches the response
651    }
652    return r
653  })
654  // No .catch on turn.step: its replay-safe `next` re-ran a request that had
655  // failed beneath (two requests, measured in the 2.1.296 kit). The engine's
656  // default already fits: a hook failing before `next` is skipped (the step
657  // goes out unchanged), one failing after keeps that single run.
658
659  // Model-invoked skill (plan 3.4 row 4): candidate rule; manual for this loop
660  // from this call to the end of its turn.
661  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
662    if (!inert) {
663      try {
664        const k = e.agentId ?? 'main'
665        const name = String(e.skill ?? '')
666        const commands: CommandInfo[] = await $.command.list().catch(() => [])
667        const info = commands.find(c => c.name === name)
668        const manual =
669          info?.source === 'builtin' || info?.source === 'mcp'
670            ? info.source === 'builtin'
671            : await skillIsManual($, name, info?.plugin)
672        if (manual) {
673          await patchLoop($, k, current => (current === undefined ? current : { ...current, manual: 'skill' }))
674          await log($, k, { event: 'manual', manual: 'skill' })
675        }
676      } catch {
677        // detection failure: the loop stays as it was
678      }
679    }
680    return next(e)
681  })
682
683  // The model's own choice. A pure state write: no permission dialog.
684  on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
685    const input = e as unknown as { level?: unknown; category?: unknown; agentId?: string }
686    const { level, category } = input
687    if (!isLevel(level) || !isCategory(category)) {
688      return { deny: `level must be one of ${LEVELS.join(', ')}; category one of ${CATEGORY_NAMES.join(', ')}.` }
689    }
690    if (level === 'max' && category !== 'prior_attempt_failed') {
691      return { deny: 'max is allowed only with category prior_attempt_failed (xhigh already failed on this problem).' }
692    }
693    const k = input.agentId ?? 'main'
694    // After repeated request failures turn.step stops rewriting: say so, never ask or promise a level.
695    if (((await $.state.get(DISABLED)).value ?? null) !== null) {
696      await log($, k, { event: 'set-refused-disabled', desired: level, category })
697      return { result: 'Not applied: effort-picker stopped changing levels in this session after repeated request failures. Keep working.' }
698    }
699    const loops = await readLoops($)
700    const loop = loops[k]
701    const spawned = (await $.state.get(SPAWNED)).value ?? {}
702    const lockedPending = ((await $.state.get(LOCKED)).value ?? []).length > 0
703    const keep =
704      loop?.manual !== undefined && loop.manual !== false
705        ? true
706        : spawned[k]?.locked === true
707          ? true
708          : k !== 'main' && spawned[k] === undefined && lockedPending // fail toward manual
709    if (keep) {
710      await log($, k, { turnId: loop?.turnId, event: 'set-refused-manual', desired: level, category })
711      return { result: KEEP_IT }
712    }
713    if (loop === undefined) {
714      await log($, k, { event: 'set-untracked', desired: level, category })
715      return { result: 'Not applied: this turn started before effort-picker loaded. Keep working.' }
716    }
717    if (NEEDS_APPROVAL.includes(level) && !(await userApproves($, level, CATEGORIES[category].label))) {
718      await patchLoop($, k, current =>
719        current === undefined ? current : { ...current, desired: { level: DECLINED_LEVEL, category } },
720      )
721      await log($, k, { turnId: loop.turnId, event: 'set-declined', desired: DECLINED_LEVEL, category })
722      return {
723        result: `The user did not approve ${level}. Effort ${DECLINED_LEVEL} requested from your next request; do not ask for ${level} again this prompt.`,
724      }
725    }
726    await patchLoop($, k, current => (current === undefined ? current : { ...current, desired: { level, category } }))
727    await log($, k, { turnId: loop.turnId, event: 'set', desired: level, category })
728    return { result: `Effort ${level} requested from your next request.` }
729  })
730
731  // Helpers: the parent picks each child's level per job via the native `effort` param.
732  on('tool.describe', { tool: 'Agent' }, async ($, e, next) => {
733    const r = await next(e)
734    if (inert || r.description.includes('effort-picker:')) return r
735    return { ...r, description: r.description + AGENT_ADDENDUM }
736  })
737
738  // User-named child levels are enforced: the marker locks that spawn. An
739  // unlocked helper asked to run at xhigh/max needs the user's approval, like set_effort.
740  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
741    if (inert) return next(e)
742    if (!e.description.startsWith(LOCK_MARK)) {
743      const level = e.effort
744      if (isLevel(level) && NEEDS_APPROVAL.includes(level) && !(await userApproves($, level, `helper: ${e.description}`))) {
745        await log($, e.agentId ?? 'main', { event: 'agent-declined', desired: DECLINED_LEVEL })
746        return next({ ...e, effort: DECLINED_LEVEL })
747      }
748      return next(e)
749    }
750    const id = e.tool_use_id
751    await update($, LOCKED, ids => [...(ids ?? []), id])
752    try {
753      return await next({ ...e, description: e.description.slice(LOCK_MARK.length) })
754    } finally {
755      await update($, LOCKED, ids => (ids ?? []).filter(x => x !== id)).catch(() => undefined)
756    }
757  })
758
759  on('agent.spawn', async ($, e, next) => {
760    const r = await next(e)
761    if (inert || r.deny !== undefined || r.agentId === undefined) return r
762    try {
763      const locked = ((await $.state.get(LOCKED)).value ?? []).includes(e.tool_use_id)
764      const agentId = r.agentId
765      await update($, SPAWNED, s => ({ ...(s ?? {}), [agentId]: { locked } }))
766      if (locked) await log($, agentId, { event: 'spawn-locked', manual: 'agent-request' })
767    } catch {
768      // an unrecorded spawn is an unlocked one
769    }
770    return r
771  })
772
773  on('turn.complete', async ($, e, next) => {
774    const r = await next(e)
775    if (inert) return r
776    try {
777      if (e.agentId !== undefined) {
778        const agentId = e.agentId
779        const loop = (await readLoops($))[agentId]
780        await patchLoop($, agentId, () => undefined)
781        await update($, SPAWNED, s => {
782          const rest = { ...(s ?? {}) }
783          delete rest[agentId]
784          return rest
785        })
786        await log($, agentId, {
787          turnId: e.turnId,
788          event: 'sub-complete',
789          applied: loop?.applied?.level ?? null,
790          manual: loop?.manual ?? false,
791        })
792        return r
793      }
794      if (e.reason !== 'answer') return r
795      const loop = (await readLoops($)).main
796      if (loop === undefined || loop.turnId !== e.turnId || loop.appliedUnknown || loop.applied === null) {
797        await $.state.set(SHOWN, null)
798        return r
799      }
800      const level = loop.applied.level
801      // Never set this session: compare against the baseline every turn starts at.
802      // The null written after an unknown turn still suppresses the line.
803      const stored = (await $.state.get(SHOWN)).value
804      const was = stored === undefined ? baseline : stored
805      await $.state.set(SHOWN, level)
806      await log($, 'main', {
807        turnId: e.turnId,
808        event: 'turn-complete',
809        applied: level,
810        desired: loop.desired?.level ?? null,
811        category: loop.desired?.category ?? null,
812        manual: loop.manual,
813      })
814      if (was === null || was === level || loop.manual !== false) return r
815      const line = footerLine(level, was, loop.desired?.category ?? null)
816      return { ...r, text: r.text === e.answer ? line : `${r.text}\n\n${line}` }
817    } catch {
818      return r
819    } finally {
820      if (e.agentId === undefined) void flush($)
821    }
822  })
823}
824
825/**
826 * Test seam: `register`, judged against another rubric. The test kit can't
827 * swap a module, so this is how the "invalid built-in -> inert" contract is
828 * exercised. Only the inert decision follows `rubric`; the description and
829 * categories stay the built-in ones.
830 */
831export const registerWithRubric =
832  (rubric: unknown): Register =>
833  (on, options) => {
834    register(on, options)
835    rubricOk = validateRubric(rubric) !== null
836  }
837
hooks/rubric.ts 68 lines
1import type { EffortPickerCategory as Category, EffortPickerLevel as Level } from '../types'
2
3// effort-picker's rubric: default level and guidance per task category, as data.
4// Data only - register.ts
5// validates it (validateRubric) and renders the set_effort tool description from
6// it. Safety policy never lives here: the level enum, the category names, the
7// xhigh/max approval, the max-only-with-prior_attempt_failed refusal and the
8// baseline clamp are code in register.ts.
9//
10// Release rule (README "Rubric"): `checkedAt` moves only after a comparison
11// against `source`, recorded in the README's "Rubric check log"; bump `version`
12// on every release that touches this object. Any text change here changes the
13// tool description and costs every session one prompt-cache miss.
14
15export type Rubric = {
16  readonly schema: 1
17  readonly version: string
18  readonly checkedAt: string
19  readonly source: string
20  readonly checkedModels: readonly string[]
21  readonly categories: Readonly<Record<Category, { readonly level: Level; readonly label: string; readonly when: string }>>
22  readonly rules: readonly string[]
23}
24
25export const RUBRIC: Rubric = {
26  schema: 1,
27  version: '2026.10.21',
28  checkedAt: '2026-10-18',
29  source: 'https://platform.claude.com/docs/en/build-with-claude/effort',
30  checkedModels: ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-opus-4-7'],
31  categories: {
32    chat_or_question: { level: 'low', label: 'chat or question', when: 'answering from what you already know' },
33    lookup_or_search: { level: 'low', label: 'lookup or search', when: 'finding files, reading status, short lookups' },
34    trivial_edit: { level: 'low', label: 'trivial edit', when: 'one-line edit, rename, formatting, obvious fix' },
35    contained_change: {
36      level: 'medium',
37      label: 'contained change',
38      when: 'known-cause bug fix, small feature in 1-2 files, doc from known facts',
39    },
40    multi_file_change: { level: 'high', label: 'multi-file change', when: 'changes across several files' },
41    unknown_root_cause: { level: 'high', label: 'unknown root cause', when: 'debugging where the cause is not yet known' },
42    review_or_plan: { level: 'high', label: 'review or plan', when: 'reviews, plans, designs' },
43    strict_rules: {
44      level: 'high',
45      label: 'strict rules',
46      when: 'skills/CLAUDE.md rules to follow exactly, personal or sensitive data handling',
47    },
48    long_agentic: { level: 'high', label: 'long agentic work', when: 'work expected to run >30 min' },
49    architecture_security_data: {
50      level: 'high',
51      label: 'architecture, security or data',
52      when: 'architecture, security, migrations, financial calculation correctness',
53    },
54    prior_attempt_failed: {
55      level: 'max',
56      label: 'prior attempt failed',
57      when: 'ONLY when xhigh already failed on this same problem in this session',
58    },
59  },
60  // Guidance bullets of the description's "Rules:" list, after the two
61  // code-generated ones (max refusal, xhigh/max approval). "Never flip back and
62  // forth" stays in the call-mechanics paragraph in register.ts: moving it into
63  // a bullet would change the tool description.
64  rules: [
65    'Never `low` for code changes of unclear scope or any sensitive-data work (at low the model is more likely to skip a search, stop early, or skip a check).',
66  ],
67}
68
types/index.d.ts 53 lines
1// effort-picker's $.state contract.
2// Every value is session-scoped and survives a hot reload; nothing here holds
3// prompt text, file content or model-written text.
4
5export type EffortPickerLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
6
7export type EffortPickerCategory =
8  | 'chat_or_question'
9  | 'lookup_or_search'
10  | 'trivial_edit'
11  | 'contained_change'
12  | 'multi_file_change'
13  | 'unknown_root_cause'
14  | 'review_or_plan'
15  | 'strict_rules'
16  | 'long_agentic'
17  | 'architecture_security_data'
18  | 'prior_attempt_failed'
19
20export type EffortPickerManual = false | 'effort-command' | 'ultrathink' | 'skill' | 'agent-request'
21
22export type EffortPickerLoop = {
23  turnId: string
24  manual: EffortPickerManual
25  desired: { level: EffortPickerLevel; category: EffortPickerCategory } | null
26  applied: { level: EffortPickerLevel | number; step: number } | null
27  /** A failed step left the sent value unknown: no footer this turn. */
28  appliedUnknown: boolean
29  /** The effort the engine itself handed the previous step (before any rewrite). */
30  lastIncoming: EffortPickerLevel | number | null
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'effort-picker': {
36      /** key: 'main' | a subagent's agentId */
37      loops: Record<string, EffortPickerLoop>
38      /** Set by command.run, consumed by the next main turn.start. */
39      pendingManual: false | 'effort-command' | 'skill'
40      /** Agent tool_use_ids marked [effort-locked], until their call settles. */
41      lockedSpawns: string[]
42      /** agentId -> lock, from agent.spawn's result, until the child's turn.complete. */
43      spawned: Record<string, { locked: boolean }>
44      /** Last applied main level seen at an answered turn (R4). */
45      lastShownMain: EffortPickerLevel | number | null
46      /** Why the rewrite is off for the rest of the session, once proven refused. */
47      disabled: string | null
48      /** Consecutive failed rewritten steps. */
49      failures: number
50    }
51  }
52}
53