Asks Jev whether a prompt leaves a choice open and whether each edit stays within what the user approved

A few Claude Code mods. A mod is a plugin with a TypeScript or JavaScript hooks module that Claude Code runs on events such as tool calls, prompts and rendering. The mods need Claude Code v2.1.287 or later.
| Mod | What it does |
|---|---|
git-status | Runs git status --porcelain at session start, after each prompt, after file-changing tool calls and when the working directory changes. It keeps the changed, staged and unstaged file counts in session state. |
tool-calls | Counts how often each tool and each skill was used in the session. /clear resets the counts. |
sidebar | A pane that shows the git-status counts above the tool-calls counts. It opens at session start in terminals at least 144 columns wide. /sidebar opens it at any width. It needs git-status and tool-calls to be loaded. |
jev-guard | Asks Jev, a classifier model on the Vercel AI Gateway, two kinds of questions. See below. |
On each prompt you type, jev-guard asks Jev whether the request leaves a choice open, such as which fix or what scope. If it does, and the prompt is not a question, a dialog asks you to clarify, send the prompt as written, or cancel it.
Before each Edit, Write, MultiEdit, NotebookEdit and Bash call, jev-guard asks Jev whether the call stays within what you approved and whether it removes behavior you did not ask to remove. It denies the call if the in-scope rating is below 0.5 or the removes-behavior rating is 0.7 or more. Claude then sees the reason and is told to ask you. A Bash command that Jev rates as read-only is allowed without those checks. The thresholds are at the top of jev-guard/hooks/judge.ts.
Things to know before you enable jev-guard:
https://ai-gateway.vercel.sh. For a Write call it also sends the current content of the file being overwritten.jev-guard/.debug/last-request.json. That folder is git-ignored.It needs a Vercel AI Gateway API key. Run jev-guard/set-key.sh, which saves the key to ~/.config/jev-guard/key with permissions 600.
Clone the repo:
git clone https://github.com/klefix/claude-code-mods.git ~/claude-code-mods
To load mods in every session, add their directories to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. The value is a list of absolute paths separated by :. Use the path you cloned to:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/git-status:/Users/you/claude-code-mods/tool-calls:/Users/you/claude-code-mods/sidebar"
}
}
Add /Users/you/claude-code-mods/jev-guard to the list if you want jev-guard, after you have set the key.
To try one mod for a single session, run claude --plugin-dir ./sidebar from the repo. Claude Code reloads the mod when you save a file.
To check that a mod is loaded, open /plugin and look at the Installed tab.
npm install
npm run lint # Biome lint and format check
npm run lint:fix # apply the fixes
claude plugin validate ./jev-guard
claude plugin test jev-guard
Claude Code generates .claude-plugin/types/ and tsconfig.json in each mod directory when it loads the mod. Both are git-ignored.
hooks/register.ts 144 lines1import type { EngineInterface, On } from 'claude-code'
2import {
3 type Answers,
4 lastExchange,
5 PROMPT_QUESTIONS,
6 probability,
7 QUESTION_MIN,
8 TOOL_QUESTIONS,
9 toolCallState,
10 toolVerdict,
11 VAGUE_MIN,
12 withStepText,
13} from './judge.js'
14
15const GUARDED = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']
16const USER_ORIGINS = ['composer', 'bridge', 'sdk', 'slack-ping']
17const JEV_TIMEOUT_MS = 5000
18const PROCEED = 'Proceed as written'
19const CANCEL = 'Cancel prompt'
20
21let stepText = ''
22
23function report($: EngineInterface, text: string) {
24 $.ui.log(`jev-guard: ${text}`)
25}
26
27async function readKey($: EngineInterface) {
28 const { exitCode, stdout } = await $.process.run(['sh', '-c', 'cat "$HOME/.config/jev-guard/key"'])
29 const key = stdout.trim()
30 if (exitCode !== 0 || !key) {
31 throw new Error('no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh')
32 }
33 return key
34}
35
36async function askJev($: EngineInterface, state: object, questions: object): Promise<Answers> {
37 const apiKey = await readKey($)
38 await $.fs.write(`${$.plugin.root}/.debug/last-request.json`, JSON.stringify({ state, questions }, null, 2))
39 const request = $.http.fetch('https://ai-gateway.vercel.sh/v1/evaluate', {
40 method: 'POST',
41 headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
42 body: JSON.stringify({ model: 'typesafe-ai/jev', state, questions }),
43 })
44 const timeout = $.clock.sleep(JEV_TIMEOUT_MS).then(() => {
45 throw new Error(`Jev did not answer within ${JEV_TIMEOUT_MS} ms`)
46 })
47 const res = await Promise.race([request, timeout])
48 if (!res.ok) {
49 throw new Error(`Jev HTTP ${res.status}: ${res.text.slice(0, 200)}`)
50 }
51 const { answers }: { answers: Answers } = JSON.parse(res.text)
52 $.ui.log(`jev-guard ${JSON.stringify(answers)}`, { to: 'debug' })
53 return answers
54}
55
56export function register(on: On) {
57 on('prompt.submit', async ($, e, next) => {
58 if (!USER_ORIGINS.includes(e.origin.kind)) {
59 return next(e)
60 }
61 const { assistantProposal } = lastExchange(await $.session.messages())
62 const answers = await askJev($, { userRequest: e.text, assistantProposal }, PROMPT_QUESTIONS)
63 const vague = probability(answers, 'vague')
64 if (vague < VAGUE_MIN || probability(answers, 'question') >= QUESTION_MIN) {
65 return next(e)
66 }
67 let answer: string
68 try {
69 answer = await $.ui.ask(
70 `Jev: this request may leave a choice open (${vague.toFixed(2)}). Type the choice under Other, or pick:`,
71 {
72 options: [PROCEED, CANCEL],
73 header: 'jev-guard',
74 },
75 )
76 } catch (err) {
77 const reason = `jev-guard blocked this prompt (vague ${vague.toFixed(2)}): the clarification dialog failed: ${err instanceof Error ? err.message : String(err)}`
78 report($, reason)
79 return { drop: reason }
80 }
81 if (answer === CANCEL) {
82 report($, `prompt cancelled at the clarification dialog (vague ${vague.toFixed(2)})`)
83 return { drop: 'Cancelled by jev-guard clarification' }
84 }
85 if (answer === PROCEED) {
86 return next(e)
87 }
88 return next({ ...e, text: `${e.text}\n\nClarification: ${answer}` })
89 }).catch(($, e, next) => {
90 const error = next.error.message ?? next.error.kind
91 if (next.called) {
92 report($, `prompt hook failed after passing the prompt on: ${error}`)
93 return next(e)
94 }
95 const reason = `jev-guard blocked this prompt because Jev could not be asked: ${error}`
96 report($, reason)
97 return { drop: reason }
98 })
99
100 on('turn.step', async function* (_$, e, next) {
101 if (e.agentId) {
102 return yield* next(e)
103 }
104 stepText = ''
105 const stream = next(e)
106 let item = await stream.next()
107 while (!item.done) {
108 if (item.value.kind === 'text') {
109 stepText += item.value.text
110 }
111 yield item.value
112 item = await stream.next()
113 }
114 return item.value
115 })
116
117 on('tool.call', async ($, e, next) => {
118 if (!GUARDED.includes(e.tool)) {
119 return next(e)
120 }
121 const exchange = withStepText(lastExchange(await $.session.messages()), e.agentId ? '' : stepText)
122 let currentFile: string | undefined
123 if (e.tool === 'Write') {
124 currentFile = await $.fs.read(e.file_path).catch(() => '')
125 }
126 const answers = await askJev($, toolCallState(exchange, e, currentFile), TOOL_QUESTIONS)
127 const reason = toolVerdict(e.tool, answers)
128 if (reason) {
129 report($, `${e.tool} denied: ${reason}`)
130 return { deny: reason }
131 }
132 return next(e)
133 }).catch(($, e, next) => {
134 const error = next.error.message ?? next.error.kind
135 if (next.called) {
136 report($, `${e.tool} hook failed after passing the call on: ${error}`)
137 return next(e)
138 }
139 const reason = `jev-guard blocked this call because Jev could not be asked: ${error}`
140 report($, `${e.tool} denied: ${reason}`)
141 return { deny: reason }
142 })
143}
144hooks/judge.ts 113 lines1import type { SessionMessage } from 'claude-code'
2
3export const VAGUE_MIN = 0.6
4export const QUESTION_MIN = 0.5
5export const IN_SCOPE_MIN = 0.5
6export const REMOVES_MAX = 0.7
7const MAX_FIELD = 8000
8
9export type Exchange = { userRequest: string; assistantProposal: string; assistantThisTurn: string }
10export type Answers = Record<string, { probability?: number } | undefined>
11type ToolCallState = Exchange & { toolCall: Record<string, unknown>; currentFileContent?: string }
12
13export const PROMPT_QUESTIONS = {
14 vague: {
15 type: 'boolean',
16 instructions:
17 'Does the user request leave a choice open that the user has not made, such as which fix, which option, or what scope?',
18 },
19 question: {
20 type: 'boolean',
21 instructions:
22 'Is the user asking a question, such as for options or an explanation, rather than asking for a change to be made?',
23 },
24}
25
26export const TOOL_QUESTIONS = {
27 writes: {
28 type: 'boolean',
29 instructions: 'Does this tool call change files, git state or external systems?',
30 },
31 inScope: {
32 type: 'boolean',
33 instructions:
34 'Does this tool call do only what the user approved in userRequest, read together with assistantProposal? Choices the user did not make count as not approved.',
35 },
36 removes: {
37 type: 'boolean',
38 instructions: 'Does this tool call remove or disable existing behavior that the user did not ask to remove?',
39 },
40}
41
42function clip<T>(text: T): T | string {
43 if (typeof text !== 'string') {
44 return text
45 }
46 return text.length > MAX_FIELD ? `${text.slice(0, MAX_FIELD)}\n[truncated]` : text
47}
48
49// Tool results arrive as user messages with empty text
50export function lastExchange(messages: SessionMessage[]): Exchange {
51 const i = messages.findLastIndex((m) => m.role === 'user' && m.text.trim() !== '')
52 if (i === -1) {
53 return { userRequest: '', assistantProposal: '', assistantThisTurn: '' }
54 }
55 const before = messages.slice(0, i).findLast((m) => m.role === 'assistant' && m.text.trim() !== '')
56 const after = messages
57 .slice(i + 1)
58 .filter((m) => m.role === 'assistant')
59 .map((m) => m.text)
60 return {
61 userRequest: clip(messages[i]?.text ?? ''),
62 assistantProposal: clip(before?.text ?? ''),
63 assistantThisTurn: clip(after.join('\n').trim()),
64 }
65}
66
67// The response that holds a tool call is not in $.session.messages() yet
68export function withStepText(exchange: Exchange, stepText: string): Exchange {
69 const text = stepText.trim()
70 if (!text || exchange.assistantThisTurn.includes(text)) {
71 return exchange
72 }
73 return { ...exchange, assistantThisTurn: clip([exchange.assistantThisTurn, text].filter(Boolean).join('\n')) }
74}
75
76export function toolCallState(exchange: Exchange, e: { tool: string } & Record<string, unknown>, currentFile?: string) {
77 const { tool, tool_use_id, agentId, ...input } = e
78 const clipped = Object.fromEntries(Object.entries(input).map(([k, v]) => [k, clip(v)]))
79 const state: ToolCallState = { ...exchange, toolCall: { tool, ...clipped } }
80 if (currentFile !== undefined) {
81 state.currentFileContent = clip(currentFile)
82 }
83 return state
84}
85
86export function probability(answers: Answers, name: string) {
87 const p = answers?.[name]?.probability
88 if (typeof p !== 'number') {
89 throw new Error(`Jev answer missing: ${name}`)
90 }
91 return p
92}
93
94export function toolVerdict(tool: string, answers: Answers) {
95 const writes = probability(answers, 'writes')
96 if (tool === 'Bash' && writes < 0.5) {
97 return null
98 }
99 const inScope = probability(answers, 'inScope')
100 const removes = probability(answers, 'removes')
101 const fails: string[] = []
102 if (inScope < IN_SCOPE_MIN) {
103 fails.push(`in-scope ${inScope.toFixed(2)} (needs ${IN_SCOPE_MIN} or more)`)
104 }
105 if (removes >= REMOVES_MAX) {
106 fails.push(`removes-behavior ${removes.toFixed(2)} (needs below ${REMOVES_MAX})`)
107 }
108 if (fails.length === 0) {
109 return null
110 }
111 return `jev-guard blocked this call. Jev rated ${fails.join(' and ')}. Ask the user before trying again.`
112}
113