Classifies each prompt's intent with Jev (question, plan, review, execute, ops) and blocks file edits and git writes on question, plan and review turns.

Stops the agent from editing code when you only asked a question, a plan or a review ("review it, don't execute", "I only want the docs").
claude -p), Jev classifies the intent: question, plan, review, execute, ops or none. The previous turn's intent, prompt and reply tail go along as evidence, so "continue", "1. A 2. yes" and "ok go" follow the thread. Secrets (JWTs, bearer tokens, passwords, DSNs, cookies, API keys, private keys) are redacted first, because Jev is an external API.intent: <intent>./plans/, /specs/, /adr/, docs/superpowers, ~/.local/state/zstack and /tmp./intent execute|question|plan|review sets the intent for the running turn, or for your next prompt when no turn is running. /intent off turns the gate off for this session, and /intent auto turns it back on.Fails open. If Jev is unavailable, times out (8 s) or answers with confidence below 0.6, nothing is blocked. The status line then shows intent: ? (Jev unavailable) or intent: ? (unsure, …). If the guard throws, the call goes through. A wrong block costs you a turn, and a missed block costs no more than running without the mod. Jev needs TYPESAFE_API_KEY in the environment Claude Code starts with. Without it, a session the desktop app starts fails open.
Limits. Under claude -p nobody can say "go", so a denied edit stays denied and the model reports in text. It never waits for input. File writes through Bash (sed -i, cat >, scripts) are not gated. The classifier adds one Jev round trip (about 0.4 s) before each prompt enters.
hooks/register.ts 218 lines1import { update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { IntentGateIntent, IntentGatePrevious, IntentGateTurn } from '../types/index'
4
5const TURN = { plugin: 'intent-gate', key: 'turn' } as const
6const PREVIOUS = { plugin: 'intent-gate', key: 'previous' } as const
7const MODE = { plugin: 'intent-gate', key: 'mode' } as const
8const PINNED = { plugin: 'intent-gate', key: 'pinned' } as const
9const RUNNING = { plugin: 'intent-gate', key: 'running' } as const
10
11const INTENTS: readonly IntentGateIntent[] = ['question', 'plan', 'review', 'execute', 'ops', 'none']
12const BLOCKING: readonly IntentGateIntent[] = ['question', 'plan', 'review']
13const MIN_CONFIDENCE = 0.6
14const JEV_TIMEOUT_MS = 8000
15
16// Prompts a person (or their own script) wrote; other origins keep the previous intent.
17const PERSON_ORIGINS = ['composer', 'bridge', 'sdk']
18
19const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
20
21// Secrets the user pastes into prompts. Jev is an external API: these never leave the machine.
22const SECRETS: readonly [RegExp, string][] = [
23 [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, '[REDACTED KEY]'],
24 [/\beyJ[\w-]+\.[\w-]+\.[\w-]*/g, '[REDACTED JWT]'],
25 [/\b(Bearer|Basic|Token)\s+[\w.~+/=-]{8,}/gi, '$1 [REDACTED]'],
26 [/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:/@]+:[^\s@/]+@/gi, '$1[REDACTED]@'],
27 [/\b((?:set-)?cookie)\s*:\s*[^\n]+/gi, '$1: [REDACTED]'],
28 [/\b(password|passwd|pwd|pass|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth)(["']?\s*[:=]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1$2[REDACTED]'],
29 [/\bsshpass\s+-p\s*\S+/g, 'sshpass -p [REDACTED]'],
30 [/\b(sk-[\w-]{16,}|gh[pousr]_\w{20,}|github_pat_\w{20,}|xox[abprs]-[\w-]{10,}|glpat-[\w-]{16,}|AKIA[0-9A-Z]{16}|AIza[\w-]{35})/g, '[REDACTED KEY]'],
31 [/\b(?=[A-Za-z0-9+/_-]*\d)(?=[A-Za-z0-9+/_-]*[A-Za-z])[A-Za-z0-9+/_-]{40,}={0,2}/g, '[REDACTED]'],
32]
33
34export function redact(text: string): string {
35 return SECRETS.reduce((t, [pattern, replacement]) => t.replace(pattern, replacement), text)
36}
37
38const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
39const tail = (text: string, max: number) => (text.length > max ? `…${text.slice(-max)}` : text)
40
41const GIT_WRITE =
42 /\bgit\s+(?:-[Cc]\s+\S+\s+|--?[\w-]+(?:=\S+)?\s+)*(?:add|commit|push|reset|rebase|merge|cherry-pick|revert|restore|clean|rm|mv|am|apply|stash(?!\s+(?:list|show)\b)|checkout\b[^;&|\n]*\s--(?:\s|$))/
43
44export function isGitWrite(command: string): boolean {
45 return GIT_WRITE.test(command)
46}
47
48const PLAN_PATH = [/(^|\/)(plans|specs|adrs?)\//i, /(^|\/)docs\/superpowers(\/|$)/, /\/\.local\/state\/zstack(\/|$)/, /^\/tmp\//]
49
50export function isPlanPath(path: string): boolean {
51 return !/(^|\/)\.\.(\/|$)/.test(path) && PLAN_PATH.some(p => p.test(path))
52}
53
54function editedPath(e: Record<string, unknown>): string {
55 const path = e.file_path ?? e.notebook_path
56 return typeof path === 'string' ? path : ''
57}
58
59export type Verdict = { intent?: IntentGateIntent; confidence?: number; source: IntentGateTurn['source'] }
60
61export function readJevAnswer(stdout: string): Verdict {
62 const answers = JSON.parse(stdout) as { intent?: { choice?: unknown; confidence?: unknown } }
63 const choice = answers.intent?.choice
64 const confidence = answers.intent?.confidence
65 if (typeof choice !== 'string' || !INTENTS.includes(choice as IntentGateIntent) || typeof confidence !== 'number') {
66 return { source: 'unavailable' }
67 }
68 const intent = choice as IntentGateIntent
69 return confidence < MIN_CONFIDENCE ? { intent, confidence, source: 'unsure' } : { intent, confidence, source: 'jev' }
70}
71
72export function jevRequest(prompt: string, previous: IntentGatePrevious | undefined) {
73 return {
74 state: {
75 prompt: clip(redact(prompt), 2000),
76 previous_turn: {
77 intent: previous?.intent ?? 'unknown',
78 prompt: clip(redact(previous?.prompt ?? ''), 300),
79 assistant_reply_tail: redact(previous?.replyTail ?? ''),
80 },
81 policy:
82 'Classify what the person wants done THIS turn. A terse follow-up ("continue", "go", "lanjutin", "1. A 2. yes") keeps the previous turn intent unless it explicitly asks to start changing things.',
83 },
84 questions: {
85 intent: {
86 type: 'choice',
87 instructions: 'Which intent best describes `prompt`, using `previous_turn` only to interpret terse follow-ups?',
88 criteria: {
89 question: 'Asks a question, wants an opinion, explanation or diagnosis; no change to files wanted',
90 plan: 'Wants a plan, spec, design or ADR written or discussed; explicitly not the implementation yet',
91 review: 'Wants a review, audit or check of existing code or changes, with no edits',
92 execute: 'Wants files changed: write, fix, implement, refactor, revert, edit config or docs, continue implementing, commit or push',
93 ops: 'Wants something run or operated: trigger, deploy, run a job or command until it works',
94 none: 'Unclear, empty, or not a request',
95 },
96 },
97 },
98 }
99}
100
101async function classify($: EngineInterface, prompt: string, previous: IntentGatePrevious | undefined): Promise<Verdict> {
102 try {
103 const home = (await $.env.get('HOME')) ?? ''
104 const ran = await $.process.run([home ? `${home}/.local/bin/jev` : 'jev'], {
105 stdin: JSON.stringify(jevRequest(prompt, previous)),
106 timeoutMs: JEV_TIMEOUT_MS,
107 })
108 return ran.exitCode === 0 ? readJevAnswer(ran.stdout) : { source: 'unavailable' }
109 } catch {
110 return { source: 'unavailable' }
111 }
112}
113
114export function statusLine(turn: IntentGateTurn | undefined, mode: 'auto' | 'off' | undefined): string | undefined {
115 if (mode === 'off') return 'intent: off'
116 if (!turn) return undefined
117 if (turn.source === 'unavailable') return 'intent: ? (Jev unavailable)'
118 if (turn.source === 'unsure') return `intent: ? (unsure, ${turn.intent} ${turn.confidence?.toFixed(2)})`
119 return `intent: ${turn.intent}${turn.source === 'override' ? ' (set by /intent)' : ''}`
120}
121
122function denyReason(turn: IntentGateTurn, what: string): string {
123 const by = turn.source === 'override' ? 'set by /intent' : `Jev ${turn.confidence?.toFixed(2)}`
124 const instead = turn.intent === 'plan' ? 'write the plan (plan files under plans/, specs/, adr/ or /tmp are allowed) or reply in text' : 'answer in text'
125 return (
126 `intent-gate: the user's request this turn is "${turn.intent}" (${by}), so ${what} is blocked. ` +
127 `Do not change files or git state; ${instead}. If changes are needed, describe them and ask the user to reply "go" (or run /intent execute).`
128 )
129}
130
131const USAGE = 'Usage: /intent <execute|question|plan|review|off|auto>'
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 await $.command.register({
136 name: 'intent',
137 description: 'Override intent-gate: set this turn\'s intent, or switch the gate off/auto for the session.',
138 argumentHint: '<execute|question|plan|review|off|auto>',
139 immediate: true,
140 })
141 return next(e)
142 })
143
144 on('command.run', { command: 'intent' }, async ($, e) => {
145 const arg = e.args.trim().toLowerCase()
146 if (arg === 'off' || arg === 'auto') {
147 await $.state.set(MODE, arg)
148 await $.state.set(PINNED, null)
149 const { value: turn } = await $.state.get(TURN)
150 $.ui.status(statusLine(turn, arg))
151 return { text: arg === 'off' ? 'intent-gate is off for this session. /intent auto turns it back on.' : 'intent-gate classifies prompts again.' }
152 }
153 const intent = INTENTS.find(i => i === arg && i !== 'none')
154 if (!intent) return { text: USAGE }
155
156 // Mid-turn the override applies to the running turn; when idle, to the next prompt.
157 const { value: running } = await $.state.get(RUNNING)
158 const turn: IntentGateTurn = { intent, source: 'override' }
159 await $.state.set(MODE, 'auto')
160 await $.state.set(TURN, turn)
161 await $.state.set(PINNED, running ? null : intent)
162 $.ui.status(statusLine(turn, 'auto'))
163 return { text: `intent set to ${intent} for ${running ? 'the running turn' : 'your next prompt'}.` }
164 })
165
166 on('prompt.submit', async ($, e, next) => {
167 if (!PERSON_ORIGINS.includes(e.origin.kind) || e.text.trimStart().startsWith('/intent')) return next(e)
168 const { value: mode } = await $.state.get(MODE)
169 if (mode === 'off') return next(e)
170
171 const { value: pinned } = await $.state.get(PINNED)
172 let turn: IntentGateTurn
173 if (pinned) {
174 turn = { intent: pinned, source: 'override' }
175 await $.state.set(PINNED, null)
176 } else {
177 const { value: previous } = await $.state.get(PREVIOUS)
178 turn = await classify($, e.text, previous)
179 }
180 await $.state.set(TURN, turn)
181 await update($, PREVIOUS, p => ({ intent: turn.intent, prompt: e.text, replyTail: p?.replyTail ?? '' }))
182 $.ui.status(statusLine(turn, mode))
183 return next(e)
184 }).catch(($, e, next) => next(e))
185
186 on('turn.start', async ($, e, next) => {
187 await $.state.set(RUNNING, true)
188 return next(e)
189 })
190
191 on('turn.complete', async ($, e, next) => {
192 if (e.agentId === undefined) {
193 await $.state.set(RUNNING, false)
194 await update($, PREVIOUS, p => ({ intent: p?.intent, prompt: p?.prompt ?? '', replyTail: tail(e.answer, 400) }))
195 }
196 return next(e)
197 })
198
199 on('tool.call', async ($, e, next) => {
200 const tool = String(e.tool)
201 const isEdit = EDIT_TOOLS.includes(tool)
202 const command = tool === 'Bash' ? String((e as Record<string, unknown>).command ?? '') : ''
203 if (!isEdit && !isGitWrite(command)) return next(e)
204
205 const { value: mode } = await $.state.get(MODE)
206 const { value: turn } = await $.state.get(TURN)
207 if (mode === 'off' || !turn?.intent || turn.source === 'unavailable' || turn.source === 'unsure') return next(e)
208 if (!BLOCKING.includes(turn.intent)) return next(e)
209
210 if (isEdit) {
211 const path = editedPath(e as Record<string, unknown>)
212 if (turn.intent === 'plan' && isPlanPath(path)) return next(e)
213 return { deny: denyReason(turn, `${tool} on ${path || 'a file'}`) }
214 }
215 return { deny: denyReason(turn, 'a git write') }
216 }).catch(($, e, next) => next(e))
217}
218types/index.d.ts 28 lines1export type IntentGateIntent = 'question' | 'plan' | 'review' | 'execute' | 'ops' | 'none'
2
3/** The current turn's intent and where it came from. */
4export type IntentGateTurn = {
5 intent?: IntentGateIntent
6 confidence?: number
7 source: 'jev' | 'override' | 'unavailable' | 'unsure'
8}
9
10/** What the next classification reads as evidence about the previous turn. */
11export type IntentGatePrevious = {
12 intent?: IntentGateIntent
13 prompt: string
14 replyTail: string
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'intent-gate': {
20 turn: IntentGateTurn
21 previous: IntentGatePrevious
22 mode: 'auto' | 'off'
23 pinned: IntentGateIntent | null
24 running: boolean
25 }
26 }
27}
28