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

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' }
https://api.typesafe.ai/v1/systemone).The full contract is types/index.d.ts. A mod that lists jev under dependencies gets those types laid into its own types folder.
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.
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.
claude --plugin-dir . # load from disk, hot-reloads on save
claude plugin validate .
claude plugin test . # offline: fake fetch, no key neededhooks/register.ts 31 lines1import 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}
31hooks/jev.ts 212 lines1import 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}
212types/index.d.ts 129 lines1/**
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