Probability-scored guardrails for Claude Code: deny rule-breaking edits and unasked-for deploys, route your own docs into each prompt, and check the final…

A Claude Code plugin that scores what the model is about to do — and what it just claimed — with a probability model, and denies the call when the score is high enough.
Conventions written in CLAUDE.md are advice. The model follows them until the context gets long, then quietly stops. jev-guard moves the ones that matter out of the prompt and into hooks, where a broken rule is a denied tool call with the correction attached, not a review comment three days later.
Scoring runs through Jev (noul questions — each returns a probability, not a yes/no), so the thresholds are yours to set. Every failure path passes the call through: no key, endpoint down, malformed response, the tool call proceeds. The guard never blocks on the scorer being unavailable.
1. Rule guard (tool.call on Edit/Write/Bash)
hooks/rules.ts holds two tables. REGEX_RULES decide locally — no network, no cost — and suit anything a pattern can settle: a path, a command, a flag. JEV_RULES go to Jev as one batched request per tool call and cover what a regex cannot express: is this a hardcoded color where a token exists?, is this a catch block that swallows the error? A rule's reason is shown to the model verbatim, so write it as the correction you want.
2. Deploy gate (same request, Bash only)
Two questions on every command: does this deploy, publish, merge, or send a credential? and did the user's last prompt ask for exactly that? Deny when the first is ≥ denyThreshold and the second is < 0.5. This is what stops an agent from pushing a branch you were still discussing. It is deliberately literal — a general go-ahead does not satisfy the second question, and you will be asked again in the command's own words. That is the design, not a bug.
3. Background mover (same request)
A command Jev scores as long-running — installs, a full test suite, a dev server — is rewritten to run_in_background instead of blocking the turn. An explicit run_in_background you set yourself is always kept.
4. Answer check (turn.complete, main loop only)
The finished answer, the turn's last 40 tool results, and the prompt go to Jev as five questions:
| id | fires when |
|---|---|
unbacked-claim | the answer reports something done or passing, and no tool call in the turn shows that result |
merged-claim | it says a PR merged when the turn only shows a push, a comment, or a queued merge |
ends-on-promise | it ends on "I will…" or a question the model could have answered by working |
asks-on-disk-credential | it asks you for a secret that is already on disk |
hidden-red-check | a tool result shows a failure the answer does not mention |
Above answerThreshold it submits one correction prompt carrying the fix text, then marks the turn so the correction itself is not re-scored. One nudge per turn, no loops.
It is worth being precise about what this is: the model is asked how likely a claim is unsupported by the evidence in the same turn. It does not verify truth. It catches the common shape — an answer that sounds finished over a turn that never ran the check — and nothing more.
5. Doc router (prompt.submit, off by default)
Scores your MEMORY.md entries and a configured list of project docs against the incoming prompt and attaches the best routeCount as context. Turns a large docs directory into something the model reads the relevant page of, instead of all of it or none of it. Skipped for slash commands and prompts under 20 chars.
6. Skill gate
Skills whose description says "trigger ONLY on /x" are denied when the prompt did not name them.
7. Subagent model routing (off by default)
Downgrades a lookup-shaped Explore subagent to a cheaper model.
git clone https://github.com/<you>/jev-guard ~/.claude/plugins/jev-guard
export JEV_API_KEY=... # or TYPESAFE_API_KEY
Point Claude Code at the directory as a plugin, then edit hooks/rules.ts — it ships an example set of common React/React Native conventions to show both rule kinds. It is the config file, not a library; the rules are meant to be replaced with yours.
Optional env: JEV_BASE_URL (default https://api.typesafe.ai/v1/systemone), JEV_MODEL (default jev-latest).
| key | default | |
|---|---|---|
denyThreshold | 0.7 | how sure Jev must be before a call is denied |
answerThreshold | 0.75 | same, for the answer check |
verifyAnswers | true | the answer check |
routeMemories | false | the doc router |
memoryDir | "" | directory holding a MEMORY.md index of - Title — hook lines |
ruleFiles | "" | comma-separated project docs, relative to cwd, the router may attach |
gatedSkillPrefixes | "" | comma-separated prefixes of user-invoked-only skills |
slowThreshold | 0.55 | background mover |
agentRouting | false | subagent downgrade |
scripts/jev-lint.ts runs the same JEV_RULES over the added lines of a diff, one request per file:
bun scripts/jev-lint.ts --base origin/main --path src --threshold 0.7
Advisory by default (exit 0, findings to the job summary); --strict exits 1. Skips silently when JEV_API_KEY is unset, so forks and contributors without the secret are unaffected. The editor-time guard catches a rule as it is broken; this catches what was written with the plugin off.
One request per guarded tool call, one per prompt when routing is on, one per answer. Each one sends the relevant content to the Jev endpoint — the file being edited, the command, the prompt, or the answer plus its tool results. On a third-party endpoint by default. If that is not acceptable for your codebase, point JEV_BASE_URL somewhere you control, or run the regex tier alone by leaving JEV_RULES empty.
bun hooks/guard.test.mjs # fake `on`, canned transport, no network
claude plugin validate .
hooks/types/claude-code.d.ts is not vendored — generate it with /plugin-types if your editor wants the types.
One gotcha the test suite cannot warn you about: a regex rule is matched against raw command text, so a command that merely mentions the pattern — writing the rule file, grepping for it — trips the rule too. Anchor your patterns.
MIT
hooks/guard.ts 619 lines1import type { On, PluginOptions, Register } from "claude-code";
2
3import { JEV_RULES, REGEX_RULES, type Tool } from "./rules.js";
4
5const DEFAULT_URL = "https://api.typesafe.ai/v1/systemone";
6const DEFAULT_MODEL = "jev-latest";
7
8type Noul = {
9 type: "noul";
10 instructions: string;
11 criteria?: { true?: string; false?: string };
12};
13type Choice = {
14 type: "choice";
15 instructions: string;
16 options: Record<string, string>;
17};
18type Questions = Record<string, Noul | Choice>;
19type Answers = Record<
20 string,
21 {
22 noul?: number;
23 choice?: string;
24 probabilities?: Record<string, number>;
25 }
26>;
27
28function num(
29 options: PluginOptions,
30 key: string,
31 fallback: number
32): number {
33 const v = options[key];
34 return typeof v === "number" && Number.isFinite(v) ? v : fallback;
35}
36function str(
37 options: PluginOptions,
38 key: string,
39 fallback: string
40): string {
41 const v = options[key];
42 return typeof v === "string" && v.length > 0 ? v : fallback;
43}
44function message(error: unknown): string {
45 return error instanceof Error ? error.message : String(error);
46}
47
48type Jev = { apiKey?: string; url: string; model: string };
49type Deps = {
50 fetch: (
51 url: string,
52 init: {
53 method: string;
54 headers: Record<string, string>;
55 body: string;
56 }
57 ) => Promise<{ status: number; ok: boolean; text: string }>;
58 log: (text: string) => void;
59};
60
61/** One Jev request; throws on transport or shape errors, the caller decides the fallback. */
62async function ask(
63 jev: Jev,
64 deps: Deps,
65 state: string | object,
66 questions: Questions
67): Promise<Answers> {
68 const { apiKey, url, model } = jev;
69 if (!apiKey)
70 throw new Error(
71 "JEV_API_KEY is not set (session.start has not run?)"
72 );
73 const started = Date.now();
74 const res = await deps.fetch(url, {
75 method: "POST",
76 headers: {
77 authorization: `Bearer ${apiKey}`,
78 "content-type": "application/json",
79 },
80 body: JSON.stringify({ model, state, questions }),
81 });
82 if (!res.ok)
83 throw new Error(`Jev ${res.status}: ${res.text.slice(0, 200)}`);
84 const parsed: unknown = JSON.parse(res.text);
85 if (
86 !parsed ||
87 typeof parsed !== "object" ||
88 !("answers" in parsed) ||
89 !parsed.answers ||
90 typeof parsed.answers !== "object"
91 ) {
92 throw new Error("Jev response is missing answers");
93 }
94 deps.log(
95 `jev-guard: ${Object.keys(questions).length} q in ${Date.now() - started}ms`
96 );
97 return parsed.answers as Answers;
98}
99
100function noul(answers: Answers, key: string): number {
101 const p = answers[key]?.noul;
102 return typeof p === "number" && Number.isFinite(p) ? p : 0;
103}
104
105type Call = { tool: Tool; target: string; body: string };
106
107type Msg = {
108 role: "user" | "assistant";
109 text: string;
110 toolUses: readonly {
111 tool: string;
112 input: unknown;
113 text?: string;
114 isError?: boolean;
115 }[];
116 toolResults?: readonly unknown[];
117};
118
119/** The user's last typed prompt and a compact log of every tool call since it. */
120function turnEvidence(messages: readonly Msg[]): {
121 prompt: string;
122 calls: string[];
123} {
124 let start = 0;
125 for (let i = messages.length - 1; i >= 0; i--) {
126 const m = messages[i];
127 if (
128 m &&
129 m.role === "user" &&
130 m.text.trim() &&
131 !(m.toolResults && m.toolResults.length > 0)
132 ) {
133 start = i;
134 break;
135 }
136 }
137 const calls: string[] = [];
138 for (const m of messages.slice(start + 1)) {
139 for (const t of m.toolUses) {
140 const input = JSON.stringify(t.input).slice(0, 160);
141 calls.push(
142 `${t.tool}${t.isError ? " ERROR" : ""} ${input} -> ${(t.text ?? "").slice(0, 200).replace(/\s+/g, " ")}`
143 );
144 }
145 }
146 return {
147 prompt: messages[start]?.text.slice(0, 2000) ?? "",
148 calls: calls.slice(-40),
149 };
150}
151
152const ANSWER_CHECKS: readonly {
153 id: string;
154 question: string;
155 fix: string;
156}[] = [
157 {
158 id: "unbacked-claim",
159 question:
160 "The answer reports a task, check, test, push or merge as done or passing, but no tool call in the turn shows that result.",
161 fix: "Run the check or command whose result you reported and paste its output, or say plainly that it was not verified.",
162 },
163 {
164 id: "merged-claim",
165 question:
166 "The answer says a PR was merged, although the turn only shows a push, a comment, a review or a merge request being queued.",
167 fix: "Verify the merge state (gh pr view --json state,mergedAt) and report what it actually says.",
168 },
169 {
170 id: "ends-on-promise",
171 question:
172 'The answer ends with a plan, a promise of work not yet done ("I will…", "next I…"), or a question the assistant could answer itself by working.',
173 fix: "Do that work now and report the outcome instead of promising it.",
174 },
175 {
176 id: "asks-on-disk-credential",
177 question:
178 "The answer asks the user for a credential, token, secret, SECRET_NAME or a path to one.",
179 fix: "Credentials for this repo are on disk (~/.config/kai/*.env, see CLAUDE.md and memory). Source them and continue; only a write token is a real escalation.",
180 },
181 {
182 id: "hidden-red-check",
183 question:
184 "A tool result in the turn shows a failed check, failing test, type error or lint error that the answer does not mention.",
185 fix: "Report the failing command and its output verbatim, then fix it — a pre-existing failure in a touched file is part of the task.",
186 },
187];
188
189// Wordings were probed live (2026-09-19): criteria-form questions separated best.
190const BASH_CHECKS: Questions = {
191 deploy: {
192 type: "noul",
193 instructions:
194 "The command triggers a deploy, OTA update, publish, release, tag, PR merge, CI/workflow run, a push to a shared branch (staging, main, master), or sends a credential or token to a service.",
195 },
196 asked: {
197 type: "noul",
198 instructions:
199 "The user's last prompt asked for this action to happen, now or once a stated condition is met.",
200 },
201};
202const KNOWN_SLOW =
203 /\b(bun install|pod install|bun start|expo start|eas build|prebuild|ci:local)\b/;
204
205function guardTarget(e: Record<string, unknown>): Call | undefined {
206 const s = (k: string) =>
207 typeof e[k] === "string" ? (e[k] as string) : "";
208 if (e.tool === "Bash" && s("command"))
209 return { tool: "Bash", target: s("command"), body: s("command") };
210 if (e.tool === "Edit" && s("file_path")) {
211 return {
212 tool: "Edit",
213 target: s("file_path"),
214 body: `--- old\n${s("old_string")}\n+++ new\n${s("new_string")}`,
215 };
216 }
217 if (e.tool === "Write" && s("file_path"))
218 return {
219 tool: "Write",
220 target: s("file_path"),
221 body: s("content"),
222 };
223 return undefined;
224}
225
226export const register: Register = (on: On, options: PluginOptions) => {
227 const denyThreshold = num(options, "denyThreshold", 0.7);
228 const routeCount = num(options, "routeCount", 3);
229 const routeThreshold = num(options, "routeThreshold", 0.65);
230 const agentRouting = options.agentRouting === true;
231 const memoryDir = str(options, "memoryDir", "");
232 const verifyAnswers = options.verifyAnswers !== false;
233 const answerThreshold = num(options, "answerThreshold", 0.75);
234 // ends-on-promise is log-only: a live run showed the nudge pushing the model into unasked work.
235 const nudgeIds = str(
236 options,
237 "nudgeOn",
238 "unbacked-claim,merged-claim,hidden-red-check,asks-on-disk-credential"
239 )
240 .split(",")
241 .map((x) => x.trim());
242 const routeMemories = options.routeMemories === true;
243 const gatedSkills = str(options, "gatedSkillPrefixes", "")
244 .split(",")
245 .map((x) => x.trim())
246 .filter(Boolean);
247 // Project docs the router may attach, as paths relative to the session cwd.
248 const ruleFiles = str(options, "ruleFiles", "")
249 .split(",")
250 .map((x) => x.trim())
251 .filter(Boolean);
252 let nudged = false;
253 let jev: Jev = { url: DEFAULT_URL, model: DEFAULT_MODEL };
254
255 // Env is read once here: the validator wants literal names, hooks want no per-call env reads.
256 on("session.start", async ($, e, next) => {
257 jev = {
258 apiKey:
259 (await $.env.get("JEV_API_KEY")) ??
260 (await $.env.get("TYPESAFE_API_KEY")),
261 url: (await $.env.get("JEV_BASE_URL")) ?? DEFAULT_URL,
262 model: (await $.env.get("JEV_MODEL")) ?? DEFAULT_MODEL,
263 };
264 return next(e);
265 });
266
267 // Hook 1: rule guard on Edit / Write / Bash.
268 on(
269 "tool.call",
270 { tool: ["Edit", "Write", "Bash"] },
271 async ($, e, next) => {
272 const call = guardTarget(e as Record<string, unknown>);
273 if (!call) return next(e);
274 const cwd = await $.session.cwd();
275
276 for (const rule of REGEX_RULES) {
277 if (
278 rule.tools.includes(call.tool) &&
279 rule.test(call.target, cwd)
280 ) {
281 $.ui.log(
282 `jev-guard: deny ${rule.id} (regex) ${call.target.slice(0, 80)}`
283 );
284 return { deny: `jev-guard [${rule.id}]: ${rule.reason}` };
285 }
286 }
287
288 const rules = JEV_RULES.filter(
289 (r) =>
290 r.tools.includes(call.tool) &&
291 (!r.path || r.path.test(call.target))
292 );
293 const isBash = call.tool === "Bash";
294 if (rules.length === 0 && !isBash) return next(e);
295
296 try {
297 const questions: Questions = {};
298 for (const r of rules) {
299 questions[r.id] = {
300 type: "noul",
301 instructions: r.question,
302 criteria: {
303 true: "The statement holds for this change.",
304 false: "It does not.",
305 },
306 };
307 }
308 const state: Record<string, unknown> = {
309 tool: call.tool,
310 target: call.target,
311 change: call.body.slice(0, 12_000),
312 };
313 if (isBash) {
314 state.userPrompt = turnEvidence(
315 (await $.session.messages()) as Msg[]
316 ).prompt;
317 Object.assign(questions, BASH_CHECKS);
318 }
319 const answers = await ask(
320 jev,
321 {
322 fetch: (u, i) => $.http.fetch(u, i),
323 log: (t) => $.ui.log(t),
324 },
325 state,
326 questions
327 );
328 let worst:
329 | { id: string; p: number; reason: string }
330 | undefined;
331 for (const r of rules) {
332 const p = noul(answers, r.id);
333 if (p >= 0.5)
334 $.ui.log(
335 `jev-guard: ${r.id} p=${p.toFixed(2)} ${call.target.slice(0, 80)}`
336 );
337 if (p >= denyThreshold && (!worst || p > worst.p))
338 worst = { id: r.id, p, reason: r.reason };
339 }
340 if (worst)
341 return {
342 deny: `jev-guard [${worst.id}, p=${worst.p.toFixed(2)}]: ${worst.reason}`,
343 };
344 if (isBash) {
345 const deploy = noul(answers, "deploy");
346 const asked = noul(answers, "asked");
347 if (deploy >= denyThreshold && asked < 0.5) {
348 $.ui.log(
349 `jev-guard: deny deploy-gate p=${deploy.toFixed(2)} asked=${asked.toFixed(2)} ${call.target.slice(0, 80)}`
350 );
351 return {
352 deny: `jev-guard [deploy-gate, p=${deploy.toFixed(2)}]: this command deploys, publishes, merges or sends a credential and the user did not ask for exactly that this turn. Present it and wait for an explicit go-ahead.`,
353 };
354 }
355 // ponytail: Jev scored `cat`/`grep` as long-running (0.94) in a live run; the regex is the whole rule.
356 const input = e as Record<string, unknown>;
357 if (
358 KNOWN_SLOW.test(call.target) &&
359 input.run_in_background === undefined &&
360 !/&\s*$/.test(call.target)
361 ) {
362 $.ui.log(
363 `jev-guard: background ${call.target.slice(0, 80)}`
364 );
365 return next({ ...e, run_in_background: true } as typeof e);
366 }
367 }
368 } catch (error) {
369 $.ui.log(`jev-guard: skipped (${message(error)})`);
370 }
371 return next(e);
372 }
373 );
374
375 // Skill gate: user-invoked skills need the user's words, not the model's guess.
376 on("tool.call", { tool: "Skill" }, async ($, e, next) => {
377 const input = e as Record<string, unknown>;
378 const skill = typeof input.skill === "string" ? input.skill : "";
379 if (!gatedSkills.some((prefix) => skill.startsWith(prefix)))
380 return next(e);
381 try {
382 const { prompt } = turnEvidence(
383 (await $.session.messages()) as Msg[]
384 );
385 if (
386 prompt.includes(`/${skill}`) ||
387 prompt.includes(`/${skill.split(":").pop() ?? ""}`)
388 )
389 return next(e);
390 const answers = await ask(
391 jev,
392 {
393 fetch: (u, i) => $.http.fetch(u, i),
394 log: (t) => $.ui.log(t),
395 },
396 { skill, args: input.args ?? "", userPrompt: prompt },
397 {
398 invoked: {
399 type: "noul",
400 instructions:
401 "The skill was explicitly invoked by the user.",
402 criteria: {
403 true: "The prompt contains /<skill name> or says to run/use/invoke that skill by name.",
404 false:
405 "The prompt describes a task in its own words without naming the skill.",
406 },
407 },
408 }
409 );
410 const p = noul(answers, "invoked");
411 if (p < 0.5) {
412 $.ui.log(
413 `jev-guard: deny skill-gate ${skill} p=${p.toFixed(2)}`
414 );
415 return {
416 deny: `jev-guard [skill-gate]: ${skill} is user-invoked and the prompt did not ask for it. Do the work directly, or ask the user whether to run /${skill}.`,
417 };
418 }
419 } catch (error) {
420 $.ui.log(`jev-guard: skill gate skipped (${message(error)})`);
421 }
422 return next(e);
423 });
424
425 // Answer check: the final message of a main-loop turn against the turn's own evidence.
426 on("turn.complete", async ($, e, next) => {
427 if (
428 !verifyAnswers ||
429 e.agentId ||
430 e.reason !== "answer" ||
431 !e.answer.trim()
432 )
433 return next(e);
434 if (nudged) {
435 nudged = false;
436 return next(e);
437 }
438 try {
439 const { prompt, calls } = turnEvidence(
440 (await $.session.messages()) as Msg[]
441 );
442 const questions: Questions = {};
443 for (const c of ANSWER_CHECKS)
444 questions[c.id] = { type: "noul", instructions: c.question };
445 const answers = await ask(
446 jev,
447 {
448 fetch: (u, i) => $.http.fetch(u, i),
449 log: (t) => $.ui.log(t),
450 },
451 {
452 userPrompt: prompt,
453 toolCallsThisTurn: calls,
454 answer: e.answer.slice(0, 6000),
455 },
456 questions
457 );
458 const hits = ANSWER_CHECKS.map((c) => ({
459 ...c,
460 p: noul(answers, c.id),
461 })).filter(
462 (c) => c.p >= answerThreshold && nudgeIds.includes(c.id)
463 );
464 for (const c of ANSWER_CHECKS) {
465 const p = noul(answers, c.id);
466 if (p >= 0.5)
467 $.ui.log(`jev-guard: answer ${c.id} p=${p.toFixed(2)}`);
468 }
469 if (hits.length > 0) {
470 nudged = true;
471 const text = `jev-guard flagged your last answer:\n${hits.map((h) => `- ${h.id} (p=${h.p.toFixed(2)}): ${h.fix}`).join("\n")}\nAddress each point now, then answer again.`;
472 $.ui.toast(`jev-guard: ${hits.map((h) => h.id).join(", ")}`, {
473 timeoutMs: 8000,
474 });
475 await $.prompt.submit({ text });
476 }
477 } catch (error) {
478 $.ui.log(`jev-guard: answer check skipped (${message(error)})`);
479 }
480 return next(e);
481 });
482
483 // Hook 2: memory / rule router on each prompt.
484 on("prompt.submit", async ($, e, next) => {
485 const text = e.text.trim();
486 if (
487 !routeMemories ||
488 text.startsWith("/") ||
489 text.startsWith("jev-guard") ||
490 text.length < 20 ||
491 (!memoryDir && ruleFiles.length === 0)
492 )
493 return next(e);
494 try {
495 const cwd = await $.session.cwd();
496 const candidates: { key: string; path: string; hook: string }[] =
497 [];
498 if (memoryDir) {
499 const index = await $.fs.read(`${memoryDir}/MEMORY.md`);
500 for (const m of index.matchAll(
501 /\[([^\]]+)\]\(([^)]+\.md)\)([^·\n]*)/g
502 )) {
503 candidates.push({
504 key: `m${candidates.length}`,
505 path: `${memoryDir}/${m[2]}`,
506 hook: `${m[1]} ${m[3] ?? ""}`.trim(),
507 });
508 }
509 }
510 for (const rel of ruleFiles) {
511 const name = rel.replace(/^.*\//, "").replace(/\.md$/, "");
512 candidates.push({
513 key: `r${candidates.length}`,
514 path: `${cwd}/${rel}`,
515 hook: `rule: ${name.replace(/-/g, " ")}`,
516 });
517 }
518 // Measured: hooks in the state and a short per-note question rank far better than
519 // the hook inside each question (0.85 vs 0.42 on the right note, same latency).
520 const notes: Record<string, string> = {};
521 const questions: Questions = {};
522 for (const c of candidates) {
523 notes[c.key] = c.hook;
524 questions[c.key] = {
525 type: "noul",
526 instructions: `Note ${c.key} should be read before carrying out the prompt.`,
527 };
528 }
529 const answers = await ask(
530 jev,
531 {
532 fetch: (u, i) => $.http.fetch(u, i),
533 log: (t) => $.ui.log(t),
534 },
535 { prompt: text.slice(0, 4000), notes },
536 questions
537 );
538 const picked = candidates
539 .map((c) => ({ ...c, p: noul(answers, c.key) }))
540 .filter((c) => c.p >= routeThreshold)
541 .sort((a, b) => b.p - a.p)
542 .slice(0, routeCount);
543 if (picked.length === 0) return next(e);
544 const context: string[] = [];
545 for (const c of picked) {
546 try {
547 const body = await $.fs.read(c.path);
548 context.push(
549 `<!-- jev-guard routed: ${c.path} (p=${c.p.toFixed(2)}) -->\n${body.slice(0, 8000)}`
550 );
551 } catch {
552 // ponytail: a stale index line is skipped, not fatal
553 }
554 }
555 $.ui.log(
556 `jev-guard: routed ${picked.map((c) => `${c.path.split("/").pop()}@${c.p.toFixed(2)}`).join(", ")}`
557 );
558 return next({
559 ...e,
560 context: [...(e.context ?? []), ...context],
561 });
562 } catch (error) {
563 $.ui.log(`jev-guard: router skipped (${message(error)})`);
564 return next(e);
565 }
566 });
567
568 // Hook 3: subagent model routing (opt-in). Only downgrades lookup-style Explore/general-purpose calls.
569 on("tool.call", { tool: "Agent" }, async ($, e, next) => {
570 if (!agentRouting) return next(e);
571 const input = e as Record<string, unknown>;
572 const type = input.subagent_type;
573 if (input.model !== undefined) return next(e);
574 if (
575 type !== undefined &&
576 type !== "Explore" &&
577 type !== "general-purpose"
578 )
579 return next(e);
580 const prompt = typeof input.prompt === "string" ? input.prompt : "";
581 try {
582 const answers = await ask(
583 jev,
584 {
585 fetch: (u, i) => $.http.fetch(u, i),
586 log: (t) => $.ui.log(t),
587 },
588 {
589 subagent_type: type ?? "general-purpose",
590 prompt: prompt.slice(0, 4000),
591 },
592 {
593 tier: {
594 type: "choice",
595 instructions:
596 "The model capability this subagent task needs.",
597 options: {
598 haiku:
599 "A lookup: find one file, symbol or value; list matches; no judgement.",
600 sonnet:
601 "Read several files and summarise or compare; light judgement.",
602 opus: "Design, review, debugging, multi-step edits, or anything ambiguous.",
603 },
604 },
605 }
606 );
607 const choice = answers.tier?.choice;
608 const p = answers.tier?.probabilities?.[choice ?? ""] ?? 0;
609 if (choice === "haiku" && p >= denyThreshold) {
610 $.ui.log(`jev-guard: Agent -> haiku (p=${p.toFixed(2)})`);
611 return next({ ...e, model: "haiku" } as typeof e);
612 }
613 } catch (error) {
614 $.ui.log(`jev-guard: agent routing skipped (${message(error)})`);
615 }
616 return next(e);
617 });
618};
619hooks/rules.ts 113 lines1// The rule table. THIS FILE IS THE CONFIG — edit it for your project.
2//
3// Two kinds:
4// REGEX_RULES decided locally, no network, no cost. Use for anything a
5// pattern can decide: a path, a command, a flag.
6// JEV_RULES sent to Jev as noul questions (one request per tool call) and
7// denied at `denyThreshold`. Use for the rules a regex cannot
8// express — intent, not syntax.
9//
10// A rule's `reason` is shown to the model verbatim as the deny text, so write
11// it as the correction you want, quoting your own conventions doc. That is what
12// makes the model self-correct instead of retrying the same call.
13//
14// Everything below is an EXAMPLE set: common React/React Native conventions,
15// chosen because they demonstrate both rule kinds. Delete what does not apply.
16//
17// One caveat worth knowing before you write a regex rule: the rule is tested
18// against the raw command text, so a command that merely *contains* the pattern
19// — writing this file, grepping for it — trips it too. Keep patterns anchored.
20
21export type Tool = "Edit" | "Write" | "Bash";
22
23export type RegexRule = {
24 id: string;
25 tools: readonly Tool[];
26 /** Tested against the file path (Edit/Write) or the command (Bash). */
27 test: (target: string, cwd: string) => boolean;
28 reason: string;
29};
30
31export type JevRule = {
32 id: string;
33 tools: readonly Tool[];
34 /** Only ask when the file path matches; absent = always ask for those tools. */
35 path?: RegExp;
36 question: string;
37 reason: string;
38};
39
40/** Build artefacts that a generator owns and a hand edit silently loses. */
41const GENERATED = /(\.generated\.[tj]sx?|(^|\/)(dist|build)\/|\.lock$)/;
42
43export const REGEX_RULES: readonly RegexRule[] = [
44 {
45 id: "generated-file",
46 tools: ["Edit", "Write"],
47 test: (p) => GENERATED.test(p),
48 reason:
49 "Generated file: never edit by hand — the next build reverts it. Change the source and re-run its generator.",
50 },
51 {
52 id: "force-push",
53 tools: ["Bash"],
54 test: (c) =>
55 /^\s*git\s+push\b/m.test(c) &&
56 /(--force(?!-with-lease)|\s-f\b)/.test(c),
57 reason:
58 "A bare force push discards whatever landed since your last fetch. Use `--force-with-lease`, or stop and ask.",
59 },
60 {
61 id: "wrong-package-manager",
62 // Adjust to whichever manager this project actually uses.
63 tools: ["Bash"],
64 test: (c) => /^\s*(npm|yarn)\s/m.test(c),
65 reason: "This project's package manager is Bun. Use `bun`/`bunx`.",
66 },
67];
68
69/**
70 * Limit the semantic rules to source components, so a doc edit costs no request.
71 * Matches both an absolute path (the editor guard) and a repo-relative one (the
72 * CI lint reading a diff) — hence the leading alternation.
73 */
74const SOURCE = /(^|\/)src\/.*\.[jt]sx$/;
75
76export const JEV_RULES: readonly JevRule[] = [
77 {
78 id: "hardcoded-color",
79 tools: ["Edit", "Write"],
80 path: SOURCE,
81 question:
82 "The new code hardcodes a color literal (hex, rgb, named color) in a style or className where a design token would do.",
83 reason:
84 "Use the design tokens; do not add hardcoded colors when a token exists.",
85 },
86 {
87 id: "hardcoded-copy",
88 tools: ["Edit", "Write"],
89 path: SOURCE,
90 question:
91 "The new code renders user-facing UI text or an accessibility label as a literal string instead of going through the translation layer.",
92 reason:
93 "Never hardcode UI strings when a translation key is appropriate; an accessibility label is spoken UI and must be localized too.",
94 },
95 {
96 id: "manual-memo",
97 tools: ["Edit", "Write"],
98 path: SOURCE,
99 question:
100 "The new code adds useMemo, useCallback or React.memo with no comment stating a correctness, third-party identity or measured performance reason.",
101 reason:
102 "React Compiler is enabled. Do not add `useMemo`, `useCallback` or `React.memo` by default — keep them only where a comment says why.",
103 },
104 {
105 id: "swallowed-error",
106 tools: ["Edit", "Write"],
107 question:
108 "The new code adds a catch block that discards the error — empty body, or only a log — where the caller can no longer tell the operation failed.",
109 reason:
110 "A swallowed error becomes a silent failure. Re-throw, return a failure the caller must handle, or add a comment stating why dropping it is correct.",
111 },
112];
113