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.

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.
Add the marketplace once:
/plugin marketplace add EfiPerez/claude-code-mods
Then install only the mods you want:
| Mod | What it does | Install command |
|---|---|---|
effort-picker | Sets 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-language | Claude'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.
baseline, default high).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.effort per job. Start an Agent description with [effort-locked] to lock the child at that level./effort, ultrathink, a skill whose frontmatter declares effort:. Setting CLAUDE_CODE_EFFORT_LEVEL turns the mod off for the whole session.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.~/.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).hooks/rubric.ts (checkedAt).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.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.claude -p, the SDK); the system-prompt section is also skipped for --bare and teammates.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.effort-picker's approval) and toasts show in the panel; do not expect terminal/Desktop UI parity..claude-plugin/types/ declarations are regenerated at load time.MIT — see LICENSE.
Built by Efi Perez.
hooks/register.ts 837 lines1import 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 }
837hooks/rubric.ts 68 lines1import 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}
68types/index.d.ts 53 lines1// 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