@jkudish/jev-mcp 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -19,18 +19,28 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
19
19
  import { choice, noul, score } from "@typesafe-ai/sdk";
20
20
  import { z } from "zod";
21
21
  import { createRequire } from "node:module";
22
- import { classificationDecision, claimAction, contradictsRecommendation, DECIDE_ESCAPE_HATCHES, DEFAULT_COMPOSITE_FLOOR, ensureUniqueIds, existsVerdict, hasNonEmptyEvidence, marginOf, MAX_CANDIDATES_DECIDE, MAX_CLASSES, MAX_ITEM_CHARS, MAX_ITEMS, MAX_REQUIREMENTS, MAX_CANDIDATES, MAX_CANDIDATE_CHARS, MAX_GATE_CLAIMS, MAX_GATE_EVIDENCE_CHARS, MAX_GATE_EVIDENCE_ITEMS, MAX_CLAIM_CHARS, MAX_REVIEW_DOC_CHARS, normalizeEvidence, rankCandidates, requireCompleteContext, resolvePolicyThresholds, REVIEW_WEIGHTS, reviewAction, reviewComposite, COMPARE_RELATIONS, ASPECT_RELATIONS, MAX_COMPARE_ASPECTS, MAX_EXTRACT_CANDIDATES, MAX_EXTRACT_CANDIDATE_CHARS, MAX_EXTRACT_FIELDS, MAX_EXTRACT_TOTAL_CHARS, MAX_RERANK_CANDIDATES, MAX_RERANK_TOTAL_CHARS, REGEX_TIMEOUT_MS, rerankByScore, RELATION_TO_VERDICT, screenRecommendation, truncate, verifyAction, VERIFY_CLAIM_CRITERIA, worstAction, } from "./lib.js";
22
+ import { classificationDecision, claimAction, contradictsRecommendation, DECIDE_ESCAPE_HATCHES, DEFAULT_COMPOSITE_FLOOR, ensureUniqueIds, existsVerdict, hasNonEmptyEvidence, isRecord, marginOf, MAX_CANDIDATES_DECIDE, MAX_CLASSES, MAX_ITEM_CHARS, MAX_ITEMS, MAX_REQUIREMENTS, MAX_CANDIDATES, MAX_CANDIDATE_CHARS, PROBABILITY_SUM_TOLERANCE, SCORE_MEAN_TOLERANCE, MAX_GATE_CLAIMS, MAX_GATE_EVIDENCE_CHARS, MAX_GATE_EVIDENCE_ITEMS, MAX_CLAIM_CHARS, MAX_REVIEW_DOC_CHARS, normalizeEvidence, rankCandidates, requireCompleteContext, resolvePolicyThresholds, REVIEW_WEIGHTS, reviewAction, reviewComposite, COMPARE_RELATIONS, ASPECT_RELATIONS, MAX_COMPARE_ASPECTS, MAX_EXTRACT_CANDIDATES, MAX_EXTRACT_CANDIDATE_CHARS, MAX_EXTRACT_FIELDS, MAX_EXTRACT_TOTAL_CHARS, MAX_RERANK_CANDIDATES, MAX_RERANK_TOTAL_CHARS, REGEX_TIMEOUT_MS, rerankByScore, RELATION_TO_VERDICT, screenRecommendation, truncate, verifyAction, VERIFY_CLAIM_CRITERIA, worstAction, } from "./lib.js";
23
23
  const MODEL = process.env.JEV_MCP_MODEL ?? "jev-latest";
24
24
  // Resolved at runtime so the MCP handshake version always matches the package.
25
25
  const { version: packageVersion } = createRequire(import.meta.url)("../package.json");
26
26
  const server = new McpServer({ name: "jev-mcp", version: packageVersion });
27
27
  import { askJev as askProvider } from "./provider.js";
28
- async function askJev(state, questions) {
29
- return askProvider(state, questions, MODEL);
28
+ async function askJev(state, questions, signal) {
29
+ const result = await askProvider(state, questions, MODEL, signal);
30
+ // The typesafe SDK transport returns unchecked `answers: any`; a null or
31
+ // non-object envelope must not crash tools that index it. Normalize once
32
+ // here so every tool takes the same {}-to-invalid_response path. The
33
+ // compatible transport already rejects such envelopes itself.
34
+ return { ...result, answers: (isRecord(result.answers) ? result.answers : {}) };
30
35
  }
31
36
  const text = (payload) => ({
32
37
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
33
38
  });
39
+ // Tool inputs are strict: unknown top-level keys are rejected instead of
40
+ // silently stripped, so a client typos-as-error rather than typos-as-default.
41
+ // Nested fixed-shape objects are strict for the same reason; `context`
42
+ // deliberately stays an open record.
43
+ const strictShape = (shape) => z.object(shape).strict();
34
44
  const evidenceSchema = z.union([
35
45
  z.string().describe("A single evidence document."),
36
46
  z
@@ -38,12 +48,13 @@ const evidenceSchema = z.union([
38
48
  id: z.string().optional().describe("Short identifier for this evidence item."),
39
49
  text: z.string().describe("The evidence text."),
40
50
  })
51
+ .strict()
41
52
  .describe("A single evidence item."),
42
53
  z
43
54
  .array(z.object({
44
55
  id: z.string().optional().describe("Short identifier for this evidence item (e.g. 'site-html', 'rfc-4.1.3')."),
45
56
  text: z.string().describe("The evidence text."),
46
- }))
57
+ }).strict())
47
58
  .min(1)
48
59
  .describe("Multiple evidence items; each claim is also matched to the item it rests on."),
49
60
  ]);
@@ -51,7 +62,7 @@ const candidatesSchema = z
51
62
  .array(z.object({
52
63
  id: z.string().optional().describe("Short identifier for this candidate (e.g. a file path, note name, or line id)."),
53
64
  text: z.string().describe("The candidate's text."),
54
- }))
65
+ }).strict())
55
66
  .min(1)
56
67
  .max(MAX_CANDIDATES)
57
68
  .describe(`Candidates to search. Up to ${MAX_CANDIDATES} in one call; texts are truncated at ${MAX_CANDIDATE_CHARS} chars.`);
@@ -65,7 +76,7 @@ server.registerTool("jev_verify", {
65
76
  "and whether the verdict stands on its own (auto) or needs human review. " +
66
77
  "Pattern: docs.typesafe.ai/cookbooks/citation_check. Pass reports, PR descriptions, or agent briefs as claims " +
67
78
  "and their cited sources, diffs, or documents as evidence.",
68
- inputSchema: {
79
+ inputSchema: strictShape({
69
80
  claims: z.array(z.string()).min(1).describe("Claims to verify, e.g. individual factual statements from a report."),
70
81
  evidence: evidenceSchema,
71
82
  auto_accept: z
@@ -74,8 +85,8 @@ server.registerTool("jev_verify", {
74
85
  .max(1)
75
86
  .optional()
76
87
  .describe("Verdicts at or above this confidence stand automatically; below it they are flagged 'review'. Default 0.8."),
77
- },
78
- }, async ({ claims, evidence: rawEvidence, auto_accept }) => {
88
+ }),
89
+ }, async ({ claims, evidence: rawEvidence, auto_accept }, extra) => {
79
90
  const autoAccept = auto_accept ?? 0.8;
80
91
  const evidenceItems = typeof rawEvidence === "string"
81
92
  ? [{ id: "evidence", text: rawEvidence }]
@@ -102,20 +113,42 @@ server.registerTool("jev_verify", {
102
113
  claims: claimItems,
103
114
  evidence,
104
115
  };
105
- const { answers, usage, provider, model } = await askJev(state, questions);
116
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
106
117
  const results = claimItems.map((claim) => {
107
118
  const relation = answers[`relation_${claim.id}`];
108
- const source = answers[`source_${claim.id}`];
109
- const confidence = relation?.confidence ?? null;
110
- const verdict = RELATION_TO_VERDICT[relation?.choice] ?? "unknown";
119
+ const validated = validateChoiceAnswer(relation, Object.keys(RELATION_TO_VERDICT));
120
+ // source_* is optional auxiliary information, requested only for multiple
121
+ // evidence items. Its absence does not invalidate the relation verdict,
122
+ // but a present source must name a supplied evidence id (or "none").
123
+ const sourceKeys = [...evidence.map((e) => e.id), "none"];
124
+ const source = validateChoiceAnswer(answers[`source_${claim.id}`], sourceKeys);
125
+ const valid = validated && Object.hasOwn(RELATION_TO_VERDICT, validated.choice) &&
126
+ (relation.confidence == null || validated.confidence !== null);
127
+ const confidence = valid ? validated.confidence : null;
128
+ if (!valid) {
129
+ // A missing or malformed relation must not masquerade as an unsupported
130
+ // claim: surface it as invalid so callers can tell protocol failure
131
+ // apart from a real verdict.
132
+ return {
133
+ id: claim.id,
134
+ claim: claim.text,
135
+ verdict: "unknown",
136
+ probabilities: null,
137
+ confidence: null,
138
+ status: "invalid_response",
139
+ action: "review",
140
+ supporting_evidence: null,
141
+ };
142
+ }
143
+ const verdict = RELATION_TO_VERDICT[relation.choice];
111
144
  return {
112
145
  id: claim.id,
113
146
  claim: claim.text,
114
147
  verdict,
115
- probabilities: relation?.probabilities ?? null,
148
+ probabilities: relation.probabilities ?? null,
116
149
  confidence,
117
150
  action: confidence === null ? "review" : verifyAction(confidence, autoAccept),
118
- supporting_evidence: source?.choice && source.choice !== "none" ? source.choice : null,
151
+ supporting_evidence: source && source.choice !== "none" ? source.choice : null,
119
152
  };
120
153
  });
121
154
  return text({
@@ -142,7 +175,7 @@ server.registerTool("jev_screen", {
142
175
  "instructions aimed at an AI agent (prompt injection), whether it has substantive content, and (when a purpose " +
143
176
  "is given) whether it is relevant to the task. Returns a recommendation: pass | review | block | skip. " +
144
177
  "Pattern: docs.typesafe.ai/cookbooks/llm_guardrails.",
145
- inputSchema: {
178
+ inputSchema: strictShape({
146
179
  text: z.string().min(1).describe("The content to screen, e.g. a fetched web page or pasted document."),
147
180
  purpose: z
148
181
  .string()
@@ -150,8 +183,8 @@ server.registerTool("jev_screen", {
150
183
  .describe("What the consuming agent is trying to do; enables a relevance judgment and the 'skip' action."),
151
184
  block_at: z.number().min(0).max(1).optional().describe("Injection probability at or above which content is blocked. Default 0.75."),
152
185
  review_at: z.number().min(0).max(1).optional().describe("Injection probability at or above which content is flagged for review. Default 0.25."),
153
- },
154
- }, async ({ text: content, purpose, block_at, review_at }) => {
186
+ }),
187
+ }, async ({ text: content, purpose, block_at, review_at }, extra) => {
155
188
  const blockAt = block_at ?? 0.75;
156
189
  const reviewAt = review_at ?? 0.25;
157
190
  const questions = {
@@ -171,11 +204,25 @@ server.registerTool("jev_screen", {
171
204
  });
172
205
  }
173
206
  const state = { content, purpose: purpose ?? null };
174
- const { answers, usage, provider, model } = await askJev(state, questions);
175
- const injection = answers.injection?.noul ?? 0;
176
- const substance = answers.substance?.noul ?? undefined;
177
- const relevance = purpose ? (answers.relevance?.noul ?? undefined) : undefined;
178
- const recommendation = screenRecommendation({ injection, relevance, substance, blockAt, reviewAt });
207
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
208
+ const injection = validateNoulAnswer(answers.injection);
209
+ const substance = validateNoulAnswer(answers.substance);
210
+ const relevance = purpose ? validateNoulAnswer(answers.relevance) : undefined;
211
+ if (injection === null || substance === null || (purpose && relevance === null)) {
212
+ // A screening tool must fail closed: a missing or malformed answer must
213
+ // not be treated as a clean bill of health (injection ?? 0 would pass).
214
+ return text({
215
+ tool: "jev_screen",
216
+ model: model,
217
+ provider,
218
+ status: "invalid_response",
219
+ probabilities: { injection: injection ?? null, substance: substance ?? null, relevance: relevance ?? null },
220
+ thresholds: { block_at: blockAt, review_at: reviewAt },
221
+ recommendation: { action: "review", reason: "missing or malformed answers; cannot screen safely" },
222
+ usage,
223
+ });
224
+ }
225
+ const recommendation = screenRecommendation({ injection, relevance: relevance ?? undefined, substance, blockAt, reviewAt });
179
226
  return text({
180
227
  tool: "jev_screen",
181
228
  model: model,
@@ -196,12 +243,12 @@ server.registerTool("jev_find", {
196
243
  "any candidate addresses the query at all (so a confident 'top hit' cannot masquerade as an answer). " +
197
244
  "Pattern: docs.typesafe.ai/cookbooks/semantic_find. Use for 'which file/note/line covers X' across up to " +
198
245
  `${MAX_CANDIDATES} candidates.`,
199
- inputSchema: {
246
+ inputSchema: strictShape({
200
247
  query: z.string().min(1).describe("What you are looking for, in natural language."),
201
248
  candidates: candidatesSchema,
202
249
  top_k: z.number().int().min(1).max(50).optional().describe("How many ranked candidates to return. Default 5."),
203
- },
204
- }, async ({ query, candidates: rawCandidates, top_k }) => {
250
+ }),
251
+ }, async ({ query, candidates: rawCandidates, top_k }, extra) => {
205
252
  const topK = top_k ?? 5;
206
253
  const { items: candidates } = ensureUniqueIds(rawCandidates.map((c) => ({ id: c.id ?? "", text: truncate(c.text, MAX_CANDIDATE_CHARS) })), "candidate");
207
254
  const criteria = Object.fromEntries(candidates.map((c) => [c.id, null]));
@@ -213,10 +260,26 @@ server.registerTool("jev_find", {
213
260
  }),
214
261
  };
215
262
  const state = { query, candidates };
216
- const { answers, usage, provider, model } = await askJev(state, questions);
217
- const probabilities = answers.best?.probabilities ?? {};
218
- const ranked = rankCandidates(candidates, probabilities).slice(0, topK);
219
- const exists = answers.exists?.noul ?? 0;
263
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
264
+ const exists = validateNoulAnswer(answers.exists);
265
+ const best = validateChoiceAnswer(answers.best, candidates.map((c) => c.id));
266
+ if (exists === null || best === null) {
267
+ // A missing exists/best answer must not be read as "no match" (exists ?? 0)
268
+ // or "no ranking": surface the protocol failure instead.
269
+ return text({
270
+ tool: "jev_find",
271
+ model: model,
272
+ provider,
273
+ query,
274
+ status: "invalid_response",
275
+ exists: exists ?? null,
276
+ exists_verdict: null,
277
+ top: [],
278
+ reason: "missing or malformed best or exists answer; cannot rank safely",
279
+ usage,
280
+ });
281
+ }
282
+ const ranked = rankCandidates(candidates, best.probabilities).slice(0, topK);
220
283
  return text({
221
284
  tool: "jev_find",
222
285
  model: model,
@@ -239,14 +302,14 @@ server.registerTool("jev_classify", {
239
302
  "and an auto-versus-review decision. Auto requires both a high top probability (default 0.85) and a " +
240
303
  "clear margin (default 0.50); everything else is flagged for review. Include a manual_review class " +
241
304
  "in the catalog if you want an explicit escape hatch; the tool never invents one.",
242
- inputSchema: {
305
+ inputSchema: strictShape({
243
306
  items: z
244
- .array(z.object({ id: z.string().optional(), text: z.string() }))
307
+ .array(z.object({ id: z.string().optional(), text: z.string() }).strict())
245
308
  .min(1)
246
309
  .max(MAX_ITEMS)
247
310
  .describe(`Items to classify. Text is truncated at ${MAX_ITEM_CHARS} characters; send bounded excerpts, not whole documents.`),
248
311
  classes: z
249
- .array(z.object({ id: z.string().optional(), description: z.string() }))
312
+ .array(z.object({ id: z.string().optional(), description: z.string() }).strict())
250
313
  .min(2)
251
314
  .max(MAX_CLASSES)
252
315
  .describe("Shared class catalog. Strong descriptions carry the decision: a precise definition, " +
@@ -258,8 +321,8 @@ server.registerTool("jev_classify", {
258
321
  .describe("Shared context available to every item's judgment: policies, catalogs, anything stable."),
259
322
  auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
260
323
  minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
261
- },
262
- }, async ({ items: rawItems, classes: rawClasses, purpose, context, auto_accept, minimum_margin }) => {
324
+ }),
325
+ }, async ({ items: rawItems, classes: rawClasses, purpose, context, auto_accept, minimum_margin }, extra) => {
263
326
  const autoAccept = auto_accept ?? 0.85;
264
327
  const minMargin = minimum_margin ?? 0.5;
265
328
  // Preserve caller IDs exactly; use opaque internal keys (i0/c0) for the
@@ -303,23 +366,11 @@ server.registerTool("jev_classify", {
303
366
  for (const item of items) {
304
367
  questions[item.key] = choice({ task: "Which class does this item belong to?", item: { id: item.key, text: item.text } }, criteria);
305
368
  }
306
- const { answers, usage, provider, model } = await askJev(state, questions);
369
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
307
370
  const keyToExternal = new Map(classes.map((c) => [c.key, c.external]));
308
371
  const results = items.map((item) => {
309
- const answer = answers[item.key];
310
- const expected = new Set(classes.map((c) => c.key));
311
- const probabilities = answer?.probabilities ?? {};
312
- const keys = Object.keys(probabilities);
313
- const values = Object.values(probabilities);
314
- const sum = values.reduce((a, b) => a + b, 0);
315
- const valid = answer &&
316
- typeof answer.choice === "string" &&
317
- expected.has(answer.choice) &&
318
- keys.length >= expected.size &&
319
- keys.every((k) => expected.has(k)) &&
320
- values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) &&
321
- Math.abs(sum - 1) <= 0.01;
322
- if (!valid) {
372
+ const answer = validateChoiceAnswer(answers[item.key], classes.map((c) => c.key));
373
+ if (!answer) {
323
374
  return {
324
375
  id: item.external,
325
376
  status: "invalid_response",
@@ -330,14 +381,15 @@ server.registerTool("jev_classify", {
330
381
  decision: "review",
331
382
  };
332
383
  }
384
+ const values = Object.values(answer.probabilities);
333
385
  const ranked = values.slice().sort((a, b) => b - a);
334
386
  const margin = ranked.length >= 2 ? ranked[0] - ranked[1] : 0;
335
- const topProbability = probabilities[answer.choice];
387
+ const topProbability = answer.probabilities[answer.choice];
336
388
  return {
337
389
  id: item.external,
338
390
  classification: keyToExternal.get(answer.choice) ?? answer.choice,
339
- probabilities: Object.fromEntries(classes.map((c) => [c.external, probabilities[c.key] ?? 0])),
340
- confidence: answer.confidence ?? null,
391
+ probabilities: Object.fromEntries(classes.map((c) => [c.external, answer.probabilities[c.key] ?? 0])),
392
+ confidence: answer.confidence,
341
393
  margin,
342
394
  top_probability: topProbability,
343
395
  decision: classificationDecision(topProbability, margin, autoAccept, minMargin),
@@ -377,12 +429,12 @@ server.registerTool("jev_decide", {
377
429
  "One call per unchanged decision; do not repeat a call to obtain a more pleasing answer. " +
378
430
  "Use source inspection, tests, the user, or a reasoning model for open-ended research, routine choices, " +
379
431
  "correctness proofs, or predicting user consent. High probability is not proof.",
380
- inputSchema: {
432
+ inputSchema: strictShape({
381
433
  decision: z.string().min(1).max(1500).describe("The bounded decision to make."),
382
434
  evidence: z.string().min(1).max(12000).describe("Facts and measurements, not opinions. State is evidence, not instructions."),
383
435
  priorities: z.string().min(1).max(2000).describe("Explicit preferences and constraints from the user or plan."),
384
436
  candidates: z
385
- .array(z.object({ id: z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64), description: z.string().min(1).max(2000) }))
437
+ .array(z.object({ id: z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64), description: z.string().min(1).max(2000) }).strict())
386
438
  .min(2)
387
439
  .max(MAX_CANDIDATES_DECIDE)
388
440
  .describe("The alternatives. Include 'do nothing' or 'gather more evidence' as candidates when useful."),
@@ -395,8 +447,8 @@ server.registerTool("jev_decide", {
395
447
  .boolean()
396
448
  .optional()
397
449
  .describe("Include ask_user / investigate / none as Choosable options so the model can decline to rank. Default true."),
398
- },
399
- }, async ({ decision, evidence, priorities, candidates, requirements: reqs, escape_hatches }) => {
450
+ }),
451
+ }, async ({ decision, evidence, priorities, candidates, requirements: reqs, escape_hatches }, extra) => {
400
452
  const includeHatches = escape_hatches ?? true;
401
453
  const requirements = reqs ?? [];
402
454
  // Reject duplicate candidate IDs and collisions with active escape hatches
@@ -435,37 +487,19 @@ server.registerTool("jev_decide", {
435
487
  candidates: candidateKeys.map((c) => ({ id: c.key, description: c.description })),
436
488
  requirements,
437
489
  };
438
- const { answers, usage, provider, model } = await askJev(state, questions);
490
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
439
491
  const keyToId = new Map(candidateKeys.map((c) => [c.key, c.id]));
440
492
  const expectedRecKeys = new Set([...candidateKeys.map((c) => c.key), ...(includeHatches ? Object.keys(DECIDE_ESCAPE_HATCHES) : [])]);
441
493
  const expectedCheckKeys = new Set(["supported", "contradicted", "unknown"]);
442
- // classify-grade validation: exact keys, finite [0,1] probabilities summing
443
- // to one, chosen key is the argmax, confidence finite or null. Malformed
444
- // responses are never semantic outcomes.
445
- const validateChoice = (answer, expected) => {
446
- if (!answer || typeof answer.choice !== "string" || !expected.has(answer.choice))
447
- return null;
448
- const probabilities = answer.probabilities ?? {};
449
- const keys = Object.keys(probabilities);
450
- const values = Object.values(probabilities);
451
- if (keys.length !== expected.size ||
452
- !keys.every((k) => expected.has(k)) ||
453
- !values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
454
- Math.abs(values.reduce((a, b) => a + b, 0) - 1) > 0.01 ||
455
- // Choice contract: the chosen option must be the argmax.
456
- probabilities[answer.choice] < Math.max(...values) - 1e-9)
457
- return null;
458
- const rawConfidence = answer.confidence;
459
- const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
460
- ? rawConfidence
461
- : null;
462
- return { choice: answer.choice, confidence, probabilities };
463
- };
464
- const rec = validateChoice(answers.recommendation, expectedRecKeys);
494
+ // classify-grade validation via the shared validateChoiceAnswer: exact
495
+ // keys, finite [0,1] probabilities summing to one within the shared
496
+ // tolerance, chosen key is the argmax, confidence finite or null.
497
+ // Malformed responses are never semantic outcomes.
498
+ const rec = validateChoiceAnswer(answers.recommendation, expectedRecKeys);
465
499
  const recProbabilities = rec?.probabilities ?? {};
466
500
  const recommendedKey = rec?.choice ?? null;
467
501
  const checks = candidateKeys.flatMap((c, i) => requirements.map((_, j) => {
468
- const answer = validateChoice(answers[`check_${i}_${j}`], expectedCheckKeys);
502
+ const answer = validateChoiceAnswer(answers[`check_${i}_${j}`], expectedCheckKeys);
469
503
  return { candidate: c.id, requirement: j, answer: answer?.choice ?? "invalid_response" };
470
504
  }));
471
505
  const contradicted = recommendedKey && candidateKeySet.has(recommendedKey)
@@ -501,12 +535,12 @@ server.registerTool("jev_rerank", {
501
535
  "every candidate so the full ordering survives. TypeSafe's rerank cookbook reports that on the CLERC benchmark " +
502
536
  "this pattern lifted top-1 from 5% to 18% and top-10 from 38% to 62% (docs.typesafe.ai/cookbooks). " +
503
537
  `Use for retrieval ordering, dedup triage, or feed ranking across up to ${MAX_RERANK_CANDIDATES} candidates.`,
504
- inputSchema: {
538
+ inputSchema: strictShape({
505
539
  query: z.string().min(1).max(2000).describe("What relevance is measured against, in natural language."),
506
540
  candidates: candidatesSchema,
507
541
  top_k: z.number().int().min(1).max(250).optional().describe("How many ranked candidates to return. Default: all."),
508
- },
509
- }, async ({ query, candidates: rawCandidates, top_k }) => {
542
+ }),
543
+ }, async ({ query, candidates: rawCandidates, top_k }, extra) => {
510
544
  const topK = top_k ?? null;
511
545
  // Caller IDs are preserved verbatim; opaque wire keys (classify pattern).
512
546
  // Duplicate supplied IDs are rejected rather than silently renamed, and
@@ -549,7 +583,7 @@ server.registerTool("jev_rerank", {
549
583
  false: "The candidate is about a different subject, or only shares vocabulary with the query",
550
584
  });
551
585
  });
552
- const { answers, usage, provider, model } = await askJev(state, questions);
586
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
553
587
  // One invalid Noul makes the whole ordering untrustworthy; never sort a
554
588
  // missing answer as a confident zero.
555
589
  const scores = candidates.map((_, i) => {
@@ -597,7 +631,7 @@ server.registerTool("jev_compare", {
597
631
  "Optionally supply aspects (price, date, method, …) and each gets an independent per-aspect judgment " +
598
632
  "in the same single request. Use for source reconciliation, changelog-vs-code drift, or merge sanity checks. " +
599
633
  "The request supplies no evidence beyond the two passages, so a same_fact verdict means they agree with each other, not that they are true.",
600
- inputSchema: {
634
+ inputSchema: strictShape({
601
635
  passage_a: z.string().min(1).max(20000).describe("First passage. Rejected above 20,000 characters."),
602
636
  passage_b: z.string().min(1).max(20000).describe("Second passage. Rejected above 20,000 characters."),
603
637
  aspects: z
@@ -608,8 +642,8 @@ server.registerTool("jev_compare", {
608
642
  purpose: z.string().optional().describe("What this comparison is for; helps disambiguate overlap."),
609
643
  auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
610
644
  minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
611
- },
612
- }, async ({ passage_a, passage_b, aspects: rawAspects, purpose, auto_accept, minimum_margin }) => {
645
+ }),
646
+ }, async ({ passage_a, passage_b, aspects: rawAspects, purpose, auto_accept, minimum_margin }, extra) => {
613
647
  const autoAccept = auto_accept ?? 0.85;
614
648
  const minMargin = minimum_margin ?? 0.5;
615
649
  const aspects = rawAspects ?? [];
@@ -625,29 +659,12 @@ server.registerTool("jev_compare", {
625
659
  questions[`aspect_${i}`] = choice(`Judging only the aspect "${aspect}" of the two passages in the state, which relation holds?`, { ...ASPECT_RELATIONS });
626
660
  });
627
661
  const state = { purpose: purpose ?? null, passage_a: a, passage_b: b, aspects };
628
- const { answers, usage, provider, model } = await askJev(state, questions);
662
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
629
663
  const expected = new Set(Object.keys(COMPARE_RELATIONS));
630
- const validateChoice = (answer) => {
631
- if (!answer || typeof answer.choice !== "string" || !expected.has(answer.choice))
632
- return null;
633
- const probabilities = answer.probabilities ?? {};
634
- const keys = Object.keys(probabilities);
635
- const values = Object.values(probabilities);
636
- if (keys.length !== expected.size ||
637
- !keys.every((k) => expected.has(k)) ||
638
- !values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
639
- Math.abs(values.reduce((x, y) => x + y, 0) - 1) > 0.01 ||
640
- // Choice contract: the chosen option must be the argmax.
641
- probabilities[answer.choice] < Math.max(...values) - 1e-9)
642
- return null;
643
- const rawConfidence = answer.confidence;
644
- const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
645
- ? rawConfidence
646
- : null;
647
- return { choice: answer.choice, confidence, probabilities };
648
- };
664
+ // classify-grade validation via the shared validateChoiceAnswer; the
665
+ // overall and aspect questions share the same three relation keys.
649
666
  const shape = (raw) => {
650
- const answer = validateChoice(raw);
667
+ const answer = validateChoiceAnswer(raw, expected);
651
668
  if (!answer) {
652
669
  return {
653
670
  relation: null,
@@ -739,7 +756,7 @@ server.registerTool("jev_extract", {
739
756
  "returned verbatim — never model-generated text. Fields with zero regex matches never reach the model " +
740
757
  "(not_found); if no field has matches, no API call is made. Ambiguous picks are flagged for review. Use for prices, dates, version numbers, " +
741
758
  "IDs, and anything with a recognizable shape; keep documents bounded.",
742
- inputSchema: {
759
+ inputSchema: strictShape({
743
760
  document: z.string().min(1).max(50000).describe("The document to extract from. Rejected above 50,000 characters."),
744
761
  fields: z
745
762
  .array(z.object({
@@ -747,15 +764,15 @@ server.registerTool("jev_extract", {
747
764
  pattern: z.string().min(1).max(500).describe("JavaScript regex source (without delimiters) that matches candidate values. Runs in a sandboxed worker with a hard timeout."),
748
765
  flags: z.string().max(8).optional().describe("Regex flags (e.g. 'i'). 'g' is always added; non-letters are dropped."),
749
766
  description: z.string().min(1).max(2000).describe("What the field is, so Jev can pick the right candidate among regex matches."),
750
- }))
767
+ }).strict())
751
768
  .min(1)
752
769
  .max(MAX_EXTRACT_FIELDS)
753
770
  .describe(`Fields to extract. Up to ${MAX_EXTRACT_FIELDS} per call, all judged in one request.`),
754
771
  purpose: z.string().optional().describe("What the extraction is for; shared across fields."),
755
772
  auto_accept: z.number().min(0).max(1).optional().describe("Minimum top probability for auto. Default 0.85."),
756
773
  minimum_margin: z.number().min(0).max(1).optional().describe("Minimum winner-to-runner-up gap for auto. Default 0.5."),
757
- },
758
- }, async ({ document, fields: rawFields, purpose, auto_accept, minimum_margin }) => {
774
+ }),
775
+ }, async ({ document, fields: rawFields, purpose, auto_accept, minimum_margin }, extra) => {
759
776
  const autoAccept = auto_accept ?? 0.85;
760
777
  const minMargin = minimum_margin ?? 0.5;
761
778
  const doc = truncate(document, 50000);
@@ -802,7 +819,7 @@ server.registerTool("jev_extract", {
802
819
  questions[f.key] = choice(`Which candidate is the correct value of the field "${f.id}" (${f.description}) in the document in the state? Pick the exact substring the document presents as this field's value.`, criteria);
803
820
  stateFields.push({ id: f.key, description: f.description, pattern: f.pattern });
804
821
  }
805
- const { answers, usage, provider, model } = stateFields.length > 0 ? await askJev({ purpose: purpose ?? null, document: doc, fields: stateFields }, questions) : { answers: {}, usage: null, provider: "none", model: MODEL };
822
+ const { answers, usage, provider, model } = stateFields.length > 0 ? await askJev({ purpose: purpose ?? null, document: doc, fields: stateFields }, questions, extra.signal) : { answers: {}, usage: null, provider: "none", model: MODEL };
806
823
  const results = fields.map((f) => {
807
824
  const flags = { candidates_truncated: f.truncated, matches_skipped_too_long: f.tooLong };
808
825
  // An incomplete candidate universe (capped or overlong-skipped matches)
@@ -829,7 +846,7 @@ server.registerTool("jev_extract", {
829
846
  keys.length === expectedKeys.size &&
830
847
  keys.every((k) => expectedKeys.has(k)) &&
831
848
  values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) &&
832
- Math.abs(values.reduce((x, y) => x + y, 0) - 1) <= 0.01 &&
849
+ Math.abs(values.reduce((x, y) => x + y, 0) - 1) <= PROBABILITY_SUM_TOLERANCE &&
833
850
  probabilities[answer.choice] >= Math.max(...values) - 1e-9;
834
851
  if (!valid) {
835
852
  return { id: f.id, value: null, status: "invalid_response", reason: null, candidates_considered: f.candidates.length, ...flags };
@@ -920,6 +937,13 @@ function reviewQuestions(extraFraming = "") {
920
937
  }
921
938
  // Score contract: a finite score within the 0..2 rubric and confidence finite
922
939
  // or null. Malformed responses are never semantic outcomes.
940
+ // Score questions always offer exactly three options (0, 1, 2).
941
+ const SCORE_OPTION_KEYS = ["0", "1", "2"];
942
+ // Score contract: a finite 0..2 score, plus, when a distribution is present
943
+ // at all, an exact three-key distribution over the option scores summing to
944
+ // one whose expected value matches the reported score. A distribution the
945
+ // provider did not report stays null (still valid); a malformed or
946
+ // contradictory one invalidates the answer.
923
947
  function validateScoreAnswer(answer) {
924
948
  const a = answer;
925
949
  if (!a || typeof a.score !== "number" || !Number.isFinite(a.score) || a.score < 0 || a.score > 2)
@@ -927,7 +951,46 @@ function validateScoreAnswer(answer) {
927
951
  const confidence = typeof a.confidence === "number" && Number.isFinite(a.confidence) && a.confidence >= 0 && a.confidence <= 1
928
952
  ? a.confidence
929
953
  : null;
930
- return { score: a.score, confidence };
954
+ if (a.probabilities == null)
955
+ return { score: a.score, confidence, probabilities: null };
956
+ if (!isRecord(a.probabilities))
957
+ return null;
958
+ const probabilities = a.probabilities;
959
+ const keys = Object.keys(probabilities);
960
+ const values = Object.values(probabilities);
961
+ const expected = new Set(SCORE_OPTION_KEYS);
962
+ if (keys.length !== expected.size ||
963
+ !keys.every((key) => expected.has(key)) ||
964
+ !values.every((p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1) ||
965
+ Math.abs(values.reduce((x, y) => x + y, 0) - 1) > PROBABILITY_SUM_TOLERANCE) {
966
+ return null;
967
+ }
968
+ const mean = SCORE_OPTION_KEYS.reduce((sum, key) => sum + Number(key) * probabilities[key], 0);
969
+ if (Math.abs(mean - a.score) > SCORE_MEAN_TOLERANCE)
970
+ return null;
971
+ return { score: a.score, confidence, probabilities };
972
+ }
973
+ // Choice contract: exact candidate keys, finite [0,1] probabilities summing
974
+ // to one within PROBABILITY_SUM_TOLERANCE, and a choice tied for the maximum
975
+ // probability.
976
+ function validateChoiceAnswer(answer, expectedKeys) {
977
+ if (!isRecord(answer) || typeof answer.choice !== "string" || !isRecord(answer.probabilities))
978
+ return null;
979
+ const expected = new Set(expectedKeys);
980
+ // Shape is enforced by the isRecord guards above and the finite [0,1] check below.
981
+ const probabilities = answer.probabilities;
982
+ const keys = Object.keys(probabilities);
983
+ const values = Object.values(probabilities);
984
+ if (!expected.has(answer.choice) || keys.length !== expected.size ||
985
+ !keys.every((key) => expected.has(key)) ||
986
+ !values.every((p) => typeof p === "number" && Number.isFinite(p) && p >= 0 && p <= 1) ||
987
+ Math.abs(values.reduce((a, b) => a + b, 0) - 1) > PROBABILITY_SUM_TOLERANCE ||
988
+ probabilities[answer.choice] < Math.max(...values) - 1e-9)
989
+ return null;
990
+ const confidence = typeof answer.confidence === "number" && Number.isFinite(answer.confidence) && answer.confidence >= 0 && answer.confidence <= 1
991
+ ? answer.confidence
992
+ : null;
993
+ return { choice: answer.choice, probabilities, confidence };
931
994
  }
932
995
  // Noul contract: a finite probability in [0,1].
933
996
  function validateNoulAnswer(answer) {
@@ -946,7 +1009,7 @@ function projectReviewHalf(answers, thresholds, truncated) {
946
1009
  for (const key of rubrics) {
947
1010
  const parsed = validateScoreAnswer(answers[key]);
948
1011
  if (!parsed) {
949
- scores[key] = { score: null, confidence: null, status: "invalid_response" };
1012
+ scores[key] = { score: null, confidence: null, probabilities: null, status: "invalid_response" };
950
1013
  invalid = true;
951
1014
  }
952
1015
  else {
@@ -964,8 +1027,26 @@ function projectReviewHalf(answers, thresholds, truncated) {
964
1027
  thresholds: { auto_accept: thresholds.autoAccept, review_at: thresholds.reviewAt, composite_floor: thresholds.compositeFloor },
965
1028
  };
966
1029
  if (invalid) {
967
- return { ...base, action: "escalate", status: "invalid_response", composite: null };
1030
+ return {
1031
+ ...base,
1032
+ action: "escalate",
1033
+ status: "invalid_response",
1034
+ composite: null,
1035
+ reason_codes: ["invalid_response"],
1036
+ limiting_rubrics: [],
1037
+ };
968
1038
  }
1039
+ // Per-rubric favorability: the normalized 0..1 contribution each rubric
1040
+ // makes to the composite (test gap and blast radius inverted), used to name
1041
+ // the limiting rubric(s) when the composite is what blocks auto.
1042
+ const favorability = {
1043
+ correctness: valid.correctness.score / 2,
1044
+ spec_match: valid.spec_match.score / 2,
1045
+ test_gap: 1 - valid.test_gap.score / 2,
1046
+ blast_radius: 1 - valid.blast_radius.score / 2,
1047
+ };
1048
+ const favorabilities = rubrics.map((r) => favorability[r]);
1049
+ const minFavorability = Math.min(...favorabilities);
969
1050
  const composite = reviewComposite({
970
1051
  correctness: valid.correctness.score,
971
1052
  specMatch: valid.spec_match.score,
@@ -976,8 +1057,46 @@ function projectReviewHalf(answers, thresholds, truncated) {
976
1057
  // number that can satisfy a threshold (a bare zero would, at auto_accept 0).
977
1058
  const rubricConfidences = rubrics.map((r) => valid[r].confidence);
978
1059
  const minConfidence = rubricConfidences.some((c) => c === null) ? null : Math.min(...rubricConfidences);
979
- const action = requireCompleteContext(reviewAction({ composite, safeToApply: safeToApply, minConfidence, ...thresholds }), truncated);
980
- return { ...base, action, composite };
1060
+ const rawAction = reviewAction({ composite, safeToApply: safeToApply, minConfidence, ...thresholds });
1061
+ const action = requireCompleteContext(rawAction, truncated);
1062
+ // Reason codes cover every action path; limiting_rubrics names the rubric(s)
1063
+ // that bound the decision: the null-confidence ones when confidence is
1064
+ // unknown, every rubric tied at the minimum confidence when confidence is
1065
+ // what blocks, and every rubric tied at the minimum favorability when the
1066
+ // composite floor is what blocks (ties always included, never a lone winner).
1067
+ const reasonCodes = [];
1068
+ let limitingRubrics = [];
1069
+ if (minConfidence === null) {
1070
+ reasonCodes.push("unknown_confidence");
1071
+ limitingRubrics = rubrics.filter((r) => valid[r].confidence === null);
1072
+ }
1073
+ else if (minConfidence < thresholds.reviewAt) {
1074
+ reasonCodes.push("confidence_below_review");
1075
+ limitingRubrics = rubrics.filter((r) => valid[r].confidence === minConfidence);
1076
+ }
1077
+ if (safeToApply !== null && safeToApply < thresholds.reviewAt)
1078
+ reasonCodes.push("safe_to_apply_below_review");
1079
+ if (rawAction !== "escalate") {
1080
+ if (safeToApply !== null && safeToApply < thresholds.autoAccept)
1081
+ reasonCodes.push("safe_to_apply_below_auto_accept");
1082
+ if (minConfidence !== null && minConfidence < thresholds.autoAccept) {
1083
+ reasonCodes.push("confidence_below_auto_accept");
1084
+ if (limitingRubrics.length === 0)
1085
+ limitingRubrics = rubrics.filter((r) => valid[r].confidence === minConfidence);
1086
+ }
1087
+ if (composite < thresholds.compositeFloor) {
1088
+ reasonCodes.push("composite_below_floor");
1089
+ // Tolerant equality: complementary scores (0.6 vs 1.4) are
1090
+ // mathematically tied but differ by a float ulp after inversion.
1091
+ if (limitingRubrics.length === 0)
1092
+ limitingRubrics = rubrics.filter((r) => Math.abs(favorability[r] - minFavorability) <= 1e-12);
1093
+ }
1094
+ }
1095
+ if (truncated)
1096
+ reasonCodes.push("incomplete_context");
1097
+ if (action === "auto")
1098
+ reasonCodes.push("accepted");
1099
+ return { ...base, action, composite, reason_codes: reasonCodes, limiting_rubrics: limitingRubrics };
981
1100
  }
982
1101
  server.registerTool("jev_review", {
983
1102
  title: "Review a proposed patch",
@@ -987,7 +1106,7 @@ server.registerTool("jev_review", {
987
1106
  "safe_to_apply and min score confidence at auto_accept and the composite at composite_floor; truncated or " +
988
1107
  "malformed input never returns auto. Does not apply the patch or run tests. " +
989
1108
  "Use jev_gate to also verify completion claims against evidence in the same call.",
990
- inputSchema: {
1109
+ inputSchema: strictShape({
991
1110
  request: z.string().min(1).describe("What the user asked for; this frames the review, it is not proof of anything."),
992
1111
  diff: z
993
1112
  .string()
@@ -1012,8 +1131,8 @@ server.registerTool("jev_review", {
1012
1131
  .max(1)
1013
1132
  .optional()
1014
1133
  .describe("Weighted composite at or above this is required for auto. Default 0.7."),
1015
- },
1016
- }, async ({ request, diff, tests, auto_accept, review_at, composite_floor }) => {
1134
+ }),
1135
+ }, async ({ request, diff, tests, auto_accept, review_at, composite_floor }, extra) => {
1017
1136
  const { autoAccept, reviewAt } = resolvePolicyThresholds(auto_accept ?? 0.8, review_at);
1018
1137
  const compositeFloor = composite_floor ?? DEFAULT_COMPOSITE_FLOOR;
1019
1138
  const truncated = request.length > MAX_REVIEW_DOC_CHARS ||
@@ -1025,7 +1144,7 @@ server.registerTool("jev_review", {
1025
1144
  diff: truncate(diff, MAX_REVIEW_DOC_CHARS),
1026
1145
  tests: tests ? truncate(tests, MAX_REVIEW_DOC_CHARS) : null,
1027
1146
  };
1028
- const { answers, usage, provider, model } = await askJev(state, reviewQuestions());
1147
+ const { answers, usage, provider, model } = await askJev(state, reviewQuestions(), extra.signal);
1029
1148
  return text({
1030
1149
  tool: "jev_review",
1031
1150
  model,
@@ -1044,7 +1163,7 @@ server.registerTool("jev_gate", {
1044
1163
  "evidence. Evidence is capped at 16 items and 200,000 characters in aggregate. " +
1045
1164
  "Does not run tests or apply changes. Use jev_review for a patch without claims, jev_verify for " +
1046
1165
  "claims without a patch review.",
1047
- inputSchema: {
1166
+ inputSchema: strictShape({
1048
1167
  request: z.string().min(1).describe("What the user asked for; this is not evidence of completion."),
1049
1168
  diff: z
1050
1169
  .string()
@@ -1077,8 +1196,8 @@ server.registerTool("jev_gate", {
1077
1196
  .max(1)
1078
1197
  .optional()
1079
1198
  .describe("Weighted composite at or above this is required for auto. Default 0.7."),
1080
- },
1081
- }, async ({ request, diff, claims, evidence: rawEvidence, tests, auto_accept, review_at, composite_floor }) => {
1199
+ }),
1200
+ }, async ({ request, diff, claims, evidence: rawEvidence, tests, auto_accept, review_at, composite_floor }, extra) => {
1082
1201
  const { autoAccept, reviewAt } = resolvePolicyThresholds(auto_accept ?? 0.8, review_at);
1083
1202
  const compositeFloor = composite_floor ?? DEFAULT_COMPOSITE_FLOOR;
1084
1203
  const evidence = normalizeEvidence(rawEvidence);
@@ -1124,31 +1243,14 @@ server.registerTool("jev_gate", {
1124
1243
  "diff and tests belong to the separate patch review. If a claim needs a diff or test log as support, it " +
1125
1244
  "must be supplied in evidence." + ANTI_INJECTION, VERIFY_CLAIM_CRITERIA);
1126
1245
  });
1127
- const { answers, usage, provider, model } = await askJev(state, questions);
1246
+ const { answers, usage, provider, model } = await askJev(state, questions, extra.signal);
1128
1247
  const review = projectReviewHalf(answers, { autoAccept, reviewAt, compositeFloor }, truncated);
1129
- // classify-grade validation: exact keys, finite [0,1] probabilities summing
1130
- // to one, chosen key is the argmax, confidence finite or null.
1248
+ // classify-grade validation via the shared validateChoiceAnswer: exact
1249
+ // keys, finite [0,1] probabilities summing to one within the shared
1250
+ // tolerance, chosen key is the argmax, confidence finite or null.
1131
1251
  const expectedClaimKeys = new Set(Object.keys(VERIFY_CLAIM_CRITERIA));
1132
- const validateChoice = (answer) => {
1133
- if (!answer || typeof answer.choice !== "string" || !expectedClaimKeys.has(answer.choice))
1134
- return null;
1135
- const probabilities = answer.probabilities ?? {};
1136
- const keys = Object.keys(probabilities);
1137
- const values = Object.values(probabilities);
1138
- if (keys.length !== expectedClaimKeys.size ||
1139
- !keys.every((k) => expectedClaimKeys.has(k)) ||
1140
- !values.every((p) => Number.isFinite(p) && p >= 0 && p <= 1) ||
1141
- Math.abs(values.reduce((a, b) => a + b, 0) - 1) > 0.01 ||
1142
- probabilities[answer.choice] < Math.max(...values) - 1e-9)
1143
- return null;
1144
- const rawConfidence = answer.confidence;
1145
- const confidence = typeof rawConfidence === "number" && Number.isFinite(rawConfidence) && rawConfidence >= 0 && rawConfidence <= 1
1146
- ? rawConfidence
1147
- : null;
1148
- return { choice: answer.choice, confidence, probabilities };
1149
- };
1150
1252
  const results = claims.map((claim, i) => {
1151
- const answer = validateChoice(answers[`claim_${i}`]);
1253
+ const answer = validateChoiceAnswer(answers[`claim_${i}`], expectedClaimKeys);
1152
1254
  if (!answer) {
1153
1255
  return {
1154
1256
  claim,
@@ -1181,6 +1283,13 @@ server.registerTool("jev_gate", {
1181
1283
  reasonCodes.push("incomplete_context");
1182
1284
  if (review.status === "invalid_response" || verification.summary.invalid_response > 0)
1183
1285
  reasonCodes.push("invalid_response");
1286
+ // The review half's specific reason codes (unknown confidence, threshold
1287
+ // and composite misses, safe_to_apply) surface at the top level too, so a
1288
+ // gate consumer never has to open review.reason_codes to learn why.
1289
+ for (const code of review.reason_codes) {
1290
+ if (code !== "invalid_response" && code !== "incomplete_context" && code !== "accepted")
1291
+ reasonCodes.push(code);
1292
+ }
1184
1293
  if (review.action === "escalate")
1185
1294
  reasonCodes.push("review_escalated");
1186
1295
  if (review.action === "review")