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…

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:
| Mod | Owns | Depends on |
|---|---|---|
cortex-guard | refusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/ | nothing |
cortex-wiki | /wiki <wiki/**.md> to PDF | nothing |
zetetic-genius | state: the request grade (task class → effort), the genius patterns and skills it matches | nothing |
zetetic-autopilot | context, policy: effort ladder, model routing, pressure, lean results | zetetic-genius |
cortex-cockpit | stats, ledger, tally, stages, hygiene; the /cortex pane draws the rest | the three above |
harness-fleet | fleet: 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 model | guard, genius, autopilot |
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.
.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.
hooks/register.tsx 259 lines1import { 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}
259hooks/context.ts 56 lines1import 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}
56hooks/policy.ts 128 lines1// 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}
128hooks/route.ts 69 lines1// 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}
69types/index.d.ts 52 lines1// 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