SLOPSHOPPER

jev-guard

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…

newguardtoastpromptnetwork
★ 7v0.1.0MITupdated 2026-09-22muratcakmak/jev-guard
A shopper browsing a rack in a slop shop
README

jev-guard

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.

What it does

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:

idfires when
unbacked-claimthe answer reports something done or passing, and no tool call in the turn shows that result
merged-claimit says a PR merged when the turn only shows a push, a comment, or a queued merge
ends-on-promiseit ends on "I will…" or a question the model could have answered by working
asks-on-disk-credentialit asks you for a secret that is already on disk
hidden-red-checka 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.

Install

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).

Settings worth knowing

keydefault
denyThreshold0.7how sure Jev must be before a call is denied
answerThreshold0.75same, for the answer check
verifyAnswerstruethe answer check
routeMemoriesfalsethe 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
slowThreshold0.55background mover
agentRoutingfalsesubagent downgrade

CI twin

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.

Cost and privacy

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.

Development

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.

License

MIT

Source 2 files
hooks/guard.ts 619 lines
1import 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};
619
hooks/rules.ts 113 lines
1// 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