SLOPSHOPPER

zetetic-autopilot

Automatic effort and model: every request runs at the effort its grade calls for (zetetic-genius) and the tool loop steps down the ladder; subagents run on the…

newguardtoastagents
v0.1.0no licenseupdated 2026-10-08cdeust/claude-mods/mods/zetetic-autopilot
A shopper browsing a rack in a slop shop
README

claude-mods

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:

ModOwnsDepends on
cortex-guardrefusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/nothing
cortex-wiki/wiki <wiki/**.md> to PDFnothing
zetetic-geniusstate: the request grade (task class → effort), the genius patterns and skills it matchesnothing
zetetic-autopilotcontext, policy: effort ladder, model routing, pressure, lean resultszetetic-genius
cortex-cockpitstats, ledger, tally, stages, hygiene; the /cortex pane draws the restthe three above
harness-fleetfleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the modelguard, genius, autopilot

Developing

Each mod is validated, tested and type-checked on its own:

cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p .

tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/). For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.

Installing

.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:

/plugin install <mod> --marketplace cdeust/claude-mods

y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.

Source 5 files
hooks/register.tsx 259 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextHealth, PolicyDecision, PolicyState } from '../types'
5import { THRESHOLDS_PATH, band, crossed, matchThresholds, serversOf } from './context'
6import {
7  CHARS_PER_TOKEN,
8  LEAN_TOOLS,
9  STUCK_ERRORS,
10  currentEffort,
11  effortFor,
12  escalate,
13  group,
14  leanText,
15  pressureOf,
16  resultText,
17} from './policy'
18import { type TaskClass, mainModelFor, routeAgentModel } from './route'
19
20const DECISIONS_CAP = 50 // source: bounds $.state size; a viewer shows the last rows only
21
22const context = atom({ plugin: 'zetetic-autopilot', key: 'context' } as const, null as ContextHealth | null)
23const policy = atom({ plugin: 'zetetic-autopilot', key: 'policy' } as const, {
24  mode: 'observe',
25  pressure: 'none',
26  quotaPercent: 80,
27  resultCapChars: 16000,
28  decisions: [],
29  charsCut: 0,
30  errorsInRow: 0,
31} as PolicyState)
32
33// The classifier's verdict is zetetic-genius's state (a dependency): read, never written here.
34const geniusState = { plugin: 'zetetic-genius', key: 'state' } as const
35type Grade = { turnId: string | null; taskClass: TaskClass; effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' }
36
37const decide = (p: PolicyState, d: PolicyDecision, charsCut = 0): PolicyState => ({
38  ...p,
39  decisions: [...p.decisions, d].slice(-DECISIONS_CAP),
40  charsCut: p.charsCut + charsCut,
41})
42
43const expandHome = async ($: EngineInterface, path: string): Promise<string> =>
44  path.startsWith('~/') ? `${(await $.env.get('HOME')) ?? ''}/${path.slice(2)}` : path
45
46// The current grade, when the genius mod is loaded and has one bound to a turn.
47async function gradeOf($: EngineInterface): Promise<Grade | undefined> {
48  try {
49    const { value } = await $.state.get(geniusState)
50    const c = value?.classified
51    return c === null || c === undefined ? undefined : { turnId: c.turnId, taskClass: c.taskClass, effort: c.effort }
52  } catch {
53    return undefined
54  }
55}
56
57type Measured = {
58  context: {
59    tokens?: number
60    window: number
61    percent?: number
62    breakdown?: {
63      categories: readonly { name: string; tokens: number }[]
64      mcpTools: readonly { serverName: string; tokens: number }[]
65    }
66  }
67  rateLimits: readonly { kind: string; percentUsed: number }[]
68  cost?: { usd: number }
69}
70
71// A measure carries no breakdown; the one read before is kept until the next full read.
72async function readContext($: EngineInterface, m: Measured, before: ContextHealth | null): Promise<ContextHealth> {
73  const model = await $.session.model()
74  let warn: number | null = null
75  let hard: number | null = null
76  let source = `${THRESHOLDS_PATH} absent`
77  try {
78    const t = matchThresholds(await $.fs.read(await expandHome($, THRESHOLDS_PATH)), model)
79    if (t !== undefined) {
80      warn = t.warn
81      hard = t.hard
82      source = THRESHOLDS_PATH
83    } else source = `${THRESHOLDS_PATH} has no match for ${model}`
84  } catch {
85    // The file is the Stop guard's; absent means no thresholds, which a viewer says.
86  }
87  const reading = { tokens: m.context.tokens ?? null, warn, hard }
88  return {
89    ...reading,
90    window: m.context.window,
91    percent: m.context.percent ?? null,
92    model,
93    thresholdsSource: source,
94    rateLimits: m.rateLimits.map((r) => ({ kind: r.kind, percentUsed: r.percentUsed })),
95    usd: m.cost?.usd ?? null,
96    categories:
97      m.context.breakdown?.categories.map((c) => ({ name: c.name, tokens: c.tokens })) ?? before?.categories ?? [],
98    mcpServers: m.context.breakdown === undefined ? (before?.mcpServers ?? []) : serversOf(m.context.breakdown.mcpTools),
99    band: band(reading),
100    readAt: await $.clock.now(),
101  }
102}
103
104// The full read, with the window broken down as /context does it: at start.
105async function refreshContext($: EngineInterface): Promise<void> {
106  const before = await read($, context)
107  const after = await readContext($, await $.session.usage({ breakdown: 'summary' }), before)
108  await update($, context, () => after)
109}
110
111export const register: Register = (on, options) => {
112  const mode: PolicyState['mode'] = options.policy_mode === 'enforce' ? 'enforce' : 'observe'
113  const quotaPercent = Number(options.quota_pressure_percent ?? 80)
114  const resultCapChars = Number(options.result_cap_chars ?? 16000)
115  // A hot reload keeps $.state from the previous load: the configured fields are set afresh.
116  const configured = (p: PolicyState): PolicyState => ({ ...p, mode, quotaPercent, resultCapChars })
117
118  on('session.start', async ($, e, next) => {
119    await update($, policy, configured)
120    await refreshContext($)
121
122    return next(e)
123  })
124
125  // /clear, /resume and /branch reset $.state and never fire session.start again.
126  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
127    await update($, policy, configured)
128    await refreshContext($)
129
130    return next(e)
131  })
132
133  on('session.measure', async ($, e, next) => {
134    const before = await read($, context)
135    const after = await readContext($, e, before)
136    await update($, context, () => after)
137    const pressure = pressureOf(after.rateLimits, quotaPercent, after.band)
138    await update($, policy, (p) => ({ ...p, pressure }))
139    const crossing = crossed(before, after)
140    if (crossing !== undefined)
141      $.ui.toast(
142        `context ${crossing}: ${group(after.tokens ?? 0)} tokens, the Stop guard will ${
143          crossing === 'hard' ? 'block the stop' : 'ask for a checkpoint'
144        }`,
145      )
146
147    return next(e)
148  })
149
150  // The effort ladder from the graded rung, the stuck escalation, and main's model under
151  // quota: observed, or applied under enforce. A generator, as the event streams.
152  on('turn.step', async function* ($, e, next) {
153    const p = await read($, policy)
154    const isMain = e.agentId === undefined
155    const grade = isMain ? await gradeOf($) : undefined
156    const g = grade !== undefined && grade.turnId === e.turnId ? grade : undefined
157    const have = currentEffort(e.model, e.effort)
158    let target = effortFor(e.model, e.index, e.effort, p.pressure, g?.effort) ?? have
159    let kind: PolicyDecision['kind'] = 'effort'
160    if (isMain && p.errorsInRow >= STUCK_ERRORS) {
161      const raised = escalate(target)
162      if (raised !== target) {
163        target = raised
164        kind = 'stuck'
165      }
166      await update($, policy, (s) => ({ ...s, errorsInRow: 0 }))
167    }
168    const model = isMain ? mainModelFor(e.model, p.pressure) : undefined
169    if (target === have && model === undefined) return yield* next(e)
170    const applied = p.mode === 'enforce'
171    const at = await $.clock.now()
172    const who = `${isMain ? 'main' : 'agent'} step ${e.index} (${e.model})`
173    if (target !== have) {
174      const subject = g !== undefined && e.index === 0 ? `${who} · ${g.taskClass}` : who
175      await update($, policy, (s) => decide(s, { at, kind, subject, from: String(e.effort ?? 'default'), to: target, applied }))
176    }
177    if (model !== undefined && e.index === 0)
178      await update($, policy, (s) =>
179        decide(s, { at, kind: 'model', subject: 'main · quota pressure', from: e.model, to: model, applied }),
180      )
181    if (!applied) return yield* next(e)
182    return yield* next({
183      ...e,
184      ...(target !== have ? { effort: target } : {}),
185      ...(model !== undefined ? { model } : {}),
186    })
187  })
188
189  // Subagents: the family the type and the turn's class call for, one notch down under pressure.
190  on('agent.spawn', async ($, e, next) => {
191    const p = await read($, policy)
192    const grade = e.parentAgentId === undefined ? await gradeOf($) : undefined
193    const cls = grade !== undefined && grade.turnId !== null ? grade.taskClass : undefined
194    const target = routeAgentModel(e.subagentType, e.model, e.parentModel, p.pressure, cls)
195    if (target === undefined) return next(e)
196    const applied = p.mode === 'enforce'
197    const d: PolicyDecision = {
198      at: await $.clock.now(),
199      kind: 'model',
200      subject: `${e.subagentType}${cls === undefined ? '' : ` · ${cls}`}`,
201      from: e.model ?? `inherit ${e.parentModel}`,
202      to: target,
203      applied,
204    }
205    await update($, policy, (s) => decide(s, d))
206    return next(applied ? { ...e, model: target } : e)
207  })
208
209  // The stuck signal: main's tool errors in a row, read by the next turn.step.
210  on('tool.call', async ($, e, next) => {
211    const ran = await next(e)
212    if ((e as { agentId?: string }).agentId === undefined) {
213      const isBlocked = ran.deny !== undefined || ran.isError === true
214      await update($, policy, (s) => ({ ...s, errorsInRow: isBlocked ? s.errorsInRow + 1 : 0 }))
215    }
216
217    return ran
218  })
219
220  // Memory leaning: a streaming tool's long result keeps head and tail before it is stored.
221  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
222    const tool = e.origin.kind === 'tool' ? e.origin.tool : undefined
223    if (tool === undefined || !LEAN_TOOLS.has(tool)) return next(e)
224    const p = await read($, policy)
225    let cut = 0
226    const content = e.message.content.map((block) => {
227      if (block.type !== 'tool_result') return block
228      const text = resultText(block.content)
229      if (text === undefined) return block
230      const lean = leanText(text, p.resultCapChars)
231      if (lean === undefined) return block
232      cut += lean.cut
233      if (p.mode !== 'enforce') return block
234      return typeof block.content === 'string'
235        ? { ...block, content: lean.text }
236        : { ...block, content: [{ type: 'text', text: lean.text }] }
237    })
238    if (cut === 0) return next(e)
239    const applied = p.mode === 'enforce'
240    const d: PolicyDecision = {
241      at: await $.clock.now(),
242      kind: 'lean',
243      subject: tool,
244      from: `${group(cut)} chars`,
245      to: `about ${group(Math.round(cut / CHARS_PER_TOKEN))} tokens kept out`,
246      applied,
247    }
248    await update($, policy, (s) => decide(s, d, applied ? cut : 0))
249    return next({ ...e, message: { ...e.message, content } })
250  })
251
252  // The stuck counter is a turn's own; main's ends with its turn.
253  on('turn.complete', async ($, e, next) => {
254    if (e.agentId === undefined) await update($, policy, (s) => ({ ...s, errorsInRow: 0 }))
255
256    return next(e)
257  })
258}
259
hooks/context.ts 56 lines
1import type { ContextHealth } from '../types'
2
3// source: ~/.claude/ctxguard-thresholds.json, the file stop-context-guard.py (zetetic
4// plugin) reads: first substring match on the lowercased model wins, else `default`.
5export const THRESHOLDS_PATH = '~/.claude/ctxguard-thresholds.json'
6
7export type Thresholds = { warn: number; hard: number }
8
9type ThresholdsFile = {
10  models?: { match: string; warn: number; hard: number }[]
11  default?: Thresholds
12}
13
14export const matchThresholds = (fileText: string, model: string): Thresholds | undefined => {
15  let parsed: ThresholdsFile
16  try {
17    parsed = JSON.parse(fileText) as ThresholdsFile
18  } catch {
19    return undefined
20  }
21  const m = model.toLowerCase()
22  const hit = (parsed.models ?? []).find((row) => m.includes(row.match.toLowerCase()))
23  return hit === undefined ? parsed.default : { warn: hit.warn, hard: hit.hard }
24}
25
26export type Band = ContextHealth['band']
27
28export const band = (c: Pick<ContextHealth, 'tokens' | 'warn' | 'hard'>): Band => {
29  if (c.tokens === null) return 'unknown'
30  if (c.hard !== null && c.tokens >= c.hard) return 'hard'
31  if (c.warn !== null && c.tokens >= c.warn) return 'warn'
32  return 'measured'
33}
34
35// MCP tool schemas grouped by server, largest first: what each server costs on every request.
36export const serversOf = (
37  tools: readonly { serverName: string; tokens: number }[],
38): { server: string; tools: number; tokens: number }[] => {
39  const by = new Map<string, { tools: number; tokens: number }>()
40  for (const t of tools) {
41    const row = by.get(t.serverName) ?? { tools: 0, tokens: 0 }
42    by.set(t.serverName, { tools: row.tools + 1, tokens: row.tokens + t.tokens })
43  }
44  return [...by.entries()]
45    .map(([server, row]) => ({ server, ...row }))
46    .sort((a, b) => b.tokens - a.tokens)
47}
48
49// A crossing is a transition into a worse band; the same band again says nothing.
50export const crossed = (before: ContextHealth | null, after: ContextHealth): Band | undefined => {
51  const b = after.tokens === null ? 'unknown' : band(after)
52  const a = before === null ? 'measured' : band(before)
53  const rank: Record<Band, number> = { unknown: 0, measured: 0, warn: 1, hard: 2 }
54  return rank[b] > rank[a] ? b : undefined
55}
56
hooks/policy.ts 128 lines
1// Pure policy: what the mod would change on a request, a subagent or a tool result.
2// Observed (logged) or enforced, the decision is the same function of the same facts.
3
4export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
5export type Pressure = 'none' | 'quota' | 'context'
6export type PolicyMode = 'observe' | 'enforce'
7
8// source: platform.claude.com/docs/en/models/haiku-5-5/overview § How it compares (read
9// 2026-10-07), the API's default effort per model: Fable 5.1 `high`, Opus 5.5 `medium`,
10// Sonnet 5.5 `high`, Haiku 5.5 `medium`. ~/.claude/reference/model-behavior.md § Effort agrees
11// on Fable and Opus (Opus `low`/`medium` strong; Sonnet respects effort strictly).
12const DEFAULT_EFFORT: { match: string; effort: Effort }[] = [
13  { match: 'fable', effort: 'high' },
14  { match: 'opus', effort: 'medium' },
15  { match: 'sonnet', effort: 'high' },
16  { match: 'haiku', effort: 'medium' },
17]
18const RANK: Record<Effort, number> = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 }
19
20const isEffort = (v: unknown): v is Effort => typeof v === 'string' && v in RANK
21
22export const defaultEffort = (model: string): Effort => {
23  const m = model.toLowerCase()
24  return DEFAULT_EFFORT.find((row) => m.includes(row.match))?.effort ?? 'medium'
25}
26
27// The owner's ladder (2026-10-07): the first request of a turn keeps its effort, the tool-loop
28// requests after it run at medium; under quota or context pressure the loop runs at low and the
29// first request drops to medium at most. `base` is the effort the request classifier chose for
30// this turn (classify.ts); it replaces the session setting as the rung the ladder starts from,
31// so a `routine` turn runs its whole loop at low and a `critical` one keeps high on its first
32// request. Without a base the ladder never raises.
33export const effortFor = (
34  model: string,
35  index: number,
36  current: unknown,
37  pressure: Pressure,
38  base?: Effort,
39): Effort | undefined => {
40  const have = isEffort(current) ? current : defaultEffort(model)
41  const now = base ?? have
42  const ceiling: Effort = index === 0 ? (pressure === 'none' ? now : 'medium') : pressure === 'none' ? 'medium' : 'low'
43  const target = RANK[ceiling] < RANK[now] ? ceiling : now
44  return target === have ? undefined : target
45}
46
47// Pressure: the five-hour window past the threshold, else the context past its warn mark.
48export const pressureOf = (
49  rateLimits: readonly { kind: string; percentUsed: number }[],
50  quotaPercent: number,
51  contextBand: 'measured' | 'warn' | 'hard' | 'unknown',
52): Pressure => {
53  const fiveHour = rateLimits.find((r) => r.kind === 'five_hour')
54  if (fiveHour !== undefined && fiveHour.percentUsed >= quotaPercent) return 'quota'
55  if (contextBand === 'warn' || contextBand === 'hard') return 'context'
56  return 'none'
57}
58
59// Subagents under pressure go one notch down, opus/fable to sonnet; sonnet and haiku stay.
60// Owner choice 2026-10-07: no protected agent type.
61const HEAVY = /opus|fable/i
62
63export const agentModelFor = (
64  declared: string | undefined,
65  parentModel: string,
66  pressure: Pressure,
67): string | undefined => {
68  if (pressure === 'none') return undefined
69  const effective = declared ?? parentModel
70  return HEAVY.test(effective) ? 'sonnet' : undefined
71}
72
73// Memory leaning: a tool result past the cap keeps its head and tail; the cut is named so the
74// model can rerun with a narrower filter. Only tools whose output is a stream (not a document).
75export const LEAN_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'WebFetch', 'WebSearch'])
76const HEAD_SHARE = 0.6 // source: own choice, the start of an output carries the command's answer
77const TAIL_SHARE = 0.25 // source: own choice, the end carries the exit status and the last error
78
79export type Lean = { text: string; cut: number }
80
81export const leanText = (text: string, cap: number): Lean | undefined => {
82  if (text.length <= cap) return undefined
83  const head = text.slice(0, Math.floor(cap * HEAD_SHARE))
84  const tail = text.slice(text.length - Math.floor(cap * TAIL_SHARE))
85  const cut = text.length - head.length - tail.length
86  const marker = `\n[zetetic-autopilot cut ${cut} characters here; rerun with a narrower filter if they matter]\n`
87  return { text: `${head}${marker}${tail}`, cut }
88}
89
90// source: the rule of thumb the context breakdown uses, about four characters a token.
91export const CHARS_PER_TOKEN = 4
92
93// The effort a request carries now: its own setting, else the model's default.
94export const currentEffort = (model: string, current: unknown): Effort =>
95  isEffort(current) ? current : defaultEffort(model)
96
97// source: effort-calibration.md task table, "genuinely stuck / surprising result → high".
98export const STUCK_ERRORS = 3 // source: own choice, three tool errors in a row is a loop, not a slip
99const LADDER: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
100export const escalate = (effort: Effort): Effort => {
101  const i = LADDER.indexOf(effort)
102  return i < 0 || i >= LADDER.indexOf('high') ? effort : (LADDER[i + 1] ?? effort)
103}
104
105// A tool_result's content as the Messages API spells it: a string, or text blocks (joined here).
106export const resultText = (content: unknown): string | undefined => {
107  if (typeof content === 'string') return content
108  if (!Array.isArray(content)) return undefined
109  const texts = content
110    .map((b) => b as { type?: string; text?: string })
111    .filter((b) => b.type === 'text' && typeof b.text === 'string')
112    .map((b) => b.text as string)
113  return texts.length === content.length ? texts.join('\n') : undefined
114}
115
116// Exact counts, thousands grouped by a space: "119 304" (the design system's data surface).
117export const group = (n: number): string =>
118  String(Math.trunc(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
119
120export type Decision = {
121  at: number
122  kind: 'effort' | 'model' | 'lean' | 'shape' | 'stuck'
123  subject: string
124  from: string
125  to: string
126  applied: boolean
127}
128
hooks/route.ts 69 lines
1// Pure model routing across the four families (fable, opus, sonnet, haiku): which model a
2// subagent runs on, and whether main drops under quota. Families are matched by substring, so
3// an alias (`haiku`) and a full id of any release (4.5, 5.5) resolve the same way.
4
5import { type Pressure, agentModelFor } from './policy'
6
7// Mirrors the zetetic-genius contract (its types/index.d.ts): a mod never imports across mods,
8// so the class names are spelled here and checked where the state is read.
9export type TaskClass = 'routine' | 'planned' | 'bugfix' | 'analysis' | 'critical'
10
11// source: ~/.claude/reference/agent-reference/effort-calibration.md § Which model when.
12// Haiku: a task fully planned by a more capable model with mechanical execution, bounded
13// well-specified research. Sonnet: agile coding, agent planning and execution, efficient
14// research. Opus: code review and bug-finding, security, formal verification, deep multi-file
15// work. Fable: only when explicitly chosen, never the default upgrade path (twice the Opus rate).
16// source: platform.claude.com/docs/en/models/haiku-5-5/whats-new-haiku-5-5 (released
17// 2026-10-07): Haiku 5.5 is "built for high-volume, latency-sensitive work such as
18// classification, routing, extraction, and subagent tasks", takes the effort parameter with
19// adaptive thinking on by default, and has a 1M context window with 128k output (the 200K
20// ceiling that kept Haiku 4.5 off open searches no longer applies to it).
21export const FAMILIES = ['haiku', 'sonnet', 'opus', 'fable'] as const
22export type Family = (typeof FAMILIES)[number]
23
24export const SONNET = 'sonnet'
25export const HAIKU = 'haiku'
26export const OPUS = 'opus'
27
28export const familyOf = (model: string): Family | undefined => {
29  const m = model.toLowerCase()
30  return FAMILIES.find((f) => m.includes(f))
31}
32
33const MECHANICAL_TYPES = /^(Explore|general-purpose|statusline-setup)$|memory-writer|git-historian/i
34const CODING_TYPES =
35  /engineer$|refactorer|simplifier|test-engineer|data-scientist|dba|devops|mlops|paper-writer|latex|professor|ux-designer/i
36const REVIEW_TYPES = /code-reviewer|security-auditor|architect|advisor|reviewer-academic|^Plan$|dispatch|orchestrator/i
37
38// Classes whose execution is mechanical once the request is read (classify.ts).
39const MECHANICAL_CLASSES: readonly TaskClass[] = ['routine', 'planned']
40
41// Pressure first (policy.ts, the owner's rule: opus/fable one notch down to sonnet, nothing
42// protected). Then, with no explicit model on the call or the agent definition and a heavy
43// parent, the type's own routing, sharpened by the turn's task class when it is known.
44export const routeAgentModel = (
45  subagentType: string,
46  declared: string | undefined,
47  parentModel: string,
48  pressure: Pressure,
49  taskClass?: TaskClass,
50): string | undefined => {
51  const down = agentModelFor(declared, parentModel, pressure)
52  if (down !== undefined) return down
53  const parent = familyOf(parentModel)
54  if (declared !== undefined || parent === undefined || parent === HAIKU || parent === SONNET) return undefined
55  const isMechanical = taskClass !== undefined && MECHANICAL_CLASSES.includes(taskClass)
56  if (MECHANICAL_TYPES.test(subagentType)) return isMechanical ? HAIKU : SONNET
57  if (CODING_TYPES.test(subagentType)) return taskClass === 'planned' ? HAIKU : SONNET
58  if (REVIEW_TYPES.test(subagentType)) return parent === 'fable' ? OPUS : undefined
59  return undefined
60}
61
62// Main thread: a model switch forfeits the prompt cache (the engine's model-change event
63// carries `prompt_cache_warm` and `estimated_cache_write_usd` for that reason), so the lever
64// is pulled for quota pressure alone, never from a request's class. Owner's rule: heavy → sonnet.
65export const mainModelFor = (model: string, pressure: Pressure): string | undefined => {
66  const f = familyOf(model)
67  return pressure === 'quota' && (f === OPUS || f === 'fable') ? SONNET : undefined
68}
69
types/index.d.ts 52 lines
1// The autopilot's contract: the live context window it measures, and what it decided.
2
3// The live context window, with the checkpoint thresholds the Stop guard enforces.
4export type ContextHealth = {
5  tokens: number | null
6  window: number
7  percent: number | null
8  model: string
9  warn: number | null
10  hard: number | null
11  thresholdsSource: string
12  rateLimits: { kind: string; percentUsed: number }[]
13  usd: number | null
14  // The window by category and the MCP servers' tool schemas, as /context estimates them.
15  categories: { name: string; tokens: number }[]
16  mcpServers: { server: string; tools: number; tokens: number }[]
17  // Where the reading sits against the thresholds; computed once here so viewers need no rule.
18  band: 'measured' | 'warn' | 'hard' | 'unknown'
19  readAt: number
20}
21
22export type Pressure = 'none' | 'quota' | 'context'
23
24// What the policy decided this session: observed, or applied under `enforce`.
25export type PolicyDecision = {
26  at: number
27  kind: 'effort' | 'model' | 'lean' | 'stuck'
28  subject: string
29  from: string
30  to: string
31  applied: boolean
32}
33
34export type PolicyState = {
35  mode: 'observe' | 'enforce'
36  pressure: Pressure
37  quotaPercent: number
38  resultCapChars: number
39  decisions: PolicyDecision[]
40  charsCut: number
41  errorsInRow: number
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'zetetic-autopilot': {
47      context: ContextHealth | null
48      policy: PolicyState
49    }
50  }
51}
52