SLOPSHOPPER

clef-gate

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…

newspinnerguardcommandtoastnetwork
v0.1.0Apache-2.0updated 2026-10-04dwain-barnes/clef-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clef-gate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gate ⎿ clef-gate: Clef gate ⎿ clef-gate: mode shadow ⎿ clef-gate: decider clef-flash at http://127.0.0.1:8080/v1/systemone · timeout 6000 ms · state ≤ 2400 chars ⎿ clef-gate: tools WebFetch, WebSearch · subagent spawns ⎿ clef-gate: thresholds deny when needed < 20%, or needed < 50% and already in context ≥ 80%; subagent when warranted < 2 ⎿ clef-gate: clef 0 calls · 0 failures ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

clef-gate

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.

How it works

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.

What it might save

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 callTokens it would have added to the context
One web searchabout 400 to 1,000
One web fetchabout 1,000 to 3,000 after Claude Code's own summarising
One subagent50,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.

Requirements

  • Claude Code 2.1.287 or later.
  • A Clef server on localhost.

Running Clef locally

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.

Install

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.

Use

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.

Speed

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.

Privacy

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.

Licence

Apache-2.0. See LICENSE and NOTICE.

Source 11 files
hooks/register.ts 454 lines
1// 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}
454
hooks/lib/breaker.ts 37 lines
1export 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}
37
hooks/lib/config.ts 131 lines
1// 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}
131
hooks/lib/context.ts 125 lines
1import 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}
125
hooks/lib/format.ts 97 lines
1import 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")
97
hooks/lib/log.ts 137 lines
1import 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}
137
hooks/lib/policy.ts 56 lines
1import 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}
56
hooks/lib/rubric.ts 85 lines
1// 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}
85
hooks/lib/systemone.ts 113 lines
1// 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}
113
hooks/lib/types.ts 25 lines
1export 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}
25
types/index.d.ts 13 lines
1// 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