Soft-denies subagent spawns with no model set and heavy-model spawns late in the five-hour window, and shows when the window will cap at the current pace

A Claude Code mod that keeps subagent spending deliberate.
general-purpose, claude by default) that sets no model is denied once with a reminder to choose one from the task. Re-issue the identical call to run it on the inherited model anyway.opus, fable by default) while the five-hour window is at or past warnAt (75%) is denied once with the reading and the projected cap time. Re-issue unchanged to spend it anyway, or name a model: choosing a heavy one after seeing the reading passes.QM pace: cap ~15:40 (resets 17:00) · agents 4 (2 opus). Hidden off a subscription./quartermaster reports each guard's fires, re-issues and changes, the pace and the config.Spawns made by other plugins or by workflow scripts can't re-issue, so for them the guards never deny: they toast, once per plugin and agent type (model guard) or per plugin and window (heavy guard).
Quartermaster is a function-hooks plugin, built against Claude Code 2.1.292. That hooks API is early access and moves between releases, so a newer build may need changes here. The pace line and toasts need a subscription that reports rate limits.
/plugin marketplace add bloknayrb/claudestuff
/plugin install quartermaster@claudestuff-marketplace
| Field | Type | Default | Meaning |
|---|---|---|---|
warnAt | number | 75 | Five-hour usage (%) at which the heavy-model guard starts |
heavyModels | list | opus, fable | Model names or id fragments, matched case-insensitively |
guardTypes | list | general-purpose, claude | Agent types with no model of their own |
requireModel | boolean | true | Turns the model guard on or off |
The heavy-model guard knows a spawn's model only when the call names one, when it is a fork, or when its type is in guardTypes (it then inherits the parent's). It assumes no default subagent model is configured; with one, those spawns run on the default instead. Other agent types that set no model go unjudged.
A denied call's identity is a hash of prompt, description, subagentType and model, never the tool call id. The identical call issued again within ten minutes, from the same agent, runs. The same task (prompt and description) with another model or agent type counts as acting on the deny. Counters (fires, reissued, changed) and the last 200 outcomes are kept so a guard that is mostly re-issued can be narrowed or removed.
~/.claude/state/mods/quartermaster/<sessionId>.json: { "loadedAt", "lastError" }. One file is written per session id and never pruned; they are small and safe to delete.It makes no model calls. A guard that fails lets the spawn through and logs to the debug log.
claude plugin test plugins/quartermaster runs the tests. tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it first loads the mod from a folder you own (--plugin-dir, or a mods folder). Load it once before running tsc -p. Every function that takes $ lives in hooks/register.ts, because the validator refuses $ passed across an import.
hooks/register.ts 524 lines1import type { AgentSpawnResult, EngineInterface, Hook, Register, SessionRateLimit } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3import type { QmDenial, QmGuard, QmHealth } from '../types'
4import { current, parseConfig, shapeOf } from './config'
5import { spawnHash, taskHash } from './hash'
6import type { Reading } from './pace'
7import { agentsClause, fitCap, fiveHour, matchWindow, paceClause, statusText, windowText } from './pace'
8import type { Fired, SpawnView } from './rules'
9import {
10 DENIAL_TTL_MS,
11 MODEL_TEXT,
12 effectiveModel,
13 isHeavy,
14 isLive,
15 modelGuardFires,
16 settle,
17 sourceLabel,
18 sourceOf,
19 takeOne,
20 toastKey,
21 viewOf,
22} from './rules'
23
24// Every function that takes `$` lives in this file: the validator refuses `$` passed across an import.
25// So do the atoms: read/update accept only an atom the scan can see made
26// in this file. config.ts, hash.ts, pace.ts and rules.ts take no `$`.
27
28// ==== state ====
29
30// Session-scoped values ($.state): kept across hot reloads, reset on /clear and resume.
31const denied = atom({ plugin: 'quartermaster', key: 'denied' } as const, [] as QmDenial[])
32const agents = atom({ plugin: 'quartermaster', key: 'agents' } as const, {} as Record<string, string>)
33const spawnModels = atom({ plugin: 'quartermaster', key: 'spawnModels' } as const, {} as Record<string, string>)
34const sourceToasts = atom({ plugin: 'quartermaster', key: 'sourceToasts' } as const, [] as string[])
35const sourceSpawns = atom({ plugin: 'quartermaster', key: 'sourceSpawns' } as const, {} as Record<string, number>)
36const health = atom({ plugin: 'quartermaster', key: 'health' } as const, null as QmHealth | null)
37
38// ==== health ====
39
40const MOD = 'quartermaster'
41
42/** USERPROFILE, then HOME (Windows usually has no HOME); forward slashes. */
43async function homeDir($: EngineInterface): Promise<string | null> {
44 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
45 return home === undefined || home === '' ? null : home.split('\\').join('/')
46}
47
48async function writeHealth($: EngineInterface): Promise<void> {
49 try {
50 const held = await read($, health)
51 const home = await homeDir($)
52 if (held === null || home === null) return
53 const body = JSON.stringify({ loadedAt: held.loadedAt, lastError: held.lastError })
54 await $.fs.write(`${home}/.claude/state/mods/${MOD}/${held.sessionId}.json`, `${body}\n`)
55 } catch {
56 // Best-effort: a missing heartbeat reads as unknown, never as ok.
57 }
58}
59
60/** Writes the heartbeat when it is missing or belongs to another session id; `fresh` forces a new loadedAt. */
61async function ensureHeartbeat($: EngineInterface, fresh = false): Promise<void> {
62 const sessionId = await $.session.id()
63 const held = await read($, health)
64 if (!fresh && held !== null && held.sessionId === sessionId) return
65 const loadedAt = await $.clock.now()
66 await update($, health, () => ({ sessionId, loadedAt, lastError: null }))
67 await writeHealth($)
68 // A command is declared "for this session" and no session.start follows a /clear, so a re-arm declares it
69 // again (registering a name twice replaces it).
70 if (!fresh) await registerCommand($)
71}
72
73async function registerCommand($: EngineInterface): Promise<void> {
74 try {
75 await $.command.register({ name: COMMAND, description: 'Quartermaster: guard fires and re-issues, and the five-hour pace' })
76 } catch (error) {
77 // A failed registration must not cost the caller its other work.
78 await noteFailure($, 'command.register', error)
79 }
80}
81
82function describeError(error: unknown): string {
83 if (typeof error === 'object' && error !== null) {
84 const { message, kind } = error as { message?: unknown; kind?: unknown }
85 if (typeof message === 'string' && message !== '') return message
86 if (typeof kind === 'string') return kind
87 }
88 return String(error)
89}
90
91/** Fail-open bookkeeping: the debug log and the heartbeat's lastError. Never throws. */
92async function noteFailure($: EngineInterface, where: string, error: unknown): Promise<void> {
93 const message = `${where}: ${describeError(error)}`
94 try {
95 $.ui.log(`quartermaster: ${message}`, { to: 'debug' })
96 } catch {
97 // The debug log is best-effort too.
98 }
99 try {
100 await ensureHeartbeat($)
101 const ts = await $.clock.now()
102 await update($, health, held => (held === null ? held : { ...held, lastError: { ts, message } }))
103 await writeHealth($)
104 } catch {
105 // Nothing left to tell: the hook still fails open.
106 }
107}
108
109// ==== ledger ====
110
111type Counter = { fires: number; reissued: number; changed: number }
112type Counters = Record<QmGuard, Counter>
113type Outcome = 'denied' | 'reissued' | 'changed' | 'toast'
114type Fire = { ts: number; guard: QmGuard; input_hash: string; outcome: Outcome }
115
116const RING_MAX = 200
117
118let chain: Promise<unknown> = Promise.resolve()
119
120/**
121 * Runs $.store read-modify-writes one at a time. $.store has no append or compare-and-set, and
122 * parallel Agent calls run their spawn hooks concurrently, so unserialized writes lose increments.
123 * Never call serial() from inside work passed to serial(): it would wait on itself.
124 */
125function serial<T>(work: () => Promise<T>): Promise<T> {
126 const run = chain.then(work, work)
127 chain = run.catch(() => undefined)
128 return run
129}
130
131function counter(raw: Partial<Counter> | undefined): Counter {
132 return { fires: raw?.fires ?? 0, reissued: raw?.reissued ?? 0, changed: raw?.changed ?? 0 }
133}
134
135async function readCounters($: EngineInterface): Promise<Counters> {
136 const raw = (await $.store.get('counters')) as Partial<Record<QmGuard, Partial<Counter>>> | undefined
137 return { model: counter(raw?.model), heavy: counter(raw?.heavy) }
138}
139
140async function readRing($: EngineInterface): Promise<Fire[]> {
141 const raw = await $.store.get('ring')
142 return Array.isArray(raw) ? (raw as Fire[]) : []
143}
144
145/** Bumps each guard's counter for the outcome (a toast bumps none) and appends to the 200-entry ring. */
146function recordFires($: EngineInterface, guards: readonly QmGuard[], hash: string, outcome: Outcome): Promise<void> {
147 return serial(async () => {
148 const ts = await $.clock.now()
149 const counters = await readCounters($)
150 const ring = await readRing($)
151 for (const guard of guards) {
152 if (outcome === 'denied') counters[guard].fires += 1
153 if (outcome === 'reissued') counters[guard].reissued += 1
154 if (outcome === 'changed') counters[guard].changed += 1
155 ring.push({ ts, guard, input_hash: hash, outcome })
156 }
157 if (outcome !== 'toast') await $.store.set('counters', counters)
158 await $.store.set('ring', ring.slice(-RING_MAX))
159 })
160}
161
162const READINGS_MAX = 300
163const DUPLICATE_MS = 60_000
164
165/** Keeps the current window and the one before it (the two latest resets). */
166function keepTwoWindows<T>(byWindow: Record<string, T>): Record<string, T> {
167 const keys = Object.keys(byWindow)
168 .sort((a, b) => Number(a) - Number(b))
169 .slice(-2)
170 return Object.fromEntries(keys.map(key => [key, byWindow[key] as T]))
171}
172
173type WindowReadings = { key: string; readings: Reading[] }
174
175async function allReadings($: EngineInterface): Promise<Record<string, Reading[]>> {
176 return ((await $.store.get('readings')) ?? {}) as Record<string, Reading[]>
177}
178
179/** The readings of the window that resets at resetsAt, under the key matchWindow picks. */
180async function windowReadings($: EngineInterface, resetsAt: number): Promise<WindowReadings> {
181 const all = await allReadings($)
182 const key = matchWindow(Object.keys(all), resetsAt)
183 return { key, readings: all[key] ?? [] }
184}
185
186/** Adds a reading to its window and returns the window; a repeat within a minute is skipped. */
187function addReading($: EngineInterface, resetsAt: number, reading: Reading): Promise<WindowReadings> {
188 return serial(async () => {
189 const all = await allReadings($)
190 const key = matchWindow(Object.keys(all), resetsAt)
191 const list = all[key] ?? []
192 const prior = list[list.length - 1]
193 if (prior !== undefined && prior[1] === reading[1] && reading[0] - prior[0] < DUPLICATE_MS) {
194 return { key, readings: list }
195 }
196 const kept = [...list, reading].slice(-READINGS_MAX)
197 await $.store.set('readings', keepTwoWindows({ ...all, [key]: kept }))
198 return { key, readings: kept }
199 })
200}
201
202const THRESHOLDS = [50, 75, 90] as const
203
204/**
205 * The highest threshold this reading crossed that no session has toasted in this window, or null.
206 * Marks every threshold crossed as sent, so a jump toasts once. Global: $.store is shared.
207 */
208function claimThreshold($: EngineInterface, key: string, pct: number): Promise<number | null> {
209 return serial(async () => {
210 const all = ((await $.store.get('toasts')) ?? {}) as Record<string, number[]>
211 const sent = all[key] ?? []
212 const crossed: number[] = THRESHOLDS.filter(t => pct >= t)
213 const top = crossed[crossed.length - 1]
214 if (top === undefined || sent.includes(top)) return null
215 const marked = [...new Set([...sent, ...crossed])].sort((a, b) => a - b)
216 await $.store.set('toasts', keepTwoWindows({ ...all, [key]: marked }))
217 return top
218 })
219}
220
221// ==== pacing ====
222
223/** Stores a five-hour reading under its window, and toasts a threshold the first time any session crosses it. */
224async function recordReading($: EngineInterface, limits: readonly SessionRateLimit[]): Promise<void> {
225 const window = fiveHour(limits)
226 if (window === null || window.resetsAt === null) return
227 const { key, readings } = await addReading($, window.resetsAt, [await $.clock.now(), window.pct])
228 const crossed = await claimThreshold($, key, window.pct)
229 if (crossed !== null) $.ui.toast(`Quartermaster: ${windowText(window.pct, window.resetsAt, fitCap(readings), await $.clock.now())}.`)
230}
231
232/** The one status line; hidden with no five-hour reading. */
233async function refreshStatus($: EngineInterface, limits?: readonly SessionRateLimit[]): Promise<void> {
234 const window = fiveHour(limits ?? (await $.session.usage()).rateLimits)
235 if (window === null) {
236 $.ui.status(undefined)
237 return
238 }
239 const cap = window.resetsAt === null ? null : fitCap((await windowReadings($, window.resetsAt)).readings)
240 const tally = agentsClause(await read($, agents), current.cfg.heavyModels)
241 $.ui.status(statusText(paceClause(cap, window.resetsAt, window.pct, await $.clock.now()), tally))
242}
243
244// ==== guards ====
245
246type SpawnEvent = Parameters<Hook<'agent.spawn'>>[1]
247type SpawnNext = Parameters<Hook<'agent.spawn'>>[2]
248
249const DENIALS_MAX = 50
250const SPAWN_MODELS_MAX = 200
251const SOURCE_TOASTS_MAX = 200
252
253async function judge($: EngineInterface, view: SpawnView, label: string): Promise<Fired> {
254 const cfg = current.cfg
255 const fired: Fired = { guards: [], denyTexts: [], toastTexts: [], windowKey: null }
256 if (modelGuardFires(view, cfg)) {
257 fired.guards.push('model')
258 fired.denyTexts.push(MODEL_TEXT)
259 fired.toastTexts.push(`Quartermaster: ${label} spawned ${view.subagentType} with no model set.`)
260 }
261 const model = effectiveModel(view, cfg)
262 if (isHeavy(model, cfg.heavyModels)) {
263 const window = fiveHour((await $.session.usage()).rateLimits)
264 if (window !== null && window.pct >= cfg.warnAt) {
265 const held = window.resetsAt === null ? null : await windowReadings($, window.resetsAt)
266 const reading = windowText(window.pct, window.resetsAt, held === null ? null : fitCap(held.readings), await $.clock.now())
267 fired.windowKey = held === null ? null : held.key
268 fired.guards.push('heavy')
269 fired.denyTexts.push(`Quartermaster: ${reading}. Re-issue unchanged to spend it anyway.`)
270 fired.toastTexts.push(`Quartermaster: ${label} spawned an agent on ${model}; ${reading}.`)
271 }
272 }
273 return fired
274}
275
276async function safeRecord($: EngineInterface, guards: readonly QmGuard[], hash: string, outcome: Outcome): Promise<void> {
277 if (guards.length === 0) return
278 try {
279 await recordFires($, guards, hash, outcome)
280 } catch (error) {
281 // The verdict stands whatever the ledger does.
282 await noteFailure($, 'fire ledger', error)
283 }
284}
285
286function trimRecord(map: Record<string, string>, max: number): Record<string, string> {
287 const entries = Object.entries(map)
288 return entries.length <= max ? map : Object.fromEntries(entries.slice(-max))
289}
290
291/** Lets the spawn start and remembers which model it got, for the tally. */
292async function admit($: EngineInterface, e: SpawnEvent, next: SpawnNext): Promise<AgentSpawnResult> {
293 const started = await next(e)
294 const { agentId, model } = started
295 if (agentId !== undefined && model !== undefined) {
296 await update($, spawnModels, map => trimRecord({ ...map, [agentId]: model }, SPAWN_MODELS_MAX))
297 }
298 return started
299}
300
301/**
302 * Toast keys this module instance has claimed. A key is checked and added with no await between, so
303 * parallel spawns can't both claim it. Module memory is lost on a hot reload, so the
304 * claim is then persisted to $.state, which a reloaded module checks.
305 */
306const claimed = new Set<string>()
307
308async function claimToast($: EngineInterface, key: string): Promise<boolean> {
309 if (claimed.has(key)) return false
310 claimed.add(key)
311 let fresh = false
312 try {
313 await update($, sourceToasts, list => {
314 fresh = !list.includes(key)
315 return fresh ? [...list, key].slice(-SOURCE_TOASTS_MAX) : list
316 })
317 } catch (err) {
318 // Release the claim, or a failed write would suppress this toast for the rest of the session.
319 claimed.delete(key)
320 throw err
321 }
322 return fresh
323}
324
325/** A workflow's or another plugin's spawn can't re-issue: it always starts, and its guards toast instead. */
326async function toastOnly(
327 $: EngineInterface,
328 e: SpawnEvent,
329 next: SpawnNext,
330 view: SpawnView,
331 source: string,
332 fired: Fired,
333 hash: string,
334): Promise<AgentSpawnResult> {
335 await update($, sourceSpawns, map => ({ ...map, [source]: (map[source] ?? 0) + 1 }))
336 const shown: QmGuard[] = []
337 const texts: string[] = []
338 for (const [i, guard] of fired.guards.entries()) {
339 if (await claimToast($, toastKey(guard, source, view.subagentType, fired.windowKey))) {
340 shown.push(guard)
341 texts.push(fired.toastTexts[i] ?? '')
342 }
343 }
344 if (texts.length > 0) {
345 $.ui.toast(texts.join(' '))
346 // Only a toast actually shown takes a ring row, so suppressed repeats can't push real denials out.
347 await safeRecord($, shown, hash, 'toast')
348 }
349 return admit($, e, next)
350}
351
352/** Removes and returns one live pending denial that `match` picks; decided inside update, so parallel calls can't share one. */
353async function takeDenial($: EngineInterface, match: (d: QmDenial) => boolean, newest = false): Promise<QmDenial | undefined> {
354 let taken: QmDenial | undefined
355 await update($, denied, list => {
356 const result = takeOne(list, match, newest)
357 taken = result.taken
358 return result.rest
359 })
360 return taken
361}
362
363const onSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
364 const view = viewOf(e)
365 const hash = spawnHash(view)
366 const source = sourceOf(e.workflow !== undefined, next.origin.plugin)
367 if (source !== null) return toastOnly($, e, next, view, source, await judge($, view, sourceLabel(source)), hash)
368
369 const now = await $.clock.now()
370 // The soft-deny contract: an unchanged re-issue runs, whatever the guards say now. One denial, one re-issue.
371 // Checked before judge, so nothing judge reads (usage, the store) can affect a re-issue.
372 const same = await takeDenial($, d => d.hash === hash && isLive(d, view.loop, now))
373 if (same !== undefined) {
374 await safeRecord($, same.guards, hash, 'reissued')
375 return admit($, e, next)
376 }
377
378 const fired = await judge($, view, 'the model')
379
380 const task = taskHash(view)
381 const earlier = await takeDenial($, d => d.task === task && isLive(d, view.loop, now), true)
382 const { changed, deny } =
383 earlier === undefined ? { changed: [] as QmGuard[], deny: fired.guards } : settle(earlier.guards, fired.guards, view)
384 if (earlier !== undefined) await safeRecord($, changed, earlier.hash, 'changed')
385 if (deny.length === 0) return admit($, e, next)
386
387 const entry: QmDenial = { hash, task, loop: view.loop, guards: deny, ts: now }
388 // The earlier denial stays live, minus the guards already credited as changed, so an unchanged re-issue
389 // of either call still matches its own hash and `changed` is never credited twice.
390 const kept = earlier === undefined ? [] : [{ ...earlier, guards: earlier.guards.filter(g => !changed.includes(g)) }]
391 await update($, denied, list =>
392 [...list.filter(d => now - d.ts <= DENIAL_TTL_MS), ...kept, entry].slice(-DENIALS_MAX),
393 )
394 await safeRecord($, deny, hash, 'denied')
395 return { deny: deny.map(g => fired.denyTexts[fired.guards.indexOf(g)] ?? '').join('\n') }
396}
397
398// ==== tally ====
399
400/** Counts distinct subagents by agentId: a resumed subagent's later turns carry the same id. */
401const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
402 const ended = await next(e)
403 const agentId = e.agentId
404 if (agentId === undefined) {
405 // A main-loop turn: re-arms the heartbeat under a new session id after /clear.
406 await ensureHeartbeat($)
407 return ended
408 }
409 if ((await read($, agents))[agentId] !== undefined) return ended
410 const model = (await read($, spawnModels))[agentId] ?? e.usage?.model ?? 'unknown'
411 await update($, agents, map => (map[agentId] === undefined ? { ...map, [agentId]: model } : map))
412 await refreshStatus($)
413 return ended
414}
415
416// ==== lifecycle ====
417
418/** Empties every session-scoped value, and this module's toast claims. Values never go undefined. */
419async function clearSession($: EngineInterface): Promise<void> {
420 claimed.clear()
421 await update($, denied, () => [])
422 await update($, agents, () => ({}))
423 await update($, spawnModels, () => ({}))
424 await update($, sourceToasts, () => [])
425 await update($, sourceSpawns, () => ({}))
426 // No session.start follows: the next measure or main turn re-arms the heartbeat under the new id.
427 await update($, health, () => null)
428}
429
430const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
431 $.ui.log(`quartermaster: list options arrived as ${current.shapes}`, { to: 'debug' })
432 // session.start fires on every hot reload too: each load gets a fresh heartbeat.
433 await ensureHeartbeat($, true)
434 await registerCommand($)
435 const { rateLimits } = await $.session.usage()
436 await recordReading($, rateLimits)
437 await refreshStatus($, rateLimits)
438 return next(e)
439}
440
441const onMeasure: Hook<'session.measure'> = async ($, e, next) => {
442 // Re-arms the heartbeat under a new session id after /clear.
443 await ensureHeartbeat($)
444 await recordReading($, e.rateLimits)
445 await refreshStatus($, e.rateLimits)
446 return next(e)
447}
448
449const onSessionEnd: Hook<'session.end'> = async ($, e, next) => {
450 // Before next(e): one 1.5 s bound covers the whole session.end chain, core's end step included.
451 if (e.reason === 'clear' || e.reason === 'resume') {
452 await clearSession($)
453 await refreshStatus($)
454 }
455 return next(e)
456}
457
458// ==== report ====
459
460const COMMAND = 'quartermaster'
461export const REPORT_FAILED = 'Quartermaster: the report failed; the debug log has the reason.'
462
463/** `model guard: 12 fires · 3 re-issued (25%) · 8 changed (67%)`: a high re-issue rate means narrow or remove it. */
464function guardLine(name: string, c: Counter): string {
465 const rate = (n: number) => (c.fires === 0 ? '—' : `${Math.round((100 * n) / c.fires)}%`)
466 return `${name}: ${c.fires} fires · ${c.reissued} re-issued (${rate(c.reissued)}) · ${c.changed} changed (${rate(c.changed)})`
467}
468
469const onCommand: Hook<'command.run'> = async $ => {
470 const cfg = current.cfg
471 const counters = await readCounters($)
472 const ring = await readRing($)
473 const window = fiveHour((await $.session.usage()).rateLimits)
474 const cap =
475 window === null || window.resetsAt === null ? null : fitCap((await windowReadings($, window.resetsAt)).readings)
476 const sources = Object.entries(await read($, sourceSpawns)).map(([source, n]) => `${source} ${n}`)
477 const lines = [
478 'Quartermaster',
479 guardLine('model guard', counters.model),
480 guardLine('heavy guard', counters.heavy),
481 window === null
482 ? 'pace: no five-hour reading (rate limits come with a subscription)'
483 : `pace: ${paceClause(cap, window.resetsAt, window.pct, await $.clock.now())}, window at ${window.pct}%`,
484 `this session: ${agentsClause(await read($, agents), cfg.heavyModels)}`,
485 `toast-only spawns this session: ${sources.join(', ') || 'none'}`,
486 `ring: ${ring.length} of the last ${RING_MAX} outcomes kept`,
487 `config: warnAt ${cfg.warnAt}% · heavy ${cfg.heavyModels.join(', ') || 'none'} · guard types ${cfg.guardTypes.join(', ') || 'none'} · requireModel ${cfg.requireModel ? 'on' : 'off'} · list options arrived as ${current.shapes}`,
488 ]
489 return { text: lines.join('\n') }
490}
491
492// ==== register ====
493
494export const register: Register = (on, options) => {
495 current.cfg = parseConfig(options)
496 current.shapes = `heavyModels ${shapeOf(options['heavyModels'])}, guardTypes ${shapeOf(options['guardTypes'])}`
497
498 on('session.start', onSessionStart).catch(async ($, e, next) => {
499 await noteFailure($, 'session.start', next.error)
500 return next(e)
501 })
502 // Fail open: a broken guard lets the spawn through (next.called replays, never re-runs).
503 on('agent.spawn', onSpawn).catch(async ($, e, next) => {
504 await noteFailure($, 'agent.spawn', next.error)
505 return next(e)
506 })
507 on('session.measure', onMeasure).catch(async ($, e, next) => {
508 await noteFailure($, 'session.measure', next.error)
509 return next(e)
510 })
511 on('turn.complete', onTurnComplete).catch(async ($, e, next) => {
512 await noteFailure($, 'turn.complete', next.error)
513 return next(e)
514 })
515 on('session.end', onSessionEnd).catch(async ($, e, next) => {
516 await noteFailure($, 'session.end', next.error)
517 return next(e)
518 })
519 on('command.run', { command: COMMAND }, onCommand).catch(async ($, _e, next) => {
520 await noteFailure($, 'command.run', next.error)
521 return { text: REPORT_FAILED }
522 })
523}
524hooks/config.ts 51 lines1import type { PluginOptions } from 'claude-code'
2
3export type QmConfig = {
4 warnAt: number
5 heavyModels: string[]
6 guardTypes: string[]
7 requireModel: boolean
8}
9
10export const DEFAULTS: QmConfig = {
11 warnAt: 75,
12 heavyModels: ['opus', 'fable'],
13 guardTypes: ['general-purpose', 'claude'],
14 requireModel: true,
15}
16
17/** The live config, set by register() on every load; read by the hook handlers. */
18export const current: { cfg: QmConfig; shapes: string } = {
19 cfg: { ...DEFAULTS, heavyModels: [...DEFAULTS.heavyModels], guardTypes: [...DEFAULTS.guardTypes] },
20 shapes: 'unknown',
21}
22
23/** A list option arrives as an array or as a comma-separated string; either reads the same. */
24export function toList(value: unknown, fallback: string[]): string[] {
25 if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(v => v !== '')
26 if (typeof value === 'string') return value.split(',').map(v => v.trim()).filter(v => v !== '')
27 return [...fallback]
28}
29
30/** How an option value arrived, for the debug log and /quartermaster (pins how list options arrive). */
31export function shapeOf(value: unknown): string {
32 return Array.isArray(value) ? 'array' : typeof value
33}
34
35function toPercent(value: unknown): number {
36 const n =
37 typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN
38 return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : DEFAULTS.warnAt
39}
40
41export function parseConfig(options: PluginOptions): QmConfig {
42 const requireModel = options['requireModel']
43 return {
44 warnAt: toPercent(options['warnAt']),
45 // Lower-cased once here: heavy matching is a case-insensitive substring.
46 heavyModels: toList(options['heavyModels'], DEFAULTS.heavyModels).map(m => m.toLowerCase()),
47 guardTypes: toList(options['guardTypes'], DEFAULTS.guardTypes),
48 requireModel: typeof requireModel === 'boolean' ? requireModel : DEFAULTS.requireModel,
49 }
50}
51hooks/hash.ts 29 lines1/** cyrb53: a fast 53-bit string hash. A collision only costs one wrong re-issue match. */
2export function cyrb53(text: string, seed = 0): string {
3 let h1 = 0xdeadbeef ^ seed
4 let h2 = 0x41c6ce57 ^ seed
5 for (let i = 0; i < text.length; i++) {
6 const ch = text.charCodeAt(i)
7 h1 = Math.imul(h1 ^ ch, 2654435761)
8 h2 = Math.imul(h2 ^ ch, 1597334677)
9 }
10 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
11 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
12 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0')
13}
14
15export type SpawnFields = { prompt: string; description: string; subagentType: string; model: string | undefined }
16
17/** Re-issue identity: prompt, description, agent type and model, never tool_use_id (a re-issue gets a new one). */
18export function spawnHash(f: SpawnFields): string {
19 return cyrb53(JSON.stringify([f.prompt, f.description, f.subagentType, f.model ?? null]))
20}
21
22/**
23 * Task identity: the same prompt and description under any model or agent type. Links a changed call
24 * back to its deny; switching to a typed agent such as Explore is acting on the deny too.
25 */
26export function taskHash(f: Pick<SpawnFields, 'prompt' | 'description'>): string {
27 return cyrb53(JSON.stringify([f.prompt, f.description]))
28}
29hooks/pace.ts 103 lines1import type { SessionRateLimit } from 'claude-code'
2
3export type Reading = [ts: number, pct: number]
4export type FiveHour = { pct: number; resetsAt: number | null }
5
6/** The five-hour window from rateLimits, an array by kind; resetsAt may be missing. */
7export function fiveHour(limits: readonly SessionRateLimit[]): FiveHour | null {
8 const window = limits.find(limit => limit.kind === 'five_hour')
9 if (window === undefined) return null
10 const resetsAt = window.resetsAt === undefined ? NaN : Date.parse(window.resetsAt)
11 return { pct: window.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }
12}
13
14/** A new window's store key: resetsAt to the nearest minute. Rounding alone splits jitter across hh:mm:30. */
15export function windowKey(resetsAt: number): string {
16 return String(Math.round(resetsAt / 60_000) * 60_000)
17}
18
19export const WINDOW_SLACK_MS = 60_000
20
21/**
22 * The store key for resetsAt: the nearest existing key within a minute of it, else a new windowKey.
23 * Sub-minute jitter stays one window on either side of hh:mm:30.
24 */
25export function matchWindow(keys: readonly string[], resetsAt: number): string {
26 let best: string | null = null
27 for (const key of keys) {
28 const gap = Math.abs(Number(key) - resetsAt)
29 if (gap <= WINDOW_SLACK_MS && (best === null || gap < Math.abs(Number(best) - resetsAt))) best = key
30 }
31 return best ?? windowKey(resetsAt)
32}
33
34/** Least-squares line of percent over time; when it reaches 100, or null under 3 readings or no growth. */
35export function fitCap(readings: readonly Reading[]): number | null {
36 if (readings.length < 3) return null
37 const t0 = readings[0]![0]
38 const xs = readings.map(r => (r[0] - t0) / 60_000)
39 const ys = readings.map(r => r[1])
40 const n = readings.length
41 const mx = xs.reduce((a, b) => a + b, 0) / n
42 const my = ys.reduce((a, b) => a + b, 0) / n
43 let sxy = 0
44 let sxx = 0
45 for (let i = 0; i < n; i++) {
46 sxy += (xs[i]! - mx) * (ys[i]! - my)
47 sxx += (xs[i]! - mx) ** 2
48 }
49 if (sxx === 0) return null
50 const slope = sxy / sxx
51 if (slope <= 0) return null
52 const intercept = my - slope * mx
53 return t0 + Math.round(((100 - intercept) / slope) * 60_000)
54}
55
56/** HH:MM in the hooks environment's local time (not UTC). */
57export function clockText(ms: number): string {
58 const d = new Date(ms)
59 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
60}
61
62/** `capped (resets 17:00)`, `capped soon (resets 17:00)` for a stale fit, `cap ~15:40 (resets 17:00)`, `no cap before reset (resets 17:00)`, or `— (resets 17:00)`. */
63export function paceClause(cap: number | null, resetsAt: number | null, pct = 0, now = 0): string {
64 const resets = resetsAt === null ? '' : ` (resets ${clockText(resetsAt)})`
65 // At 100% the fitted cap time is in the past: say so instead of projecting it.
66 if (pct >= 100) return `capped${resets}`
67 // A fit that puts the cap before now (a steep rise that then flattened) is stale too.
68 if (cap !== null && now > 0 && cap <= now) return `capped soon${resets}`
69 if (cap === null) return `—${resets}`
70 if (resetsAt !== null && cap >= resetsAt) return `no cap before reset${resets}`
71 return `cap ~${clockText(cap)}${resets}`
72}
73
74/** `five-hour window at 82%, resets 17:00; on pace to cap at 15:40`: the reading the heavy guard and toasts give. */
75export function windowText(pct: number, resetsAt: number | null, cap: number | null, now = 0): string {
76 const resets = resetsAt === null ? '' : `, resets ${clockText(resetsAt)}`
77 const pace =
78 pct >= 100
79 ? 'already capped'
80 : cap !== null && now > 0 && cap <= now
81 ? 'about to cap'
82 : cap === null
83 ? 'pace unknown'
84 : resetsAt !== null && cap >= resetsAt
85 ? 'not on pace to cap before the reset'
86 : `on pace to cap at ${clockText(cap)}`
87 return `five-hour window at ${pct}%${resets}; ${pace}`
88}
89
90/** `agents 4 (2 opus)`: distinct subagents, and how many ran on each heavy model (zeros left out). */
91export function agentsClause(tally: Readonly<Record<string, string>>, heavyModels: readonly string[]): string {
92 const models = Object.values(tally).map(m => m.toLowerCase())
93 const heavy = heavyModels
94 .map(token => [token, models.filter(m => m.includes(token)).length] as const)
95 .filter(([, count]) => count > 0)
96 .map(([token, count]) => `${count} ${token}`)
97 return `agents ${models.length}${heavy.length > 0 ? ` (${heavy.join(', ')})` : ''}`
98}
99
100export function statusText(pace: string, agentsText: string): string {
101 return `QM pace: ${pace} · ${agentsText}`
102}
103hooks/rules.ts 123 lines1import type { AgentSpawnInput } from 'claude-code'
2import type { QmDenial, QmGuard } from '../types'
3import type { QmConfig } from './config'
4
5// The guards' decisions, with no `$`: register.ts reads the world and acts; this file only decides.
6
7export const MODEL_TEXT =
8 'Quartermaster: set `model` for this agent from its task (haiku: mechanical; sonnet: well-specified; opus: judgement). Re-issue unchanged to keep the inherited model.'
9
10/** A denial answers a re-issue or a changed call only this long. */
11export const DENIAL_TTL_MS = 10 * 60_000
12
13/** What the guards read of a spawn. A blank model is unset; `loop` is `parentAgentId`, or `main`. */
14export type SpawnView = {
15 prompt: string
16 description: string
17 subagentType: string
18 fork: boolean
19 parentModel: string
20 model: string | undefined
21 loop: string
22}
23
24/** The guards that fired, with aligned texts (the deny for a model's call, the toast otherwise) and the window. */
25export type Fired = { guards: QmGuard[]; denyTexts: string[]; toastTexts: string[]; windowKey: string | null }
26
27type LooseSpawn = Partial<Record<keyof AgentSpawnInput, unknown>> & { subagent_type?: unknown }
28
29/**
30 * Reads a spawn event in either shape: AgentSpawnInput, or the Agent tool's input that the 2.1.292 test kit
31 * gives a plugin's own spawn (`subagent_type`; no `subagentType`, `fork` or `parentModel`).
32 */
33export function viewOf(input: AgentSpawnInput): SpawnView {
34 const e = input as unknown as LooseSpawn
35 const text = (value: unknown) => (typeof value === 'string' ? value : '')
36 const type = text(e.subagentType) || text(e.subagent_type)
37 const model = text(e.model).trim()
38 return {
39 prompt: text(e.prompt),
40 description: text(e.description),
41 // An Agent call with no subagent_type is general-purpose.
42 subagentType: type === '' ? 'general-purpose' : type,
43 fork: e.fork === true,
44 parentModel: text(e.parentModel),
45 model: model === '' ? undefined : model,
46 loop: text(e.parentAgentId) || 'main',
47 }
48}
49
50/**
51 * Who can't re-issue a denied spawn: a workflow script's `agent()` (`e.workflow` set), or another
52 * plugin's `$.agent.spawn` (`next.origin`). null is the model's own Agent call, the only one ever denied.
53 */
54export function sourceOf(isWorkflow: boolean, originPlugin: string): string | null {
55 if (isWorkflow) return 'workflow'
56 return originPlugin === 'engine' ? null : originPlugin
57}
58
59export function sourceLabel(source: string): string {
60 return source === 'workflow' ? 'a workflow' : source
61}
62
63/** One toast per key per session: the model toast per (source, type), the heavy toast per (source, window). */
64export function toastKey(guard: QmGuard, source: string, subagentType: string, window: string | null): string {
65 return guard === 'model' ? `model/${source}/${subagentType}` : `heavy/${source}/${window ?? 'none'}`
66}
67
68export function modelGuardFires(view: SpawnView, cfg: QmConfig): boolean {
69 return cfg.requireModel && !view.fork && view.model === undefined && cfg.guardTypes.includes(view.subagentType)
70}
71
72/**
73 * The model a spawn will run on, where that is knowable before it starts. A fork always inherits; an
74 * unset model on a guarded type inherits the parent's (assuming no default subagent model is configured);
75 * an unset model on any other type is its definition's, which no event shows, so it is unknown.
76 */
77export function effectiveModel(view: SpawnView, cfg: QmConfig): string | null {
78 if (view.fork) return view.parentModel
79 if (view.model !== undefined) return view.model
80 return cfg.guardTypes.includes(view.subagentType) ? view.parentModel : null
81}
82
83/** Substring, any case: `opus` matches the alias and `claude-opus-5-5`. heavyModels is lower-cased. */
84export function isHeavy(model: string | null, heavyModels: readonly string[]): boolean {
85 if (model === null || model === '') return false
86 const id = model.toLowerCase()
87 return heavyModels.some(token => id.includes(token))
88}
89
90/** A pending denial counts only in the loop it was denied in, and only for DENIAL_TTL_MS. */
91export function isLive(d: QmDenial, loop: string, now: number): boolean {
92 return d.loop === loop && now - d.ts <= DENIAL_TTL_MS
93}
94
95/**
96 * Removes one entry `match` picks, and only that one: three identical denials answer three re-issues. By default
97 * the first match; `newest` takes the last, so a task match settles against the denial the model was just shown.
98 */
99export function takeOne(
100 list: readonly QmDenial[],
101 match: (d: QmDenial) => boolean,
102 newest = false,
103): { rest: QmDenial[]; taken: QmDenial | undefined } {
104 const i = newest ? list.findLastIndex(match) : list.findIndex(match)
105 if (i < 0) return { rest: [...list], taken: undefined }
106 return { rest: [...list.slice(0, i), ...list.slice(i + 1)], taken: list[i] }
107}
108
109/**
110 * How a call repeating an earlier denial's task settles each guard:
111 * - a guard of the denial that the new call no longer trips is `changed`;
112 * - after a heavy deny, a call that names a model itself and still trips the heavy guard is an informed
113 * override: `changed`, and it does not deny again;
114 * - any guard that still trips otherwise denies again, as a fresh fire.
115 */
116export function settle(earlier: readonly QmGuard[], fired: readonly QmGuard[], view: SpawnView): { changed: QmGuard[]; deny: QmGuard[] } {
117 // A fork ignores `model` and always runs on the parent's, so naming one is no override.
118 const overridden = earlier.includes('heavy') && fired.includes('heavy') && view.model !== undefined && !view.fork
119 const changed = earlier.filter(g => !fired.includes(g) || (g === 'heavy' && overridden))
120 const deny = fired.filter(g => !(g === 'heavy' && overridden))
121 return { changed, deny }
122}
123types/index.d.ts 28 lines1export type QmGuard = 'model' | 'heavy'
2
3/**
4 * A spawn denied once and waiting for its re-issue: the full hash, the task hash, the loop it was
5 * denied in (`parentAgentId`, or `main`), the guards that denied it, and when.
6 */
7export type QmDenial = { hash: string; task: string; loop: string; guards: QmGuard[]; ts: number }
8
9/** What the heartbeat file says, and for which session. */
10export type QmHealth = {
11 sessionId: string
12 loadedAt: number
13 lastError: { ts: number; message: string } | null
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 quartermaster: {
19 denied: QmDenial[]
20 agents: Record<string, string>
21 spawnModels: Record<string, string>
22 sourceToasts: string[]
23 sourceSpawns: Record<string, number>
24 health: QmHealth | null
25 }
26 }
27}
28