Asks a local Clef decision model whether an expensive tool call or subagent is actually needed before Claude Code pays for it. Logs every decision; shadow mode…

A Claude Code mod that asks a local Clef model one question before Claude Code pays for an expensive tool call or a subagent: is this actually needed?
Fix the typo in README.md
-> WebSearch "how to spell receive" gate: needed 8% · would deny
How do I install example.com's CLI?
-> WebFetch https://example.com/docs gate: needed 90% · allow
What does the User class do?
-> Agent(Explore) "summarise User" gate: warranted 8% · would deny
Tool definitions are already cheap in Claude Code; tool search defers them. The tokens go on tool results and subagents: a fetched page, a search, a whole extra model session. This puts a quick yes/no in front of those and logs every decision with Clef's probabilities, so you can see what it would have saved before you let it refuse anything.
Nothing leaves your machine. Clef runs on your own GPU or CPU through llama.cpp or Ollama.
Status: v0.1. Nobody has calibrated Clef on these questions yet, which is what the log is for. It ships in shadow mode: it decides, shows and logs, and refuses nothing until you switch it. Tested with Claude Code 2.1.286 and llama.cpp b11384.
For watched tools (default WebFetch and WebSearch) the mod builds a short description of the turn so far, your request, the calls made, the last assistant text, the proposed call, and asks Clef two yes/no questions: is this call needed, and is its result already in the conversation. For subagents it asks one: does this task justify a separate subagent.
A short prompt like "go ahead" says nothing about the task, so when your prompt is under 50 characters the previous request is included too.
In shadow mode it logs would-deny. In deny mode it refuses the call and the model reads a short reason. A refused call made again with the same arguments always goes through, without asking Clef again, so a wrong refusal costs one round trip and never blocks work.
If Clef is down, slow or wrong, the call runs. A circuit breaker stops a dead server costing a timeout per call. Allowed calls record their result size, so /gate stats can estimate what refusals saved.
Not measured yet. These are estimates from what the pieces cost, so you can judge whether it is worth running; the log will replace them with your own numbers.
| Avoided call | Tokens it would have added to the context |
|---|---|
| One web search | about 400 to 1,000 |
| One web fetch | about 1,000 to 3,000 after Claude Code's own summarising |
| One subagent | 50,000 to 150,000. Two research subagents in one of my sessions used 110,000 and 120,000 each. |
Every token a tool result adds is paid again on every later turn of the session, so an early unnecessary fetch costs many times its own size. Subagents are the big one: avoiding one pointless subagent a day saves more than a week of avoided searches.
What is measured: on the clear cases Clef separates well. A web search to check a spelling during a typo fix scored 8% needed; a fetch for current release notes scored 90%; fetching a page already fetched this turn scored 96% already-in-context. On a 13900K with no GPU a decision takes about 3 s for a 400-token state.
llama.cpp build b11379 or later from the releases page:
llama-server -hf ggml-org/Clef-Flash-GGUF:Q4_K_M --alias clef-flash --host 127.0.0.1 --port 8080 -ngl 99 -c 8192 -np 2 -b 8192 -ub 8192 --no-webui
The first run downloads the 6.5 GB model. The batch flags matter: Clef scores all questions in one sequence and needs a batch bigger than the default.
Or Ollama 0.35.1+: ollama pull clef (the 27B, 18 GB; Ollama does not load the Clef-Flash GGUF yet). Set the endpoint to http://127.0.0.1:11434/v1/systemone and the model to clef.
claude plugin marketplace add dwain-barnes/clef-gate
claude plugin install clef-gate@clef-gate
Or for one session: clone this repo and run claude --plugin-dir ./clef-gate.
You should see gate · shadow · awaiting calls at the end of the hint line under the prompt. /gate shows the full status.
Run in shadow mode for a few sessions, then look:
/gate stats counts per tool, Clef latency, estimated tokens you would have saved
/gate history this session's decisions with the probabilities
When the would-deny lines look right, /gate deny for this session, or set Mode to deny in /config to make it the default. /gate shadow goes back, /gate off stops gating.
Add tools in the Gated tools setting, e.g. WebFetch, WebSearch, mcp__github__*. Thresholds, timeout and state size live in an optional ~/.claude/clef-gate.json, see docs/CONFIGURATION.md. Reading the log and changing the questions: docs/CALIBRATION.md.
On a GPU a decision takes well under a second. On CPU only (a 24-thread desktop measured about 4 to 5 s for a 400-token state) every gated call waits that long, which is why the default state is small. The Clef timeout is 6 s, then the call runs ungated. Parallel calls are checked one after another because the server scores one request at a time.
Your request text, a one-line summary of the proposed call and the names of earlier calls go to the Clef server on your machine. Bearer tokens, key=value secrets and long opaque strings are redacted first. The log keeps hashes and lengths of arguments unless you turn on Log prompt and argument text.
Apache-2.0. See LICENSE and NOTICE.
hooks/register.ts 454 lines1// clef-gate: ask a local Clef whether a tool call or subagent is worth paying for.
2//
3// turn.start remembers the prompt; tool.call and agent.spawn ask Clef and allow or
4// deny; turn.complete writes the log. Every failure path lets the call run.
5
6import type { EngineInterface, PluginOptions, Register } from "claude-code"
7
8import { breakerOpenFor, normaliseBreaker, recordFailure, recordSuccess, type BreakerState } from "./lib/breaker.ts"
9import { parseAdvancedFile, parseConfig, type Config } from "./lib/config.ts"
10import { argsHash, buildAgentState, buildToolState, summariseArgs, TERSE_PROMPT_CHARS, type CallSeen } from "./lib/context.ts"
11import { answerLine, HELP, historyReport, statsReport, statusLine, statusReport, type HistoryRow, type TurnCounts } from "./lib/format.ts"
12import { aggregate, gateRecord, logFileName, parseLines } from "./lib/log.ts"
13import { agentVerdict, denyText, matchesTool, toolVerdict, type Mode, type PolicyConfig } from "./lib/policy.ts"
14import { agentQuestions, DEFAULT_RUBRIC, parseRubric, toolQuestions, type Rubric } from "./lib/rubric.ts"
15import { decide, type NoulQuestion } from "./lib/systemone.ts"
16import type { ClefResult, Decision, GateKind } from "./lib/types.ts"
17
18const PLUGIN = "clef-gate"
19const SESSION_REF = { plugin: "clef-gate", key: "session" } as const
20const BREAKER_KEY = "breaker"
21const HISTORY_LIMIT = 50
22
23type Persisted = {
24 sessionMode?: Mode | "off"
25 history: HistoryRow[]
26 warned: string[]
27}
28
29type Turn = {
30 turnId: string
31 prompt: string
32 calls: CallSeen[]
33 denied: string[]
34 decisions: Decision[]
35 counts: TurnCounts
36}
37
38let options: PluginOptions = {}
39let state: Persisted = { history: [], warned: [] }
40let loaded = false
41let config: Config = parseConfig({}).config
42let problems: string[] = []
43let rubric: Rubric = DEFAULT_RUBRIC
44let logDir: string | undefined
45let advancedPath: string | undefined
46let logFile: string | undefined
47let logLines: string[] = []
48let turn: Turn | undefined
49let hint: string | undefined
50let clefQueue: Promise<unknown> = Promise.resolve()
51
52function newTurn(turnId: string, prompt: string): Turn {
53 return { turnId, prompt, calls: [], denied: [], decisions: [], counts: { checked: 0, denied: 0, wouldDeny: 0 } }
54}
55
56function mode(): Mode | "off" {
57 if (!config.enabled) return "off"
58 return state.sessionMode ?? config.mode
59}
60
61function policy(): PolicyConfig {
62 const m = mode()
63 return { mode: m === "off" ? "shadow" : m, denyThreshold: config.denyThreshold, redundantThreshold: config.redundantThreshold, agentThreshold: config.agentThreshold }
64}
65
66async function save($: EngineInterface) {
67 await $.state.set(SESSION_REF, JSON.stringify(state)).catch(() => {})
68}
69
70async function load($: EngineInterface) {
71 if (loaded) return
72 loaded = true
73 try {
74 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "."
75 const configDir = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? `${home}/.claude`
76 advancedPath = (await $.env.get("CLEF_GATE_CONFIG")) ?? `${configDir}/${PLUGIN}.json`
77 const advancedText = await $.fs.read(advancedPath).catch(() => undefined)
78 const advanced = parseAdvancedFile(typeof advancedText === "string" ? advancedText : undefined)
79 const parsed = parseConfig({ ...advanced.values, ...options })
80 config = parsed.config
81 problems = [...advanced.problems, ...parsed.problems]
82 if (config.rubricFile) {
83 const text = await $.fs.read(config.rubricFile).catch(() => undefined)
84 const r = typeof text === "string" ? parseRubric(text) : { problems: [`cannot read rubric_file ${config.rubricFile}`] }
85 if ("rubric" in r) rubric = r.rubric
86 else problems.push(...r.problems.map((p) => `${p}; using the built-in rubric`))
87 }
88 logDir = config.logDir ?? `${configDir}/plugins/data/${PLUGIN}`
89 const snapshot = await $.state.get(SESSION_REF)
90 if (typeof snapshot.value === "string") state = { history: [], warned: [], ...(JSON.parse(snapshot.value) as Partial<Persisted>) }
91 } catch (err) {
92 problems.push(`setup: ${err instanceof Error ? err.message : String(err)}`)
93 }
94}
95
96function warnOnce($: EngineInterface, key: string, text: string) {
97 if (state.warned.includes(key)) return
98 state.warned.push(key)
99 $.ui.toast(text, { timeoutMs: 8000 })
100}
101
102function showStatus($: EngineInterface, text: string | undefined) {
103 const next = text === undefined ? undefined : `↳ ${text}`
104 if (next === hint) return
105 hint = next
106 $.ui.invalidate("ui.render")
107}
108
109function announce($: EngineInterface) {
110 if (config.announce !== "status" && config.announce !== "both") return
111 const m = mode()
112 if (m === "off") return showStatus($, "gate · off")
113 showStatus($, statusLine(m, turn?.counts ?? { checked: 0, denied: 0, wouldDeny: 0 }))
114}
115
116async function readBreaker($: EngineInterface): Promise<BreakerState> {
117 return normaliseBreaker(await $.store.get(BREAKER_KEY).catch(() => undefined))
118}
119
120// The local server scores one request at a time. Parallel tool calls would queue
121// inside it and the later ones would hit our timeout, so we serialise here.
122function askClef($: EngineInterface, stateText: string, questions: Record<string, NoulQuestion>): Promise<ClefResult> {
123 const run = clefQueue.then(() => askClefNow($, stateText, questions))
124 clefQueue = run.catch(() => undefined)
125 return run
126}
127
128async function askClefNow($: EngineInterface, stateText: string, questions: Record<string, NoulQuestion>): Promise<ClefResult> {
129 const now = await $.clock.now()
130 const breaker = await readBreaker($)
131 const open = breakerOpenFor(breaker, now)
132 if (open !== undefined) return { ok: false, kind: "circuit-open", message: `paused ${open}s after repeated failures`, latencyMs: 0 }
133 const result = await decide(
134 {
135 endpoint: config.endpoint,
136 model: config.model,
137 timeoutMs: config.timeoutMs,
138 fetch: async (url, init) => {
139 const r = await $.http.fetch(url, init)
140 return { status: r.status, ok: r.ok, text: r.text }
141 },
142 sleep: (ms, signal) => $.clock.sleep(ms, { signal }),
143 now: () => $.clock.now(),
144 },
145 stateText,
146 questions,
147 )
148 const after = result.ok ? recordSuccess(breaker) : recordFailure(breaker, await $.clock.now())
149 await $.store.set(BREAKER_KEY, after).catch(() => {})
150 if (!result.ok && result.kind !== "circuit-open")
151 warnOnce($, `clef-${result.kind}`, `Clef gate: ${result.kind} talking to ${config.endpoint} (${result.message}). Calls run ungated until it answers.`)
152 return result
153}
154
155async function lastAssistantText($: EngineInterface): Promise<string | undefined> {
156 const rows = await $.session.messages().catch(() => [])
157 for (let i = rows.length - 1; i >= 0; i--) {
158 const r = rows[i]!
159 if (r.role === "assistant" && r.text.trim() !== "") return r.text
160 }
161 return undefined
162}
163
164async function previousUserText($: EngineInterface, current: string): Promise<string | undefined> {
165 if (current.trim().length >= TERSE_PROMPT_CHARS) return undefined
166 const rows = await $.session.messages().catch(() => [])
167 const users = rows.filter((r) => r.role === "user" && r.text.trim() !== "").map((r) => r.text)
168 if (users.at(-1)?.trim() === current.trim()) users.pop()
169 return users.at(-1)
170}
171
172function remember($: EngineInterface, d: Decision, ts: string) {
173 if (!turn) return
174 d.ts = ts
175 turn.decisions.push(d)
176 if (d.verdict !== "skip") {
177 turn.counts.checked++
178 if (d.verdict === "deny") turn.counts.denied++
179 if (d.verdict === "would-deny") turn.counts.wouldDeny++
180 turn.counts.last = { tool: d.tool, verdict: d.verdict, reason: d.reason }
181 }
182 state.history.push({ ts, kind: d.kind, tool: d.tool, verdict: d.verdict, reason: d.reason, summary: d.summary })
183 while (state.history.length > HISTORY_LIMIT) state.history.shift()
184 announce($)
185}
186
187async function appendLog($: EngineInterface, lines: string[], ts: string) {
188 if (!config.logEnabled || !logDir || lines.length === 0) return
189 try {
190 const file = `${logDir}/${logFileName(ts, await $.session.id())}`
191 if (file !== logFile) {
192 logFile = file
193 const existing = await $.fs.read(file).catch(() => "")
194 logLines = typeof existing === "string" && existing !== "" ? existing.trimEnd().split("\n") : []
195 }
196 logLines.push(...lines)
197 await $.fs.write(file, logLines.join("\n") + "\n")
198 } catch {
199 // the log must never cost the turn anything
200 }
201}
202
203async function gate(
204 $: EngineInterface,
205 kind: GateKind,
206 tool: string,
207 summary: string,
208 hash: string,
209 argsChars: number,
210 stateText: string,
211 questions: Record<string, NoulQuestion>,
212): Promise<Decision> {
213 const base = { kind, tool, argsHash: hash, argsChars, summary }
214 if (turn?.denied.includes(hash)) {
215 const v = kind === "tool" ? toolVerdict({}, policy(), true) : agentVerdict({}, policy(), true)
216 return { ...base, ...v }
217 }
218 const result = await askClef($, stateText, questions)
219 if (!result.ok) return { ...base, verdict: "fail-open", reason: `${result.kind}: ${result.message}`, result }
220 const v = kind === "tool" ? toolVerdict(result.probabilities, policy(), false) : agentVerdict(result.probabilities, policy(), false)
221 if (v.verdict === "deny" && turn) turn.denied.push(hash)
222 return { ...base, ...v, result }
223}
224
225async function statusText($: EngineInterface): Promise<string> {
226 const breaker = await readBreaker($)
227 const open = breakerOpenFor(breaker, await $.clock.now())
228 const m = mode()
229 const report = statusReport({
230 config,
231 configProblems: problems,
232 sessionMode: m === "off" ? config.mode : m,
233 counts: turn?.counts ?? { checked: 0, denied: 0, wouldDeny: 0 },
234 breaker: { calls: breaker.calls, failures: breaker.failures, ...(open !== undefined ? { openForSeconds: open } : {}) },
235 ...(logDir ? { logDir } : {}),
236 ...(advancedPath ? { advancedPath } : {}),
237 })
238 return m === "off" && config.enabled ? `${report}\n\nGating is OFF for this session (/gate on resumes).` : report
239}
240
241async function statsText($: EngineInterface, days: number): Promise<string> {
242 if (!logDir) return "No log directory."
243 const now = await $.clock.now()
244 const since = new Date(now - (days - 1) * 86_400_000).toISOString().slice(0, 10)
245 const entries = await $.fs.list(logDir).catch(() => [])
246 const files = entries.filter((f) => /^gate-\d{4}-\d{2}-\d{2}-/.test(f.name) && f.name.slice(5, 15) >= since)
247 const records = []
248 for (const f of files) {
249 const text = await $.fs.read(`${logDir}/${f.name}`).catch(() => "")
250 if (typeof text === "string") records.push(...parseLines(text))
251 }
252 return statsReport(aggregate(records), days, files.length)
253}
254
255async function runCommand($: EngineInterface, args: string): Promise<string> {
256 await load($)
257 const [word = "", rest = ""] = args.trim().split(/\s+/, 2)
258 switch (word) {
259 case "":
260 case "status":
261 return statusText($)
262 case "help":
263 return HELP
264 case "history":
265 return historyReport(state.history)
266 case "stats":
267 return statsText($, Math.max(1, Math.min(365, Number(rest) || 7)))
268 case "shadow":
269 case "deny":
270 state.sessionMode = word
271 await save($)
272 announce($)
273 return word === "deny"
274 ? "Deny mode for this session: calls Clef rates unnecessary are refused. A repeat of a refused call goes through. /gate shadow to stop refusing."
275 : "Shadow mode for this session: decisions are logged and shown, nothing is refused."
276 case "off":
277 state.sessionMode = "off"
278 await save($)
279 announce($)
280 return "Gating off for this session. /gate on resumes."
281 case "on":
282 delete state.sessionMode
283 await save($)
284 announce($)
285 return `Gating on (${config.mode} mode, from the plugin config).`
286 default:
287 return `Unknown: /gate ${word}\n\n${HELP}`
288 }
289}
290
291function toolArgs(e: Record<string, unknown>): Record<string, unknown> {
292 const { tool: _t, tool_use_id: _id, agentId: _a, consent: _c, ...args } = e
293 return args
294}
295
296function resultChars(r: unknown): number | undefined {
297 if (typeof r !== "object" || r === null) return undefined
298 const o = r as { text?: unknown; result?: unknown }
299 if (typeof o.text === "string") return o.text.length
300 if (o.result === undefined) return undefined
301 try {
302 return JSON.stringify(o.result).length
303 } catch {
304 return undefined
305 }
306}
307
308export const register: Register = (on, pluginOptions) => {
309 options = pluginOptions
310 state = { history: [], warned: [] }
311 loaded = false
312 hint = undefined
313 turn = undefined
314 clefQueue = Promise.resolve()
315 logFile = undefined
316 logLines = []
317
318 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
319 if (hint === undefined) return next(e)
320 const tail = e.props.tail ? `${e.props.tail} · ${hint}` : ` ${hint}`
321 return next({ ...e, props: { ...e.props, tail } })
322 })
323
324 on("session.start", async ($, e, next) => {
325 await load($)
326 await $.command
327 .register({
328 name: "gate",
329 description: "Clef gate: status, history, stats, shadow, deny, on, off",
330 argumentHint: "[status|history|stats [days]|shadow|deny|on|off|help]",
331 immediate: true,
332 })
333 .catch(() => {})
334 announce($)
335 return next(e)
336 })
337
338 on("session.end", async ($, e, next) => {
339 if (e.reason === "clear") {
340 turn = undefined
341 state.history = []
342 logFile = undefined
343 await save($)
344 announce($)
345 }
346 return next(e)
347 })
348
349 on("turn.start", async ($, e, next) => {
350 try {
351 await load($)
352 turn = newTurn(e.turnId, e.text)
353 announce($)
354 } catch {
355 // ungated turn
356 }
357 return next(e)
358 })
359
360 on("tool.call", async ($, e, next) => {
361 let decision: Decision | undefined
362 try {
363 await load($)
364 const m = mode()
365 if (m === "off" || !matchesTool(e.tool, config.tools) || (e.agentId !== undefined && !config.gateInSubagents) || !turn) return next(e)
366 const args = toolArgs(e as unknown as Record<string, unknown>)
367 const summary = summariseArgs(e.tool, args)
368 const hash = await argsHash(e.tool, args)
369 const stateText = buildToolState({
370 prompt: turn.prompt,
371 calls: turn.calls,
372 lastAssistant: await lastAssistantText($),
373 previousRequest: await previousUserText($, turn.prompt),
374 tool: e.tool,
375 summary,
376 maxChars: config.maxStateChars,
377 recentCalls: config.recentCalls,
378 })
379 decision = await gate($, "tool", e.tool, summary, hash, JSON.stringify(args).length, stateText, toolQuestions(rubric))
380 } catch {
381 decision = undefined
382 }
383 if (!decision) return next(e)
384 const ts = new Date(await $.clock.now()).toISOString()
385 if (decision.verdict === "deny") {
386 remember($, decision, ts)
387 turn?.calls.push({ tool: e.tool, summary: decision.summary, verdict: "deny" })
388 return { deny: denyText("tool", decision.result?.ok ? decision.result.probabilities : {}) }
389 }
390 const ran = await next(e)
391 const chars = ran.deny === undefined ? resultChars(ran) : undefined
392 if (chars !== undefined) decision.resultChars = chars
393 remember($, decision, ts)
394 turn?.calls.push({ tool: e.tool, summary: decision.summary, verdict: decision.verdict, ...(chars !== undefined ? { resultChars: chars } : {}) })
395 return ran
396 })
397
398 on("agent.spawn", async ($, e, next) => {
399 let decision: Decision | undefined
400 try {
401 await load($)
402 if (mode() === "off" || !config.gateSubagents || e.fork || e.parentAgentId !== undefined || !turn) return next(e)
403 const args = { subagent_type: e.subagentType, description: e.description, prompt: e.prompt }
404 const summary = summariseArgs("Agent", args)
405 const hash = await argsHash("Agent", args)
406 const stateText = buildAgentState({
407 prompt: turn.prompt,
408 calls: turn.calls,
409 previousRequest: await previousUserText($, turn.prompt),
410 subagentType: e.subagentType,
411 description: e.description,
412 agentPrompt: e.prompt,
413 maxChars: config.maxStateChars,
414 recentCalls: config.recentCalls,
415 })
416 decision = await gate($, "agent", e.subagentType, summary, hash, JSON.stringify(args).length, stateText, agentQuestions(rubric))
417 } catch {
418 decision = undefined
419 }
420 if (!decision) return next(e)
421 const ts = new Date(await $.clock.now()).toISOString()
422 remember($, decision, ts)
423 if (decision.verdict === "deny") {
424 turn?.calls.push({ tool: "Agent", summary: decision.summary, verdict: "deny" })
425 return { deny: denyText("agent", decision.result?.ok ? decision.result.probabilities : {}) }
426 }
427 turn?.calls.push({ tool: "Agent", summary: decision.summary, verdict: decision.verdict })
428 return next(e)
429 })
430
431 on("turn.complete", async ($, e, next) => {
432 const result = await next(e)
433 if (e.agentId !== undefined || !turn || turn.turnId !== e.turnId) return result
434 try {
435 const ts = new Date(await $.clock.now()).toISOString()
436 const session = await $.session.id()
437 const m = mode()
438 const logMode: Mode = m === "off" ? config.mode : m
439 const t = turn
440 await appendLog($, t.decisions.map((d) => JSON.stringify(gateRecord({ decision: d, session, turn: t.turnId, ts, mode: logMode, logText: config.logPrompts }))), ts)
441 await save($)
442 if ((config.announce === "answer" || config.announce === "both") && m !== "off") {
443 const line = answerLine(m, t.counts)
444 if (line) return { ...result, text: line }
445 }
446 } catch {
447 // logging only
448 }
449 return result
450 })
451
452 on("command.run", { command: "gate" }, async ($, e) => ({ text: await runCommand($, e.args) }))
453}
454hooks/lib/breaker.ts 37 lines1export type BreakerState = {
2 calls: number
3 failures: number
4 consecutiveFailures: number
5 pausedUntil?: number
6}
7
8export const BREAKER = { threshold: 3, pauseMs: 5 * 60_000 }
9
10export function freshBreaker(): BreakerState {
11 return { calls: 0, failures: 0, consecutiveFailures: 0 }
12}
13
14export function normaliseBreaker(value: unknown): BreakerState {
15 if (typeof value !== "object" || value === null) return freshBreaker()
16 const s = value as Partial<BreakerState>
17 if (typeof s.calls !== "number" || typeof s.failures !== "number" || typeof s.consecutiveFailures !== "number") return freshBreaker()
18 const out: BreakerState = { calls: s.calls, failures: s.failures, consecutiveFailures: s.consecutiveFailures }
19 if (typeof s.pausedUntil === "number") out.pausedUntil = s.pausedUntil
20 return out
21}
22
23export function breakerOpenFor(s: BreakerState, now: number): number | undefined {
24 if (s.pausedUntil !== undefined && s.pausedUntil > now) return Math.ceil((s.pausedUntil - now) / 1000)
25 return undefined
26}
27
28export function recordSuccess(s: BreakerState): BreakerState {
29 return { calls: s.calls + 1, failures: s.failures, consecutiveFailures: 0 }
30}
31
32export function recordFailure(s: BreakerState, now: number): BreakerState {
33 const next: BreakerState = { calls: s.calls + 1, failures: s.failures + 1, consecutiveFailures: s.consecutiveFailures + 1 }
34 if (next.consecutiveFailures >= BREAKER.threshold) next.pausedUntil = now + BREAKER.pauseMs
35 return next
36}
37hooks/lib/config.ts 131 lines1// Bad values fall back to defaults and get reported; nothing here throws.
2
3import type { Mode } from "./policy.ts"
4
5export const MODES = ["shadow", "deny"] as const
6export const ANNOUNCE_MODES = ["status", "answer", "both", "off"] as const
7export type AnnounceMode = (typeof ANNOUNCE_MODES)[number]
8
9export type Config = {
10 enabled: boolean
11 mode: Mode
12 endpoint: string
13 model: string
14 tools: string[]
15 gateSubagents: boolean
16 announce: AnnounceMode
17 logPrompts: boolean
18 denyThreshold: number
19 redundantThreshold: number
20 agentThreshold: number
21 timeoutMs: number
22 maxStateChars: number
23 recentCalls: number
24 gateInSubagents: boolean
25 logEnabled: boolean
26 logDir?: string
27 rubricFile?: string
28}
29
30export const DEFAULTS = {
31 mode: "shadow" as Mode,
32 endpoint: "http://127.0.0.1:8080/v1/systemone",
33 model: "clef-flash",
34 tools: ["WebFetch", "WebSearch"],
35 announce: "status" as AnnounceMode,
36 denyThreshold: 0.2,
37 redundantThreshold: 0.8,
38 agentThreshold: 0.2,
39 timeoutMs: 6000,
40 maxStateChars: 2400,
41 recentCalls: 8,
42}
43
44type Options = Readonly<Record<string, unknown>>
45
46function str(o: Options, key: string): string | undefined {
47 const v = o[key]
48 return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined
49}
50
51function numIn(o: Options, key: string, min: number, max: number, fallback: number, problems: string[]): number {
52 const v = o[key]
53 if (v === undefined || v === "") return fallback
54 const n = typeof v === "number" ? v : Number(v)
55 if (!Number.isFinite(n) || n < min || n > max) {
56 problems.push(`${key} must be a number from ${min} to ${max}; using ${fallback}`)
57 return fallback
58 }
59 return n
60}
61
62function oneOf<T extends string>(o: Options, key: string, allowed: readonly T[], fallback: T, problems: string[]): T {
63 const v = str(o, key)
64 if (v === undefined) return fallback
65 if ((allowed as readonly string[]).includes(v)) return v as T
66 problems.push(`${key} must be one of ${allowed.join(", ")}; using ${fallback}`)
67 return fallback
68}
69
70function bool(o: Options, key: string, fallback: boolean): boolean {
71 const v = o[key]
72 if (typeof v === "boolean") return v
73 if (v === "true") return true
74 if (v === "false") return false
75 return fallback
76}
77
78function httpUrl(o: Options, key: string, fallback: string, problems: string[]): string {
79 const v = str(o, key)
80 if (v === undefined) return fallback
81 if (/^https?:\/\/\S+$/i.test(v)) return v
82 problems.push(`${key} must be an http:// or https:// URL; using ${fallback}`)
83 return fallback
84}
85
86function list(o: Options, key: string, fallback: readonly string[]): string[] {
87 const v = str(o, key)
88 if (v === undefined) return [...fallback]
89 return [...new Set(v.split(",").map((s) => s.trim()).filter(Boolean))]
90}
91
92export function parseAdvancedFile(text: string | undefined): { values: Options; problems: string[] } {
93 if (text === undefined) return { values: {}, problems: [] }
94 try {
95 const value = JSON.parse(text) as unknown
96 if (typeof value !== "object" || value === null || Array.isArray(value)) {
97 return { values: {}, problems: ["clef-gate.json must hold a JSON object; ignoring it"] }
98 }
99 return { values: { ...(value as Record<string, unknown>) }, problems: [] }
100 } catch {
101 return { values: {}, problems: ["clef-gate.json is not valid JSON; ignoring it"] }
102 }
103}
104
105export function parseConfig(o: Options): { config: Config; problems: string[] } {
106 const problems: string[] = []
107 const config: Config = {
108 enabled: bool(o, "enabled", true),
109 mode: oneOf(o, "mode", MODES, DEFAULTS.mode, problems),
110 endpoint: httpUrl(o, "endpoint", DEFAULTS.endpoint, problems),
111 model: str(o, "model") ?? DEFAULTS.model,
112 tools: list(o, "tools", DEFAULTS.tools),
113 gateSubagents: bool(o, "gate_subagents", true),
114 announce: oneOf(o, "announce", ANNOUNCE_MODES, DEFAULTS.announce, problems),
115 logPrompts: bool(o, "log_prompts", false),
116 denyThreshold: numIn(o, "deny_threshold", 0, 1, DEFAULTS.denyThreshold, problems),
117 redundantThreshold: numIn(o, "redundant_threshold", 0, 1, DEFAULTS.redundantThreshold, problems),
118 agentThreshold: numIn(o, "agent_threshold", 0, 1, DEFAULTS.agentThreshold, problems),
119 timeoutMs: numIn(o, "timeout_ms", 100, 9_000, DEFAULTS.timeoutMs, problems),
120 maxStateChars: numIn(o, "max_state_chars", 400, 60_000, DEFAULTS.maxStateChars, problems),
121 recentCalls: numIn(o, "recent_calls", 0, 50, DEFAULTS.recentCalls, problems),
122 gateInSubagents: bool(o, "gate_in_subagents", false),
123 logEnabled: bool(o, "log_enabled", true),
124 }
125 const logDir = str(o, "log_dir")
126 if (logDir) config.logDir = logDir
127 const rubricFile = str(o, "rubric_file")
128 if (rubricFile) config.rubricFile = rubricFile
129 return { config, problems }
130}
131hooks/lib/context.ts 125 lines1import type { Verdict } from "./types.ts"
2
3export type CallSeen = {
4 tool: string
5 summary: string
6 verdict: Verdict
7 resultChars?: number
8}
9
10const SECRETS: readonly RegExp[] = [
11 /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
12 /(authorization|api[_-]?token|api[_-]?key|secret|password|token)(["']?\s*[:=]\s*["']?)(?!Bearer\s)[^\s"',}]+/gi,
13 /\b[A-Za-z0-9_-]{32,}\b/g,
14]
15
16export function redact(text: string): string {
17 let out = text.replace(SECRETS[0]!, "Bearer [redacted]")
18 out = out.replace(SECRETS[1]!, (_m, key: string, sep: string) => `${key}${sep}[redacted]`)
19 return out.replace(SECRETS[2]!, "[redacted]")
20}
21
22// Head 75%, tail 25%. The intent is usually at one end of a long prompt.
23export function truncate(text: string, maxChars: number): string {
24 if (text.length <= maxChars) return text
25 const head = Math.floor(maxChars * 0.75)
26 const tail = maxChars - head
27 return `${text.slice(0, head)}\n[... ${text.length - head - tail} characters omitted ...]\n${text.slice(text.length - tail)}`
28}
29
30function str(v: unknown, max = 200): string {
31 if (typeof v !== "string") return ""
32 return v.length > max ? `${v.slice(0, max)}…` : v
33}
34
35export function stableStringify(value: unknown): string {
36 if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
37 if (typeof value === "object" && value !== null) {
38 const o = value as Record<string, unknown>
39 return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(o[k])}`).join(",")}}`
40 }
41 return JSON.stringify(value) ?? "null"
42}
43
44export function summariseArgs(tool: string, args: Record<string, unknown>): string {
45 let s: string
46 switch (tool) {
47 case "WebFetch":
48 s = `${str(args.url, 300)} — ${str(args.prompt)}`
49 break
50 case "WebSearch":
51 s = `query: ${str(args.query, 300)}`
52 break
53 case "Read":
54 case "Write":
55 case "Edit":
56 s = str(args.file_path, 300)
57 break
58 case "Bash":
59 s = str(args.command)
60 break
61 case "Grep":
62 case "Glob":
63 s = `${str(args.pattern)}${args.path ? ` in ${str(args.path, 120)}` : ""}`
64 break
65 case "Agent":
66 s = `${str(args.subagent_type, 60) || "agent"}: ${str(args.description, 120)} — ${str(args.prompt)}`
67 break
68 default: {
69 const json = stableStringify(args)
70 s = json.length > 400 ? `${json.slice(0, 400)}…` : json
71 }
72 }
73 return redact(s.replace(/\s+/g, " ").trim())
74}
75
76export async function argsHash(tool: string, args: Record<string, unknown>): Promise<string> {
77 const bytes = new TextEncoder().encode(`${tool}\n${stableStringify(args)}`)
78 const digest = await crypto.subtle.digest("SHA-256", bytes)
79 return Array.from(new Uint8Array(digest).slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join("")
80}
81
82function callLines(calls: readonly CallSeen[], recent: number): string[] {
83 const shown = recent > 0 ? calls.slice(-recent) : []
84 if (shown.length === 0) return ["Earlier in this turn the agent called: nothing yet."]
85 return [
86 "Earlier in this turn the agent called:",
87 ...shown.map((c) => `- ${c.tool}: ${c.summary}${c.resultChars !== undefined ? ` → ${c.resultChars} chars` : ""}${c.verdict === "deny" ? " (refused)" : ""}`),
88 ]
89}
90
91// "go ahead" or "yes" says nothing about the task, so the previous request has to come along.
92export const TERSE_PROMPT_CHARS = 50
93
94function requestLines(prompt: string, previous: string | undefined, budget: number): string {
95 const current = truncate(redact(prompt), budget)
96 if (!previous || previous.trim() === "" || prompt.trim().length >= TERSE_PROMPT_CHARS) return `User request: ${current}`
97 return `Previous request: ${truncate(redact(previous), Math.max(200, Math.floor(budget / 2)))}\nUser request (a short follow-up): ${current}`
98}
99
100type StateArgs = {
101 prompt: string
102 calls: readonly CallSeen[]
103 maxChars: number
104 recentCalls: number
105 previousRequest?: string
106}
107
108export function buildToolState(a: StateArgs & { lastAssistant?: string; tool: string; summary: string }): string {
109 const tail = [
110 ...callLines(a.calls, a.recentCalls),
111 ...(a.lastAssistant ? [`Last assistant text: ${redact(str(a.lastAssistant, 400))}`] : []),
112 `Proposed call: ${a.tool} — ${a.summary}`,
113 ].join("\n")
114 return `${requestLines(a.prompt, a.previousRequest, Math.max(200, a.maxChars - tail.length))}\n\n${tail}`
115}
116
117export function buildAgentState(a: StateArgs & { subagentType: string; description: string; agentPrompt: string }): string {
118 const tail = [
119 ...callLines(a.calls, a.recentCalls),
120 `Proposed subagent: ${a.subagentType} — ${redact(str(a.description, 200))}`,
121 `Subagent task: ${redact(str(a.agentPrompt, 600))}`,
122 ].join("\n")
123 return `${requestLines(a.prompt, a.previousRequest, Math.max(200, a.maxChars - tail.length))}\n\n${tail}`
124}
125hooks/lib/format.ts 97 lines1import type { Config } from "./config.ts"
2import type { Stats } from "./log.ts"
3import { pct, type Mode } from "./policy.ts"
4import type { GateKind, Verdict } from "./types.ts"
5
6export type TurnCounts = {
7 checked: number
8 denied: number
9 wouldDeny: number
10 last?: { tool: string; verdict: Verdict; reason: string }
11}
12
13function lastNote(c: TurnCounts): string {
14 if (!c.last || (c.last.verdict !== "deny" && c.last.verdict !== "would-deny")) return ""
15 const p = /(\d+)%/.exec(c.last.reason)?.[1]
16 return ` (${c.last.tool}${p ? ` ${p}%` : ""})`
17}
18
19export function statusLine(mode: Mode, c: TurnCounts): string {
20 if (c.checked === 0) return `gate · ${mode} · awaiting calls`
21 const parts = [`gate · ${mode} · ${c.checked} checked`]
22 if (c.denied > 0) parts.push(`${c.denied} denied`)
23 if (c.wouldDeny > 0) parts.push(`${c.wouldDeny} would deny`)
24 return parts.join(" · ") + lastNote(c)
25}
26
27export function answerLine(mode: Mode, c: TurnCounts): string | undefined {
28 return c.checked === 0 ? undefined : statusLine(mode, c)
29}
30
31export type HistoryRow = { ts: string; kind: GateKind; tool: string; verdict: Verdict; reason: string; summary: string }
32
33export function historyReport(rows: readonly HistoryRow[]): string {
34 if (rows.length === 0) return "No gated calls this session."
35 const lines = ["Gate decisions this session (newest last)"]
36 for (const r of rows) {
37 lines.push(` ${r.ts.slice(11, 19)} ${r.verdict.padEnd(10)} ${r.kind === "agent" ? "agent " : "tool "} ${r.tool} · ${r.reason}`)
38 lines.push(` ${r.summary.slice(0, 110)}`)
39 }
40 return lines.join("\n")
41}
42
43export type StatusArgs = {
44 config: Config
45 configProblems: readonly string[]
46 sessionMode: Mode
47 counts: TurnCounts
48 breaker: { calls: number; failures: number; openForSeconds?: number }
49 logDir?: string
50 advancedPath?: string
51}
52
53export function statusReport(a: StatusArgs): string {
54 const c = a.config
55 const lines = ["Clef gate"]
56 lines.push(` mode ${c.enabled ? a.sessionMode : "disabled in plugin config"}${a.sessionMode !== c.mode ? ` (this session; config says ${c.mode})` : ""}`)
57 lines.push(` decider ${c.model} at ${c.endpoint} · timeout ${c.timeoutMs} ms · state ≤ ${c.maxStateChars} chars`)
58 lines.push(` tools ${c.tools.join(", ") || "(none)"}${c.gateSubagents ? " · subagent spawns" : ""}${c.gateInSubagents ? " · inside subagents too" : ""}`)
59 lines.push(` thresholds deny when needed < ${pct(c.denyThreshold)}, or needed < 50% and already in context ≥ ${pct(c.redundantThreshold)}; subagent when warranted < ${pct(c.agentThreshold)}`)
60 lines.push(` clef ${a.breaker.calls} calls · ${a.breaker.failures} failures${a.breaker.openForSeconds !== undefined ? ` · paused ${a.breaker.openForSeconds}s after repeated failures` : ""}`)
61 lines.push(` this turn ${statusLine(a.sessionMode, a.counts)}`)
62 for (const p of a.configProblems) lines.push(` config! ${p}`)
63 if (a.advancedPath) lines.push("", `Advanced settings: ${a.advancedPath} (optional)`)
64 lines.push(`Log: ${c.logEnabled ? (a.logDir ?? "(unavailable)") : "off"}${c.logEnabled && c.logPrompts ? " (with prompt and argument text)" : ""}`)
65 lines.push("Commands: /gate history · stats [days] · shadow · deny · on · off · help")
66 return lines.join("\n")
67}
68
69export function statsReport(s: Stats, days: number, files: number): string {
70 const lines = [`Clef gate, last ${days} day${days === 1 ? "" : "s"} (${files} log file${files === 1 ? "" : "s"})`]
71 if (s.events === 0) {
72 lines.push(" No gated calls in this period.")
73 return lines.join("\n")
74 }
75 lines.push(` events ${s.events} · ${Object.entries(s.byVerdict).map(([k, v]) => `${k} ${v}`).join(" · ")}`)
76 for (const [tool, t] of Object.entries(s.byTool).sort((a, b) => b[1].checked - a[1].checked)) {
77 lines.push(` ${tool.padEnd(22)} ${t.checked} checked · ${t.allowed} allowed · ${t.denied} denied · ${t.wouldDeny} would deny${t.meanResultChars !== undefined ? ` · mean result ${t.meanResultChars} chars` : ""}`)
78 }
79 if (s.latency) lines.push(` clef ${s.clefCalls} calls · latency mean ${s.latency.mean} ms · p50 ${s.latency.p50} ms · p95 ${s.latency.p95} ms`)
80 if (Object.keys(s.failures).length > 0) lines.push(` failures ${Object.entries(s.failures).map(([k, v]) => `${k} ${v}`).join(" · ")}`)
81 lines.push(` saved ~${s.estimatedSavedTokens} tokens denied · ~${s.estimatedWouldSaveTokens} tokens would have been denied in deny mode (estimates from mean result sizes)`)
82 return lines.join("\n")
83}
84
85export const HELP = [
86 "Clef gate: asks a local Clef whether a tool call or subagent is needed before Claude Code pays for it.",
87 "",
88 " /gate status, thresholds, this turn's counts",
89 " /gate history this session's decisions",
90 " /gate stats [days] counts, latency and estimated savings from the log (default 7 days)",
91 " /gate shadow this session: decide and log, deny nothing",
92 " /gate deny this session: refuse calls Clef rates unnecessary",
93 " /gate off | on stop or resume gating for this session",
94 "",
95 "A call refused once and repeated with the same arguments always goes through.",
96].join("\n")
97hooks/lib/log.ts 137 lines1import type { Mode } from "./policy.ts"
2import type { Decision, GateKind, Probabilities, Verdict } from "./types.ts"
3
4export const LOG_VERSION = 1
5
6export type GateRecord = {
7 v: 1
8 type: GateKind
9 ts: string
10 session: string
11 turn: string
12 tool: string
13 argsHash: string
14 argsChars: number
15 args?: string
16 mode: Mode
17 verdict: Verdict
18 reason?: string
19 probabilities?: Probabilities
20 latencyMs?: number
21 clefInputTokens?: number
22 resultChars?: number
23 failure?: { kind: string; message: string }
24}
25
26export function gateRecord(args: { decision: Decision; session: string; turn: string; ts: string; mode: Mode; logText: boolean }): GateRecord {
27 const d = args.decision
28 const r: GateRecord = {
29 v: LOG_VERSION,
30 type: d.kind,
31 ts: d.ts ?? args.ts,
32 session: args.session,
33 turn: args.turn,
34 tool: d.tool,
35 argsHash: d.argsHash,
36 argsChars: d.argsChars,
37 mode: args.mode,
38 verdict: d.verdict,
39 reason: d.reason,
40 }
41 if (args.logText) r.args = d.summary
42 if (d.result?.ok) {
43 r.probabilities = { ...d.result.probabilities }
44 r.latencyMs = d.result.latencyMs
45 if (d.result.inputTokens !== undefined) r.clefInputTokens = d.result.inputTokens
46 } else if (d.result) {
47 r.failure = { kind: d.result.kind, message: d.result.message }
48 if (d.result.latencyMs > 0) r.latencyMs = d.result.latencyMs
49 }
50 if (d.resultChars !== undefined) r.resultChars = d.resultChars
51 return r
52}
53
54export function logFileName(ts: string, session: string): string {
55 const safe = session.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 12) || "session"
56 return `gate-${ts.slice(0, 10)}-${safe}.jsonl`
57}
58
59export function parseLines(text: string): GateRecord[] {
60 const out: GateRecord[] = []
61 for (const line of text.split("\n")) {
62 if (line.trim() === "") continue
63 try {
64 const v = JSON.parse(line) as GateRecord
65 if (v && typeof v === "object" && (v.type === "tool" || v.type === "agent")) out.push(v)
66 } catch {
67 // a torn line from a crash mid-write
68 }
69 }
70 return out
71}
72
73export type ToolStats = {
74 checked: number
75 allowed: number
76 denied: number
77 wouldDeny: number
78 meanResultChars?: number
79}
80
81export type Stats = {
82 events: number
83 byVerdict: Record<string, number>
84 byTool: Record<string, ToolStats>
85 clefCalls: number
86 latency?: { mean: number; p50: number; p95: number }
87 failures: Record<string, number>
88 estimatedSavedTokens: number
89 estimatedWouldSaveTokens: number
90}
91
92// Stands in for a tool that never ran under the gate, so a saving can still be guessed.
93export const DEFAULT_RESULT_CHARS = 4000
94
95function quantile(sorted: number[], q: number): number {
96 if (sorted.length === 0) return 0
97 return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))]!
98}
99
100function bump(map: Record<string, number>, key: string) {
101 map[key] = (map[key] ?? 0) + 1
102}
103
104export function aggregate(records: readonly GateRecord[]): Stats {
105 const stats: Stats = { events: 0, byVerdict: {}, byTool: {}, clefCalls: 0, failures: {}, estimatedSavedTokens: 0, estimatedWouldSaveTokens: 0 }
106 const latencies: number[] = []
107 const sizes: Record<string, number[]> = {}
108 for (const r of records) {
109 stats.events++
110 bump(stats.byVerdict, r.verdict)
111 const t = (stats.byTool[r.tool] ??= { checked: 0, allowed: 0, denied: 0, wouldDeny: 0 })
112 if (r.verdict !== "skip") t.checked++
113 if (r.verdict === "allow" || r.verdict === "override" || r.verdict === "fail-open") {
114 t.allowed++
115 if (r.resultChars !== undefined) (sizes[r.tool] ??= []).push(r.resultChars)
116 }
117 if (r.verdict === "deny") t.denied++
118 if (r.verdict === "would-deny") t.wouldDeny++
119 const asked = r.probabilities || r.failure
120 if (asked) stats.clefCalls++
121 if (asked && r.latencyMs !== undefined) latencies.push(r.latencyMs)
122 if (r.failure) bump(stats.failures, r.failure.kind)
123 }
124 for (const [tool, t] of Object.entries(stats.byTool)) {
125 const s = sizes[tool]
126 if (s && s.length > 0) t.meanResultChars = Math.round(s.reduce((a, b) => a + b, 0) / s.length)
127 const perCall = (t.meanResultChars ?? DEFAULT_RESULT_CHARS) / 4
128 stats.estimatedSavedTokens += Math.round(t.denied * perCall)
129 stats.estimatedWouldSaveTokens += Math.round(t.wouldDeny * perCall)
130 }
131 if (latencies.length > 0) {
132 const sorted = [...latencies].sort((a, b) => a - b)
133 stats.latency = { mean: Math.round(sorted.reduce((s, x) => s + x, 0) / sorted.length), p50: quantile(sorted, 0.5), p95: quantile(sorted, 0.95) }
134 }
135 return stats
136}
137hooks/lib/policy.ts 56 lines1import type { GateKind, Probabilities, Verdict } from "./types.ts"
2
3export type Mode = "shadow" | "deny"
4
5export type PolicyConfig = {
6 mode: Mode
7 denyThreshold: number
8 redundantThreshold: number
9 agentThreshold: number
10}
11
12export function pct(p: number): string {
13 return `${Math.round(p * 100)}%`
14}
15
16export function matchesTool(tool: string, patterns: readonly string[]): boolean {
17 return patterns.some((p) => (p.endsWith("*") ? tool.startsWith(p.slice(0, -1)) : tool === p))
18}
19
20type Ruling = { verdict: Verdict; reason: string }
21
22const OVERRIDE: Ruling = { verdict: "override", reason: "repeated after a refusal this turn; let through" }
23
24function refuse(cfg: PolicyConfig, reason: string): Ruling {
25 return { verdict: cfg.mode === "deny" ? "deny" : "would-deny", reason }
26}
27
28export function toolVerdict(p: Probabilities, cfg: PolicyConfig, alreadyDenied: boolean): Ruling {
29 if (alreadyDenied) return OVERRIDE
30 const needed = p.needed ?? 1
31 const redundant = p.redundant ?? 0
32 if (needed < cfg.denyThreshold) return refuse(cfg, `needed ${pct(needed)} < ${pct(cfg.denyThreshold)}`)
33 if (needed < 0.5 && redundant >= cfg.redundantThreshold) return refuse(cfg, `needed ${pct(needed)}, already in context ${pct(redundant)}`)
34 return { verdict: "allow", reason: `needed ${pct(needed)}` }
35}
36
37export function agentVerdict(p: Probabilities, cfg: PolicyConfig, alreadyDenied: boolean): Ruling {
38 if (alreadyDenied) return OVERRIDE
39 const warranted = p.warranted ?? 1
40 if (warranted < cfg.agentThreshold) return refuse(cfg, `warranted ${pct(warranted)} < ${pct(cfg.agentThreshold)}`)
41 return { verdict: "allow", reason: `warranted ${pct(warranted)}` }
42}
43
44export function denyText(kind: GateKind, p: Probabilities): string {
45 if (kind === "agent") {
46 return (
47 `clef-gate: starting a subagent looks unnecessary for this task (warranted ${pct(p.warranted ?? 0)}). ` +
48 "Do the work directly with a few tool calls. If a subagent is needed after all, call Agent again with the same task and it will go through."
49 )
50 }
51 return (
52 `clef-gate: this call looks unnecessary for the current request (needed ${pct(p.needed ?? 0)}, already in context ${pct(p.redundant ?? 0)}). ` +
53 "Use what is already in the conversation. If it is needed after all, make the same call again and it will go through."
54 )
55}
56hooks/lib/rubric.ts 85 lines1// The questions Clef gets. Change the wording here (or via rubric_file) to recalibrate.
2
3import type { NoulQuestion } from "./systemone.ts"
4
5export type RubricQuestion = {
6 instructions: string
7 criteria: { true: string; false: string }
8}
9
10export type Rubric = {
11 needed: RubricQuestion
12 redundant: RubricQuestion
13 warranted: RubricQuestion
14}
15
16export const DEFAULT_RUBRIC: Rubric = {
17 needed: {
18 instructions:
19 "An AI coding agent is working on the user's request inside their software repository and is about to make the " +
20 "tool call shown last. Is this call necessary to complete the request well? Judge whether the information or effect " +
21 "it provides is required for the request and not already available from what the agent has.",
22 criteria: {
23 true: "Without this call the agent cannot complete the request well: the request asks for it, or it needs information only this call can give.",
24 false: "The call is unnecessary: the agent already has what it needs, the request does not call for it, or it is a speculative lookup the task can do without.",
25 },
26 },
27 redundant: {
28 instructions:
29 "Does the conversation so far already contain what this call would produce? For example the same page was fetched, " +
30 "the same search was run, or the same file was read earlier in this turn.",
31 criteria: {
32 true: "An earlier call this turn already produced the same or equivalent result.",
33 false: "Nothing so far provides what this call would.",
34 },
35 },
36 warranted: {
37 instructions:
38 "An AI coding agent is about to start a separate subagent for the task shown last. A subagent costs a whole extra " +
39 "model session. Does this task justify one, rather than the agent doing the work itself in a few tool calls? A broad " +
40 "search across many files, independent parallel work, or a long isolated task justifies one. A single lookup, one " +
41 "file to read, or a short answer does not.",
42 criteria: {
43 true: "The task is broad, parallel or long enough that a separate subagent is the right tool.",
44 false: "The agent could do this itself in a few tool calls; a subagent is wasted cost.",
45 },
46 },
47}
48
49function noul(q: RubricQuestion): NoulQuestion {
50 return { type: "noul", instructions: q.instructions, criteria: { ...q.criteria } }
51}
52
53export function toolQuestions(r: Rubric): Record<"needed" | "redundant", NoulQuestion> {
54 return { needed: noul(r.needed), redundant: noul(r.redundant) }
55}
56
57export function agentQuestions(r: Rubric): Record<"warranted", NoulQuestion> {
58 return { warranted: noul(r.warranted) }
59}
60
61function check(name: string, value: unknown): string[] {
62 if (typeof value !== "object" || value === null) return [`\`${name}\` must be an object`]
63 const q = value as Record<string, unknown>
64 const problems: string[] = []
65 if (typeof q.instructions !== "string" || q.instructions.trim() === "") problems.push(`\`${name}.instructions\` must be a non-empty string`)
66 const c = q.criteria as Record<string, unknown> | undefined
67 if (typeof c !== "object" || c === null || typeof c.true !== "string" || typeof c.false !== "string")
68 problems.push(`\`${name}.criteria\` must have string \`true\` and \`false\``)
69 return problems
70}
71
72export function parseRubric(text: string): { rubric: Rubric } | { problems: string[] } {
73 let value: unknown
74 try {
75 value = JSON.parse(text)
76 } catch {
77 return { problems: ["rubric file is not valid JSON"] }
78 }
79 if (typeof value !== "object" || value === null) return { problems: ["rubric must be a JSON object"] }
80 const v = value as Record<string, unknown>
81 const problems = [...check("needed", v.needed), ...check("redundant", v.redundant), ...check("warranted", v.warranted)]
82 if (problems.length > 0) return { problems }
83 return { rubric: v as unknown as Rubric }
84}
85hooks/lib/systemone.ts 113 lines1// POST /v1/systemone, as llama-server and Ollama serve it for Clef.
2
3import type { ClefFailure, ClefResult, Probabilities } from "./types.ts"
4
5export type NoulQuestion = {
6 type: "noul"
7 instructions: string
8 criteria?: { true: string; false: string }
9}
10
11export type HttpLike = (
12 url: string,
13 init: { method: string; headers: Record<string, string>; body: string },
14) => Promise<{ status: number; ok: boolean; text: string }>
15
16export type SystemOneOptions = {
17 endpoint: string
18 model: string
19 timeoutMs: number
20 fetch: HttpLike
21 sleep: (ms: number, signal: AbortSignal) => Promise<void>
22 now: () => number | Promise<number>
23}
24
25export function buildRequest(model: string, state: string, questions: Record<string, NoulQuestion>): string {
26 return JSON.stringify({ model, state, questions })
27}
28
29function num(v: unknown): number | undefined {
30 return typeof v === "number" && Number.isFinite(v) ? v : undefined
31}
32
33// llama.cpp answers `noul`, Workers AI `probability_true`; older builds put it under `probabilities`.
34function pTrue(answer: unknown): number | undefined {
35 if (typeof answer !== "object" || answer === null) return undefined
36 const a = answer as Record<string, unknown>
37 let p = num(a.noul) ?? num(a.probability_true)
38 if (p === undefined && a.probabilities && typeof a.probabilities === "object" && !Array.isArray(a.probabilities)) {
39 const m = a.probabilities as Record<string, unknown>
40 p = num(m.true) ?? num(m.yes)
41 }
42 if (p === undefined && Array.isArray(a.probabilities)) p = num(a.probabilities[0])
43 return p === undefined ? undefined : Math.min(1, Math.max(0, p))
44}
45
46export function parseAnswers(bodyText: string, ids: readonly string[], latencyMs: number): ClefResult {
47 const fail = (message: string): ClefFailure => ({ ok: false, kind: "malformed", message, latencyMs })
48 let body: unknown
49 try {
50 body = JSON.parse(bodyText)
51 } catch {
52 return fail("response is not JSON")
53 }
54 if (typeof body !== "object" || body === null) return fail("response is not an object")
55 const answers = (body as Record<string, unknown>).answers
56 if (typeof answers !== "object" || answers === null) return fail("response has no answers")
57 const probabilities: Probabilities = {}
58 for (const id of ids) {
59 const p = pTrue((answers as Record<string, unknown>)[id])
60 if (p === undefined) return fail(`no usable answer for "${id}"`)
61 probabilities[id] = p
62 }
63 const usage = (body as Record<string, unknown>).usage
64 const inputTokens = usage && typeof usage === "object" ? num((usage as Record<string, unknown>).input_tokens) : undefined
65 const out: ClefResult = { ok: true, probabilities, latencyMs }
66 if (inputTokens !== undefined) out.inputTokens = inputTokens
67 return out
68}
69
70export function classifyHttpFailure(status: number, bodyText: string, latencyMs: number): ClefFailure {
71 const text = bodyText.replace(/\s+/g, " ").trim().slice(0, 160)
72 const message = text || `HTTP ${status}`
73 if (status === 404) {
74 return { ok: false, kind: "bad-request", message: `${message} (no /v1/systemone here: needs llama.cpp b11379+ or Ollama 0.35.1+)`, status, latencyMs }
75 }
76 if (status === 408) return { ok: false, kind: "timeout", message, status, latencyMs }
77 if (status >= 500) return { ok: false, kind: "server", message, status, latencyMs }
78 if (status >= 400) return { ok: false, kind: "bad-request", message, status, latencyMs }
79 return { ok: false, kind: "malformed", message, status, latencyMs }
80}
81
82const TIMED_OUT: unique symbol = Symbol("timeout")
83
84export async function decide(opts: SystemOneOptions, state: string, questions: Record<string, NoulQuestion>): Promise<ClefResult> {
85 const started = await opts.now()
86 const elapsed = async () => Math.round((await opts.now()) - started)
87 let response: { status: number; ok: boolean; text: string } | typeof TIMED_OUT
88 const timer = new AbortController()
89 try {
90 const request = opts.fetch(opts.endpoint, {
91 method: "POST",
92 headers: { "Content-Type": "application/json" },
93 body: buildRequest(opts.model, state, questions),
94 })
95 // $.http.fetch has no abort signal, so a late answer is just dropped.
96 request.catch(() => {})
97 const deadline = opts.sleep(opts.timeoutMs, timer.signal).then(
98 (): typeof TIMED_OUT => TIMED_OUT,
99 (): typeof TIMED_OUT => TIMED_OUT,
100 )
101 response = await Promise.race([request, deadline])
102 } catch (err) {
103 const message = (err instanceof Error ? err.message : String(err)).slice(0, 200)
104 return { ok: false, kind: "network", message, latencyMs: await elapsed() }
105 } finally {
106 timer.abort()
107 }
108 const latencyMs = await elapsed()
109 if (response === TIMED_OUT) return { ok: false, kind: "timeout", message: `no answer within ${opts.timeoutMs} ms`, latencyMs }
110 if (!response.ok) return classifyHttpFailure(response.status, response.text, latencyMs)
111 return parseAnswers(response.text, Object.keys(questions), latencyMs)
112}
113hooks/lib/types.ts 25 lines1export type Verdict = "allow" | "deny" | "would-deny" | "override" | "skip" | "fail-open"
2
3export type GateKind = "tool" | "agent"
4
5export type Probabilities = Record<string, number>
6
7export type FailureKind = "timeout" | "network" | "server" | "bad-request" | "malformed" | "circuit-open"
8
9export type ClefSuccess = { ok: true; probabilities: Probabilities; latencyMs: number; inputTokens?: number }
10export type ClefFailure = { ok: false; kind: FailureKind; message: string; status?: number; latencyMs: number }
11export type ClefResult = ClefSuccess | ClefFailure
12
13export type Decision = {
14 kind: GateKind
15 tool: string
16 argsHash: string
17 argsChars: number
18 summary: string
19 verdict: Verdict
20 reason: string
21 result?: ClefResult
22 resultChars?: number
23 ts?: string
24}
25types/index.d.ts 13 lines1// The values clef-gate keeps in `$.state` for a session.
2
3/** The session's gate state as JSON: mode override, counters, history. */
4export type ClefGateSnapshot = string
5
6declare module 'claude-code' {
7 interface PluginState {
8 'clef-gate': {
9 session: ClefGateSnapshot
10 }
11 }
12}
13