SLOPSHOPPER

jev

Adds $.jev: typed Choice, Score and Noul judgments from TypeSafe's Jev, with a timeout and validated answers, for other mods to call.

newnetworktimer
v0.1.0MITupdated 2026-10-10gecm0/jev-mod
A shopper browsing a rack in a slop shop
README

jev

A Claude Code mod that adds $.jev to the engine, so other mods can ask TypeSafe's Jev typed Choice, Score and Noul questions and get calibrated probabilities back.

const { answers } = await $.jev.ask({
  state: { command: e.command },
  questions: {
    destructive: {
      type: 'noul',
      instructions: 'The command deletes files that cannot be reproduced.',
      criteria: { true: 'Removes source or data.', false: 'Removes build output.' },
    },
  },
  timeoutMs: 1500,
})
if (answers.destructive.noul > 0.9) return { deny: 'looks destructive' }
  • TypeSafe API only (https://api.typesafe.ai/v1/systemone).
  • Timeout per call: 4 s by default, 9 s at most. The request is abandoned, not cancelled.
  • Every answer is checked against its question before it is returned.
  • Error messages carry the HTTP status and a hint, never the response body.

The full contract is types/index.d.ts. A mod that lists jev under dependencies gets those types laid into its own types folder.

Install

Needs Claude Code v2.1.287 or later.

/plugin install jev --marketplace gecm0/jev-mod

Set the key in /plugin → Installed → jev → Configure options (kept in the keychain), or export TYPESAFE_API_KEY in the shell that starts claude. TYPESAFE_MODEL overrides the model (default jev-latest). These are the same variables jev-judge-mcp reads.

Data

Nothing is sent until a mod calls $.jev.ask. Each call sends the state and questions that mod passes, plus the model id, to api.typesafe.ai only.

Development

claude --plugin-dir .     # load from disk, hot-reloads on save
claude plugin validate .
claude plugin test .      # offline: fake fetch, no key needed
Source 3 files
hooks/register.ts 31 lines
1import type { Register } from 'claude-code'
2
3import { jevOf } from './jev'
4
5/** A non-empty plugin option, else what the environment holds. */
6function pick(option: unknown, fromEnv: string | undefined): string | undefined {
7  return typeof option === 'string' && option.trim() !== '' ? option : fromEnv
8}
9
10export const register: Register = (on, options) => {
11  // `$` is empty in engine.create; everything goes through the nouns beneath.
12  on('engine.create', async ($, e, next) => {
13    const beneath = await next(e)
14    const jev = jevOf({
15      // Read on every call, so a key set mid-session takes. The loader only
16      // allows env names spelled as literals at the call site.
17      config: async () => ({
18        apiKey: pick(options.typesafe_api_key, await beneath.env.get('TYPESAFE_API_KEY')),
19        model: pick(options.model, await beneath.env.get('TYPESAFE_MODEL')),
20      }),
21      fetch: async (url, init) => {
22        const { ok, status, text } = await beneath.http.fetch(url, init)
23        return { ok, status, text }
24      },
25      after: (ms, fn) => beneath.clock.after(ms, fn),
26      now: () => Date.now(),
27    })
28    return { ...beneath, jev }
29  })
30}
31
hooks/jev.ts 212 lines
1import type { Jev, JevQuestion, JevQuestions, JevRequest, JevResult } from '../types'
2
3const ENDPOINT = 'https://api.typesafe.ai/v1/systemone'
4const DEFAULT_TIMEOUT_MS = 4000
5// Safety margin under a calling hook's 10 s budget.
6const MAX_TIMEOUT_MS = 9000
7
8const STATUS_HINTS: Readonly<Record<number, string>> = {
9  401: 'Check TYPESAFE_API_KEY and account access.',
10  403: 'Check TYPESAFE_API_KEY and account access.',
11  422: "Check question criteria and the model's token limits.",
12  429: 'Rate limited or overloaded. Wait before retrying.',
13  529: 'Rate limited or overloaded. Wait before retrying.',
14}
15
16/** What the noun runs on, narrowed so tests can hand in fakes. */
17export type JevDeps = {
18  /** The key and model as configured: plugin option first, then the environment. */
19  config: () => Promise<{ apiKey?: string; model?: string }>
20  fetch: (
21    url: string,
22    init: { method: string; headers: Record<string, string>; body: string },
23  ) => Promise<{ ok: boolean; status: number; text: string }>
24  after: (ms: number, fn: () => void) => { cancel: () => void }
25  now: () => number
26}
27
28export function jevOf(deps: JevDeps): Jev {
29  return {
30    async ask<Q extends JevQuestions>(request: JevRequest<Q>): Promise<JevResult<Q>> {
31      checkRequest(request)
32
33      const { apiKey, model: configured } = await deps.config()
34      const key = apiKey?.trim()
35      if (!key) {
36        throw new Error('$.jev: no TypeSafe key. Set it in /plugin → jev → Configure options, or TYPESAFE_API_KEY.')
37      }
38      // A newline or smart quote from copy-paste breaks the header; say so before sending.
39      if (!/^[\x21-\x7e]+$/.test(key)) {
40        throw new Error('$.jev: the TypeSafe key has characters an HTTP header cannot carry. Re-copy it as plain ASCII.')
41      }
42
43      const timeoutMs = Math.min(Math.max(request.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1), MAX_TIMEOUT_MS)
44      const started = deps.now()
45      let payload: string
46      try {
47        payload = JSON.stringify({
48          model: request.model || configured?.trim() || 'jev-latest',
49          state: request.state,
50          questions: request.questions,
51        })
52      } catch {
53        // A circular value or a throwing toJSON names fields or values; never repeat it.
54        throw new Error('$.jev: the request could not be serialized to JSON.')
55      }
56      // Wrapped so a synchronous throw takes the same sanitized path as a rejection.
57      const sent = Promise.resolve().then(() =>
58        deps.fetch(ENDPOINT, {
59          method: 'POST',
60          headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
61          body: payload,
62        }),
63      )
64      // Nothing cancels the request itself; a late failure must not surface unhandled.
65      sent.catch(() => {})
66
67      let timer: { cancel: () => void } | undefined
68      const deadline = new Promise<never>((_, reject) => {
69        timer = deps.after(timeoutMs, () =>
70          reject(new Error(`$.jev: TypeSafe did not answer within ${timeoutMs} ms. No retry was made.`)),
71        )
72      })
73
74      let response: Awaited<typeof sent>
75      try {
76        response = await Promise.race([sent, deadline])
77      } catch (error) {
78        if (error instanceof Error && error.message.startsWith('$.jev:')) throw error
79        // The host's own error may echo request details; keep it out of the message.
80        throw new Error('$.jev: could not reach TypeSafe. No retry was made.')
81      } finally {
82        timer?.cancel()
83      }
84
85      if (!response.ok) {
86        // The body can echo the state or a secret, so only the status is reported.
87        throw new Error(
88          `$.jev: TypeSafe answered HTTP ${response.status}. ${STATUS_HINTS[response.status] ?? ''}`.trim(),
89        )
90      }
91
92      const body = parse(response.text)
93      if (
94        !isRecord(body) ||
95        typeof body.model !== 'string' ||
96        !isRecord(body.answers) ||
97        !isRecord(body.usage) ||
98        !isCount(body.usage.input_tokens) ||
99        !isCount(body.usage.output_tokens)
100      ) {
101        throw new Error('$.jev: TypeSafe returned an invalid response envelope.')
102      }
103      for (const [id, question] of Object.entries(request.questions)) {
104        if (!fits(question, body.answers[id])) {
105          throw new Error(`$.jev: TypeSafe returned an invalid or missing answer for ${JSON.stringify(id)}.`)
106        }
107      }
108
109      return {
110        model: body.model,
111        answers: body.answers as JevResult<Q>['answers'],
112        usage: { input_tokens: body.usage.input_tokens, output_tokens: body.usage.output_tokens },
113        elapsedMs: deps.now() - started,
114      }
115    },
116  }
117}
118
119/** Catches the mistakes TypeSafe would bounce with a 422, before spending a request. */
120// Messages name the field at fault, never its value: values are the caller's evidence.
121function checkRequest(request: JevRequest): void {
122  if (!isText(request.state)) throw new Error('$.jev: state must be non-empty text, a record or a list.')
123  const entries = Object.entries(isRecord(request.questions) ? request.questions : {})
124  if (entries.length === 0) throw new Error('$.jev: ask at least one question.')
125  for (const [id, raw] of entries) {
126    const where = `$.jev: question ${JSON.stringify(id)}`
127    // Typed callers always pass an object; untyped JS callers may not.
128    const q = raw as unknown as Record<string, unknown> | null
129    if (!isRecord(q)) throw new Error(`${where} must be an object.`)
130    if (!isText(q.instructions)) throw new Error(`${where} needs non-empty instructions.`)
131    if (q.type === 'choice') {
132      const options = isRecord(q.criteria) ? Object.values(q.criteria) : []
133      if (options.length < 2 || options.length > 255) {
134        throw new Error(`${where} needs 2 to 255 choice options, got ${options.length}.`)
135      }
136      if (!options.every((o) => o === null || isText(o))) {
137        throw new Error(`${where} has a choice option that is empty; give text or null.`)
138      }
139    } else if (q.type === 'score') {
140      const levels = Array.isArray(q.criteria) ? q.criteria : []
141      if (levels.length < 2 || levels.length > 10) {
142        throw new Error(`${where} needs 2 to 10 score levels, got ${levels.length}.`)
143      }
144      if (!levels.every(isText)) throw new Error(`${where} has an empty score level.`)
145    } else if (q.type === 'noul') {
146      const c = q.criteria
147      if (c !== undefined) {
148        const sides = isRecord(c) ? Object.keys(c) : []
149        const ok =
150          isRecord(c) &&
151          sides.length > 0 &&
152          sides.every((k) => (k === 'true' || k === 'false') && isText(c[k]))
153        if (!ok) throw new Error(`${where} noul criteria must be { true?, false? } with non-empty text.`)
154      }
155    } else {
156      throw new Error(`${where} has an unknown type; use choice, noul or score.`)
157    }
158  }
159}
160
161/** Jev's description shape: a non-empty string, record or list. */
162function isText(value: unknown): boolean {
163  if (typeof value === 'string') return value.trim() !== ''
164  if (Array.isArray(value)) return value.length > 0
165  return isRecord(value) && Object.keys(value).length > 0
166}
167
168/** Whether an answer has the exact shape its question allows. */
169function fits(question: JevQuestion, answer: unknown): boolean {
170  if (!isRecord(answer) || answer.type !== question.type) return false
171  if (question.type === 'noul') return isProbability(answer.noul)
172
173  const keys = Object.keys(question.criteria)
174  if (!isProbability(answer.confidence) || !isDistribution(answer.probabilities, keys)) return false
175  if (question.type === 'choice') return typeof answer.choice === 'string' && keys.includes(answer.choice)
176
177  return (
178    typeof answer.score === 'number' &&
179    answer.score >= 0 &&
180    answer.score <= keys.length - 1 &&
181    isRecord(answer.legend) &&
182    Object.keys(answer.legend).length === keys.length &&
183    keys.every((k) => isText((answer.legend as Record<string, unknown>)[k]))
184  )
185}
186
187function isDistribution(value: unknown, keys: readonly string[]): boolean {
188  if (!isRecord(value)) return false
189  const got = Object.keys(value)
190  return got.length === keys.length && keys.every((k) => isProbability(value[k]))
191}
192
193function isProbability(value: unknown): value is number {
194  return typeof value === 'number' && value >= 0 && value <= 1
195}
196
197function isCount(value: unknown): value is number {
198  return Number.isInteger(value) && (value as number) >= 0
199}
200
201function parse(text: string): unknown {
202  try {
203    return JSON.parse(text)
204  } catch {
205    return undefined
206  }
207}
208
209function isRecord(value: unknown): value is Record<string, unknown> {
210  return typeof value === 'object' && value !== null && !Array.isArray(value)
211}
212
types/index.d.ts 129 lines
1/**
2 * The `$.jev` noun: typed judgments from TypeSafe's Jev, one state at a time.
3 *
4 * Self-contained on purpose: a mod that lists `jev` under `dependencies` gets
5 * this file laid into its own types folder.
6 */
7
8/**
9 * Asks Jev questions over one state.
10 *
11 * A caller in a gating hook (`prompt.submit`, `tool.call`) should fail open:
12 * wrap the call in try/catch, or add a `.catch` on its registration, so a
13 * timeout or an outage never blocks the person.
14 */
15export type Jev = {
16  /**
17   * Answers every question in one request; they run in parallel and cannot
18   * see each other. Rejects on a missing key, a timeout, a non-200, or an
19   * answer that does not fit its question. Error messages never carry the
20   * response body, which can echo the state.
21   *
22   * @example
23   * const { answers } = await $.jev.ask({
24   *   state: { command: e.command },
25   *   questions: {
26   *     destructive: {
27   *       type: 'noul',
28   *       instructions: 'The command deletes files that cannot be reproduced.',
29   *       criteria: { true: 'Removes source or data.', false: 'Removes build output.' },
30   *     },
31   *   },
32   *   timeoutMs: 1500,
33   * })
34   * if (answers.destructive.noul > 0.9) return { deny: 'looks destructive' }
35   */
36  ask: <Q extends JevQuestions>(request: JevRequest<Q>) => Promise<JevResult<Q>>
37}
38
39export type JevQuestions = Readonly<Record<string, JevQuestion>>
40
41export type JevRequest<Q extends JevQuestions = JevQuestions> = {
42  /** Everything Jev sees: it never reads the conversation or files. Send only what the question needs. */
43  state: JevText
44  /** Questions by id. Ids are not sent to Jev, so instructions and criteria must carry the meaning. */
45  questions: Q
46  /** Overrides the configured model for this call. */
47  model?: string
48  /**
49   * Gives up after this long (default 4000, capped at 9000 to stay inside a
50   * hook's 10 s budget). The request itself cannot be cancelled, only abandoned.
51   */
52  timeoutMs?: number
53}
54
55/** A sentence, or structured text where definitions or examples help. */
56export type JevText = string | Readonly<Record<string, unknown>> | readonly unknown[]
57
58export type JevQuestion = JevNoulQuestion | JevChoiceQuestion | JevScoreQuestion
59
60/** Whether a condition holds. */
61export type JevNoulQuestion = {
62  type: 'noul'
63  instructions: JevText
64  criteria?: { true?: JevText; false?: JevText }
65}
66
67/** One option of a set. Add a no-match option when nothing may fit. */
68export type JevChoiceQuestion = {
69  type: 'choice'
70  instructions: JevText
71  /** 2 to 255 option names, each with the guidance that picks it (null when self-evident). */
72  criteria: Readonly<Record<string, JevText | null>>
73}
74
75/** A position on an ordered rubric. */
76export type JevScoreQuestion = {
77  type: 'score'
78  instructions: JevText
79  /** 2 to 10 levels, lowest first. */
80  criteria: readonly JevText[]
81}
82
83/** The probability of yes, 0 to 1. Near 0.5 means torn, not "medium". */
84export type JevNoulAnswer = { type: 'noul'; noul: number }
85
86export type JevChoiceAnswer = {
87  type: 'choice'
88  choice: string
89  /** Every option's probability, keyed by option name. */
90  probabilities: Readonly<Record<string, number>>
91  /** How concentrated the distribution is, 0 to 1. Not a claim of truth. */
92  confidence: number
93}
94
95export type JevScoreAnswer = {
96  type: 'score'
97  /** 0 to levels - 1, fractional: the expected level. */
98  score: number
99  /** Keyed "0" to "n-1". */
100  probabilities: Readonly<Record<string, number>>
101  confidence: number
102  legend: Readonly<Record<string, JevText>>
103}
104
105export type JevAnswer = JevNoulAnswer | JevChoiceAnswer | JevScoreAnswer
106
107/** The answer type a question gets back, so `answers.x.noul` needs no narrowing. */
108export type JevAnswerOf<T extends JevQuestion> = T extends { type: 'noul' }
109  ? JevNoulAnswer
110  : T extends { type: 'choice' }
111    ? JevChoiceAnswer
112    : JevScoreAnswer
113
114export type JevResult<Q extends JevQuestions = JevQuestions> = {
115  /** The dated build that answered (`jev-1.13-20260917`). */
116  model: string
117  answers: { readonly [K in keyof Q]: JevAnswerOf<Q[K]> }
118  /** Output tokens bill at zero. */
119  usage: { input_tokens: number; output_tokens: number }
120  elapsedMs: number
121}
122
123declare module 'claude-code' {
124  interface EngineInterface {
125    /** Present only where the jev mod is enabled. */
126    jev: Jev
127  }
128}
129