SLOPSHOPPER

model-advisor

Classifies a session's first prompt with Jev and, when it needs a stronger model than the session is on, offers to switch with /model and resend

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · model-advisor
› 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 › /advisor ⎿ model-advisor: First prompts classified 0, advised 0. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

Plugins for Claude Code that run code around the agent's tool calls: they can block a call before it runs, add a note to its result, or put a message in front of the person. They work in the terminal and in the desktop app's Code tab. Each folder is one plugin.

ModWhat it does
tripwireBlocks or annotates shell mistakes that recur in Claude Code sessions: whole-disk find, long foreground sleep loops, errors hidden by `\tail`, forgotten background shells
usage-dollarsSpend in dollars for today and the month so far against a monthly budget: status line, toast at 80% and 100%, /spend
model-advisorClassifies a session's first prompt with Jev and, when it needs a stronger model than the session is on, offers to run /model and resend it

The hook API these mods use is marked early access in Claude Code's own type declarations and can change between releases. Those declarations were written by Claude Code 2.1.286, the version tripwire was developed against.

Layout

A mod folder holds .claude-plugin/plugin.json, hooks/hooks.json naming one module, the module itself, and types/index.d.ts when the mod keeps state. Claude Code generates .claude-plugin/types/ the first time it loads a mod, so that directory is not in the repo.

Enabling

List each mod folder, :-separated, in the env block of ~/.claude/settings.json. Claude Code reads this variable only from user settings, never from a project's:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/tripwire" }

Interactive sessions watch the folder and reload on save. To check a change before committing it:

claude plugin validate tripwire

tripwire

Tripwire was built from 17 local session transcripts: 546 Bash calls. Each rule below answers a mistake that appears in them more than once. A rule can block a call before it runs, add a note the model reads after the result, or show the person a toast or a status line.

RuleKindEvidence (replayed on the transcripts)
find over /, ~, $HOME or /Users/<name> with no -maxdepthblock5 calls, each moved to the background automatically and left running past the end of the task
foreground loop whose sleep totals more than 120s (an unbounded while/until with sleep ≥ 5 counts; timeout N caps the total)block35 calls; the person's reaction to one: "what on earth is taking so long?"
error text in output whose pipeline ends in tail/head and exits 0note20 of 233 such calls
zsh glob abort, macOS has no timeout, gh pr create without --head, gh pr view --json mergednoteone fix line each
background shells still openstatus line, plus a toast at turn end"what are the 5 shells that are open still doing?"
foreground call running longer than 5 minutesstatus line, then a toasta 51-minute foreground Agent call

The replay applies the same patterns the rules use to the Bash calls in those transcripts. The counts describe one person's sessions, so another machine will see different numbers.

# tripwire:allow in a command lets a block through when the person asked for exactly that. /tripwire lists the counts for the current session and for all sessions (kept in the plugin store) and the open background tasks. The counts show whether a rule still fires, and a rule that never trips is a candidate for deletion.

usage-dollars

Books each session's cost ($.session.usage().cost.usd, what /cost totals) into a per-day ledger in the plugin store, shared by every session on the machine. The status line shows $1.20 today · $34.10/$100.00 month (34%), a toast fires once at 80% and once at 100% of the budget, and /spend prints the same figures plus what remains. Per-conversation spend is not shown: the default donut chart button covers it.

Set the budget in the config menu (monthlyBudgetUsd, default 100, 0 hides it). The ledger starts when the mod is first loaded, so earlier spend is not counted, and a resumed session's past cost is not re-booked.

model-advisor

The first-prompt model advisor from oaustegard/claude-workspace (scripts/model_advisor.py), rebuilt as a mod. The hook version guessed the session's model from the CLI's argv and could only block the prompt and ask the person to switch and resend by hand. The mod reads the model with $.session.model(). On a confident upgrade it holds the prompt and offers three buttons above the prompt box: Switch to Opus 5.5 and send (it runs /model opus and resends), Send on Sonnet 5.5, or Edit, which puts the text back in the box. /advisor switch and /advisor send do the same from the keyboard, and plain /advisor lists the recent decisions.

The rules are the hook's: upgrades only, fired at 75% confidence. A prompt that only points at the work (Resume 71afa1b8) makes no call, and only the first prompt of a fresh session is classified, since a switch after the first turn re-reads the whole context at full price. A prompt from a phone or the web (the Remote Control bridge), or one carrying an attachment, is never held. It runs, and the model is told to quote the advice and give the substantive work to a subagent on the stronger model. #no-advice in a prompt skips it. A Routine or SDK session (CLAUDE_CODE_ENTRYPOINT remote_trigger*/sdk*) is never advised, and MODEL_ADVISOR=off turns it off. Any classifier failure lets the prompt through.

The classifier is Jev, reached through TypeSafe (TYPESAFE_API_KEY) or the Cloudflare AI Gateway (CF_ACCOUNT_ID, CF_API_TOKEN, CF_GATEWAY_ID), read from the environment. With neither present the mod does nothing. Options: autoSwitch switches and resends without asking; downgrade also suggests cheaper tiers; quiet hides the one-line verdict the status line otherwise shows when it stays silent.

The mod exports MODEL_ADVISOR_MOD=1, and the workspace hook stands down when it sees that, so loading both advises once. hooks/advice.ts carries the tier table and criteria, and scripts/model_advisor.py has a copy that must change with it.

Source 3 files
hooks/register.tsx 204 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Classification, LogEntry, Pending, Tier } from '../types'
5import {
6  advice,
7  jevCall,
8  MODELS,
9  OPT_OUT,
10  parseJev,
11  relayContext,
12  suggest,
13  TIMEOUT_MS,
14  tierOf,
15  verdict,
16} from './advice'
17
18const pending = atom({ plugin: 'model-advisor', key: 'pending' } as const, null)
19const decided = atom({ plugin: 'model-advisor', key: 'decided' } as const, false)
20
21const LOG = 'log'
22const LOG_MAX = 200
23const NO_HUMAN = /^(remote_trigger|sdk)/
24
25async function log($: Engine, entry: LogEntry) {
26  const all = ((await $.store.get(LOG)) ?? []) as LogEntry[]
27  await $.store.set(LOG, [...all, entry].slice(-LOG_MAX))
28}
29
30async function settle($: Engine, outcome: LogEntry['outcome']) {
31  const all = ((await $.store.get(LOG)) ?? []) as LogEntry[]
32  const last = all.at(-1)
33  if (last && !last.outcome) await $.store.set(LOG, [...all.slice(0, -1), { ...last, outcome }])
34}
35
36async function classify($: Engine, text: string): Promise<Classification> {
37  const call = jevCall(
38    {
39      typesafeKey: await $.env.get('TYPESAFE_API_KEY'),
40      cfAccount: await $.env.get('CF_ACCOUNT_ID'),
41      cfToken: await $.env.get('CF_API_TOKEN'),
42      cfGateway: await $.env.get('CF_GATEWAY_ID'),
43    },
44    text,
45    (await $.session.repo())?.root.split('/').at(-1) ?? null,
46  )
47  if (!call) throw new Error('no Jev transport (TYPESAFE_API_KEY or CF_ACCOUNT_ID+CF_API_TOKEN)')
48  const res = await Promise.race([
49    $.http.fetch(call.url, { method: 'POST', headers: call.headers, body: call.body }),
50    $.clock.sleep(TIMEOUT_MS).then(() => {
51      throw new Error(`timeout after ${TIMEOUT_MS} ms`)
52    }),
53  ])
54  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.text.slice(0, 120)}`)
55
56  return parseJev(res.text, call.gateway)
57}
58
59/** Sends the held prompt, switching to `tier` first when one is given. */
60async function send($: Engine, p: Pending, tier: Tier | null) {
61  await update($, pending, () => null)
62  await settle($, tier ? 'switched' : 'kept')
63  if (tier) await $.command.run({ command: 'model', args: MODELS[tier].alias })
64  await $.prompt.submit({ text: p.text, asUser: true })
65}
66
67export const register: Register = (on, options) => {
68  const autoSwitch = Boolean(options.autoSwitch)
69  const downgrade = Boolean(options.downgrade)
70  const quiet = Boolean(options.quiet)
71
72  on('session.start', async ($, e, next) => {
73    // The hub's UserPromptSubmit hook (scripts/model_advisor.py) stands down on this.
74    await $.env.set('MODEL_ADVISOR_MOD', '1')
75    await $.command.register({
76      name: 'advisor',
77      description: 'Model advisor: the held prompt, and recent decisions',
78      argumentHint: '[switch|send]',
79    })
80
81    return next(e)
82  })
83
84  on('prompt.submit', async ($, e, next) => {
85    // Only a person's own first prompt: the resend, a plugin's or a scheduled prompt pass.
86    // The engine stamps every origin; an unstamped one (the test kit's) reads as the composer.
87    const kind = e.origin?.kind ?? 'composer'
88    if (kind !== 'composer' && kind !== 'bridge') return next(e)
89    if (await read($, decided)) return next(e)
90    const text = e.text
91    if (!text.trim() || text.trimStart().startsWith('/') || text.includes(OPT_OUT)) return next(e)
92    await update($, decided, () => true)
93    // A resumed session already has a cache worth keeping on its model.
94    let history: unknown = []
95    try {
96      history = await $.session.messages()
97    } catch {
98      // No transcript to read (a host without one): treat as a fresh session.
99    }
100    if (Array.isArray(history) && history.some(m => m?.role === 'assistant')) return next(e)
101    const off = (await $.env.get('MODEL_ADVISOR'))?.toLowerCase()
102    if (off === 'off' || off === '0' || off === 'false') return next(e)
103    if (NO_HUMAN.test((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? '')) return next(e)
104
105    const model = await $.session.model()
106    const current = tierOf(model)
107    const t0 = await $.clock.now()
108    let cls: Classification
109    try {
110      cls = await classify($, text)
111    } catch (err) {
112      // Fail open: a broken advisor never eats a prompt.
113      if (!quiet) $.ui.status(`advisor: classifier failed (${String(err).slice(0, 80)}); prompt sent unchanged`)
114
115      return next(e)
116    }
117    const ms = (await $.clock.now()) - t0
118    const { tier: rec, rule } = suggest(cls, current, downgrade)
119    // A prompt that cannot be held and resent intact (a phone, an attachment) is relayed instead.
120    const relay = kind === 'bridge' || (e.attachments?.length ?? 0) > 0
121    await log($, {
122      ts: t0,
123      model,
124      current,
125      suggest: rec,
126      rule,
127      ms,
128      origin: kind,
129      outcome: rec && relay ? 'relayed' : undefined,
130      head: text.slice(0, 120),
131    })
132
133    if (rec === null) {
134      if (quiet) return next(e)
135      const line = verdict(rule, current, cls)
136      $.ui.status(line)
137      if (kind !== 'bridge') return next(e)
138      const note = `The model advisor plugin wrote a note this user's client does not display. Open your reply with it verbatim as a blockquote, then answer as usual:\n${line}`
139
140      return next({ ...e, context: [...(e.context ?? []), note] })
141    }
142
143    const line = advice(rec, rule, current, cls)
144    if (relay) return next({ ...e, context: [...(e.context ?? []), relayContext(rec, current, line)] })
145
146    const held: Pending = { text, tier: rec, note: line }
147    if (autoSwitch) {
148      $.ui.toast(`${line} Switching to ${MODELS[rec].name} and resending.`)
149      // Commands queue until the session is idle, which it is once this prompt is dropped.
150      $.clock.after(0, () => void send($, held, rec))
151    } else {
152      await update($, pending, () => held)
153      $.ui.toast(line)
154    }
155
156    return { drop: line }
157  })
158
159  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160    const p = await read($, pending)
161    if (!p) return next(e)
162    const { Box, Button, Text } = $.ui.resolve(e)
163    const now = tierOf(await $.session.model())
164    const keep = now ? MODELS[now].name : 'this model'
165
166    return (
167      <Box>
168        <Text>{p.note} </Text>
169        <Button key="switch" label={`Switch to ${MODELS[p.tier].name} and send`} onPress={() => send($, p, p.tier)} />
170        <Button key="keep" label={`Send on ${keep}`} onPress={() => send($, p, null)} />
171        <Button
172          key="edit"
173          label="Edit"
174          onPress={async () => {
175            await update($, pending, () => null)
176            await settle($, 'kept')
177            await $.prompt.fill({ text: p.text })
178          }}
179        />
180      </Box>
181    )
182  })
183
184  on('command.run', { command: 'advisor' }, async ($, e) => {
185    const p = await read($, pending)
186    const arg = e.args.trim()
187    if (p && (arg === 'switch' || arg === 'send')) {
188      await send($, p, arg === 'switch' ? p.tier : null)
189
190      return { text: arg === 'switch' ? `Switching to ${MODELS[p.tier].name} and sending.` : 'Sending unchanged.' }
191    }
192    const all = ((await $.store.get(LOG)) ?? []) as LogEntry[]
193    const fired = all.filter(r => r.suggest)
194    const lines = fired
195      .slice(-10)
196      .map(r => `  ${r.current ?? '?'} -> ${r.suggest} [${r.outcome ?? 'open'}] ${r.head.slice(0, 60)}`)
197    const held = p ? `Held: ${p.note}\n/advisor switch or /advisor send.\n` : ''
198
199    return {
200      text: `${held}First prompts classified ${all.length}, advised ${fired.length}.${lines.length ? `\n${lines.join('\n')}` : ''}`,
201    }
202  })
203}
204
hooks/advice.ts 209 lines
1import type { Classification, Scope, Tier } from '../types'
2
3// Port of claude-workspace scripts/model_advisor.py: the same classifier contract,
4// thresholds and rules. Change both together.
5
6export const TIERS: readonly Tier[] = ['haiku', 'sonnet', 'opus', 'fable']
7const SCOPES: readonly Scope[] = ['quick', 'task', 'project']
8
9/** Display name, /model alias, first-party input $/MTok (2026-10; output is 5x on every tier). */
10export const MODELS: Record<Tier, { name: string; alias: string; price: number }> = {
11  haiku: { name: 'Haiku 5.5', alias: 'haiku', price: 0.1 },
12  sonnet: { name: 'Sonnet 5.5', alias: 'sonnet', price: 2 },
13  opus: { name: 'Opus 5.5', alias: 'opus', price: 4 },
14  fable: { name: 'Fable 5.1', alias: 'fable', price: 10 },
15}
16
17// Sonnet 5.5 finishes a named deliverable well but stops to ask on a goal it has to
18// work out (2026-10-07), so the sonnet/opus line is whether the work is named.
19export const CRITERIA: Record<Tier, string> = {
20  haiku:
21    'A mechanical or fully specified task a small fast model completes reliably: run a named command or script, look something up, rename or reformat, extract or classify, summarise one item, apply edits the prompt spells out. No judgment, design or debugging expected.',
22  sonnet:
23    'A bounded task whose deliverable the prompt names: implement or fix a described change in known code, review one document, issue or PR, answer a factual question, a routine lookup or write-up. Done when the named thing is done; nothing has to be found out first.',
24  opus:
25    'Work the prompt sets as a goal and leaves to be discovered: find what needs changing and then change it, audit and update, follow through across many files or repositories, design an approach, debug an unknown cause, assess claims critically, synthesise research across sources. Needs initiative to keep going without asking.',
26  fable:
27    'Frontier-hard work where depth matters more than cost: novel research or experiments with uncertain outcomes, subtle correctness or security reasoning, long autonomous projects spanning many hours.',
28}
29const QUESTION = 'Which is the least capable model tier that would reliably complete this task well?'
30const JUDGEABLE =
31  'The prompt itself describes the work to be done, so its difficulty can be judged without opening a linked issue, memory id, file, paper or web page.'
32const SCOPE_CRITERIA: Record<Scope, string> = {
33  quick: 'A single answer, a short conversation or one small change, done within minutes.',
34  task: 'A focused task of several steps, finished within about an hour.',
35  project: 'An extended session: research, experiments or many changes over hours.',
36}
37
38export const THETA_UP = 0.75
39export const THETA_DOWN = 0.75
40export const JUDGEABLE_MIN = 0.5
41export const QUICK_MAX = 0.5
42export const PROMPT_CHARS = 4000
43export const TIMEOUT_MS = 6000
44export const OPT_OUT = '#no-advice'
45
46export const tierOf = (model: string | undefined): Tier | null =>
47  TIERS.find(t => (model ?? '').toLowerCase().includes(t)) ?? null
48
49const rank = (t: Tier) => TIERS.indexOf(t)
50const atMost = (p: Record<Tier, number>, t: Tier) => TIERS.slice(0, rank(t) + 1).reduce((s, u) => s + p[u], 0)
51const atLeast = (p: Record<Tier, number>, t: Tier) => TIERS.slice(rank(t)).reduce((s, u) => s + p[u], 0)
52export const best = (p: Record<Tier, number>): Tier => TIERS.reduce((a, b) => (p[b] > p[a] ? b : a))
53
54// ------------------------------------------------------------------ transport
55
56export type JevEnv = {
57  typesafeKey?: string
58  cfAccount?: string
59  cfToken?: string
60  cfGateway?: string
61}
62
63export type JevCall = { url: string; headers: Record<string, string>; body: string; gateway: boolean }
64
65/** The Jev request for `prompt`, or null with no transport configured. */
66export const jevCall = (env: JevEnv, prompt: string, repo: string | null): JevCall | null => {
67  const state = {
68    setting:
69      'The first message of a Claude Code session. The model works autonomously with tools (shell, files, web, GitHub) for as many turns as the task takes.',
70    repository: repo,
71    prompt: prompt.slice(0, PROMPT_CHARS),
72  }
73  const questions = {
74    tier: { type: 'choice', instructions: QUESTION, criteria: CRITERIA },
75    judgeable: { type: 'noul', instructions: JUDGEABLE },
76    scope: { type: 'choice', instructions: 'How much work will this session take?', criteria: SCOPE_CRITERIA },
77  }
78  if (env.typesafeKey) {
79    return {
80      url: 'https://api.typesafe.ai/v1/systemone',
81      headers: { Authorization: `Bearer ${env.typesafeKey}`, 'Content-Type': 'application/json' },
82      body: JSON.stringify({ model: 'jev-latest', state, questions }),
83      gateway: false,
84    }
85  }
86  if (env.cfToken && env.cfAccount) {
87    const headers: Record<string, string> = {
88      Authorization: `Bearer ${env.cfToken}`,
89      'Content-Type': 'application/json',
90    }
91    if (env.cfGateway) headers['cf-aig-gateway-id'] = env.cfGateway
92
93    return {
94      url: `https://api.cloudflare.com/client/v4/accounts/${env.cfAccount}/ai/run`,
95      headers,
96      body: JSON.stringify({ model: 'typesafe/jev', input: { state, questions } }),
97      gateway: true,
98    }
99  }
100
101  return null
102}
103
104/** Reads Jev's answer; throws with a short reason on any shape it does not expect. */
105export const parseJev = (text: string, gateway: boolean): Classification => {
106  let ans: any
107  try {
108    const res = JSON.parse(text)
109    ans = (gateway ? res.result.result : res).answers
110  } catch {
111    throw new Error(`unreadable answer: ${text.slice(0, 120)}`)
112  }
113  const num = (v: unknown) => {
114    const n = Number(v)
115    if (v === undefined || v === null || Number.isNaN(n)) throw new Error(`unexpected answer shape: ${JSON.stringify(ans).slice(0, 120)}`)
116
117    return n
118  }
119  const tier = Object.fromEntries(TIERS.map(t => [t, num(ans?.tier?.probabilities?.[t])])) as Record<Tier, number>
120  const scope = Object.fromEntries(SCOPES.map(s => [s, num(ans?.scope?.probabilities?.[s])])) as Record<Scope, number>
121
122  return { tier, scope, judgeable: num(ans?.judgeable?.noul) }
123}
124
125// ------------------------------------------------------------------- decision
126
127/** The tier to suggest, or null, and the rule that decided. */
128export const suggest = (
129  cls: Classification,
130  current: Tier | null,
131  downgrade = false,
132): { tier: Tier | null; rule: string } => {
133  const p = cls.tier
134  let rec: Tier | null = null
135  if (current === null) {
136    if (downgrade) rec = TIERS.slice(0, 2).find(t => atMost(p, t) >= THETA_DOWN) ?? null
137  } else {
138    if (downgrade) rec = TIERS.slice(0, rank(current)).find(t => atMost(p, t) >= THETA_DOWN) ?? null
139    if (rec === null) rec = [...TIERS.slice(rank(current) + 1)].reverse().find(t => atLeast(p, t) >= THETA_UP) ?? null
140  }
141  if (rec === null) {
142    if (current === null) return { tier: null, rule: 'unknown-model' }
143    if (cls.judgeable < JUDGEABLE_MIN) return { tier: null, rule: 'opaque' }
144    const b = best(p)
145    if (b === current) return { tier: null, rule: 'agrees' }
146
147    return { tier: null, rule: rank(b) < rank(current) && !downgrade ? 'cheaper' : 'unsure' }
148  }
149  const down = current === null || rank(rec) < rank(current)
150  if (cls.judgeable < JUDGEABLE_MIN) {
151    if (current === 'fable' && down) return { tier: 'opus', rule: 'opaque-fable-prior' }
152
153    return { tier: null, rule: 'opaque' }
154  }
155  if (down && cls.scope.quick > QUICK_MAX) return { tier: null, rule: 'quick' }
156
157  return { tier: rec, rule: 'tier' }
158}
159
160// ------------------------------------------------------------------- messages
161
162const pct = (x: number) => `${Math.round(x * 100)}%`
163const a = (name: string) => (/^[AEIOU]/.test(name) ? `an ${name}` : `a ${name}`)
164
165export const WHY_SILENT: Record<string, string> = {
166  agrees: "agrees with the session's model",
167  unsure: 'leans another way, but not confidently enough to interrupt',
168  'unknown-model': "couldn't read the session's model",
169  cheaper: 'a cheaper model looks enough, and the advisor only suggests upgrades',
170  opaque: 'the prompt points at the work rather than describing it, so no call',
171  quick: 'looks like a few-minute session, not worth interrupting',
172}
173
174/** One line for a suggestion: the tier, how sure, and the cost ratio. */
175export const advice = (rec: Tier, rule: string, current: Tier | null, cls: Classification): string => {
176  const { name } = MODELS[rec]
177  const down = current === null || rank(rec) < rank(current)
178  const sure = down ? atMost(cls.tier, rec) : atLeast(cls.tier, rec)
179  const why =
180    rule === 'opaque-fable-prior'
181      ? 'the prompt points at the work rather than describing it, and in 32 graded past sessions none needed Fable'
182      : `${pct(sure)} sure`
183  if (current === null) return `Model advisor: this looks like ${a(name)} task (${why}).`
184  const ratio = MODELS[current].price / MODELS[rec].price
185  const cost = ratio > 1 ? `${ratio.toFixed(1)}x less` : `${(1 / ratio).toFixed(1)}x more`
186
187  return `Model advisor: this looks like ${a(name)} task (${why}); the session is on ${MODELS[current].name}, and ${name} costs about ${cost} per token.`
188}
189
190/** Context for the model when the person's client cannot show a choice (phone, web). */
191export const relayContext = (rec: Tier, current: Tier | null, line: string): string => {
192  const { name, alias } = MODELS[rec]
193  const down = current === null || rank(rec) < rank(current)
194  const todo = down
195    ? 'then answer as usual.'
196    : `then do the task, running its substantive work (the design, the hard reasoning, the implementation) in a subagent through the Agent tool with model "${alias}". Keep the main loop to coordinating, checking the subagent's result, and the reply.`
197  const tail = down ? ` /model ${alias} would likely do, for next time.` : ` Handing the heavy part to ${a(name)} subagent; /model ${alias} moves the whole session.`
198
199  return `The model advisor plugin classified this prompt; the user's client does not display plugin output. Open your reply with this line verbatim as a blockquote, ${todo}\n${line}${tail}`
200}
201
202/** The status-line verdict when the advisor stays silent. */
203export const verdict = (rule: string, current: Tier | null, cls: Classification): string => {
204  const b = best(cls.tier)
205  const on = current ? MODELS[current].name : 'unknown model'
206
207  return `advisor: ${MODELS[b].name}-shaped (${pct(cls.tier[b])}), on ${on}; quick ${pct(cls.scope.quick)}, describes the work ${pct(cls.judgeable)}. Silent: ${WHY_SILENT[rule] ?? rule}.`
208}
209
types/index.d.ts 34 lines
1/** Cheapest first; the index is the rank. */
2export type Tier = 'haiku' | 'sonnet' | 'opus' | 'fable'
3
4export type Scope = 'quick' | 'task' | 'project'
5
6/** Jev's answer: P(least capable tier that does the job), P(prompt describes the work), P(scope). */
7export type Classification = {
8  tier: Record<Tier, number>
9  judgeable: number
10  scope: Record<Scope, number>
11}
12
13/** A first prompt held back while the person chooses a model. */
14export type Pending = { text: string; tier: Tier; note: string }
15
16/** One decision, kept in the plugin store for /advisor. */
17export type LogEntry = {
18  ts: number
19  model: string
20  current: Tier | null
21  suggest: Tier | null
22  rule: string
23  ms: number
24  origin: string
25  outcome?: 'switched' | 'kept' | 'relayed'
26  head: string
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'model-advisor': { pending: Pending | null; decided: boolean }
32  }
33}
34