@kontourai/survey 2.4.3 → 3.0.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/README.md CHANGED
@@ -203,7 +203,8 @@ host app should read as itself.
203
203
 
204
204
  ## Review MCP
205
205
 
206
- Drive review-queue decisions from an MCP agent (Claude Desktop, Cursor, or any MCP host):
206
+ Drive review-queue decisions from any MCP host. The official server runtime
207
+ automatically supports MCP 2026-07-28 discovery and existing legacy clients:
207
208
 
208
209
  ```sh
209
210
  npx survey-review-mcp --session path/to/session.json
@@ -35,12 +35,12 @@ export declare const publicDirectoryReviewItemExample: {
35
35
  excerpt: string;
36
36
  };
37
37
  extraction: {
38
+ model?: undefined;
38
39
  extractionId: string;
39
40
  target: string;
40
41
  confidence: number;
41
42
  extractor: string;
42
43
  extractedAt: string;
43
- model?: undefined;
44
44
  };
45
45
  claimTarget: {
46
46
  claimId: string;
@@ -61,13 +61,13 @@ export declare const publicDirectoryReviewItemExample: {
61
61
  claimId: string;
62
62
  };
63
63
  producer: {
64
+ proposalId?: undefined;
65
+ oldValue?: undefined;
64
66
  sourceAuthority: {
65
67
  authorityClass: string;
66
68
  declaredBy: string;
67
69
  scope: string;
68
70
  };
69
- proposalId?: undefined;
70
- oldValue?: undefined;
71
71
  };
72
72
  } | {
73
73
  id: string;
@@ -16,7 +16,7 @@
16
16
  * has full provenance (excerpt, span, extractor name, confidence) and is
17
17
  * run through the Inquiry pipeline rather than treated as authoritative.
18
18
  */
19
- import type { DerivationRule, InquiryRecord, TrustBundle } from "@kontourai/surface";
19
+ import type { DerivationRule, InquiryRecord, TrustBundle, TrustStatus } from "@kontourai/surface";
20
20
  import type { CanonicalClaimTarget } from "@kontourai/surface";
21
21
  import type { Candidate, CandidateSet, Extraction, RawSource, SurveyInput } from "./types.js";
22
22
  import type { InquiryMapping } from "./inquiry-mapping.js";
@@ -52,17 +52,63 @@ export interface UtteranceClaimExtractor {
52
52
  extract(utterance: string): ExtractedStatement[] | Promise<ExtractedStatement[]>;
53
53
  }
54
54
  /**
55
- * Badge values for each extracted statement, derived from the inquiry outcome
56
- * and the answer status.
57
- *
58
- * - "verified": inquiry matched/derived and answer status is "verified"
59
- * - "assumed": inquiry matched/derived and answer status is "assumed"
60
- * - "stale": inquiry matched/derived and answer status is "stale"
61
- * - "disputed": inquiry matched/derived and answer status is "disputed"
62
- * - "rejected": inquiry matched/derived and answer status is "rejected"
63
- * - "unsupported": inquiry outcome is "unsupported" (no mapping or no registered claim)
55
+ * Badge values for each extracted statement.
56
+ *
57
+ * A badge grades THE STATEMENT, not the target. It is a function of two
58
+ * things: the status of the bundle's answer for the statement's canonical
59
+ * target, and how the statement's own asserted value compares to that
60
+ * answer's value.
61
+ *
62
+ * The status half of the vocabulary is Surface's `TrustStatus` verbatim —
63
+ * Survey does not mint parallel status terms for concepts Surface (and the
64
+ * Hachure core record shapes it implements) already name. Only two badge
65
+ * values are Survey-side additions, because they describe the
66
+ * statement-vs-answer relation rather than a claim's standing:
67
+ *
68
+ * - "contradicted": the bundle has an answer that would otherwise read as
69
+ * support ("verified", "assumed", "stale") and the statement asserts a
70
+ * DIFFERENT value. Mirrors the Hachure `contradiction` transparency-gap
71
+ * type (merge.md §7b): a value conflict is surfaced, never silently
72
+ * resolved in favour of one side.
73
+ * - "unsupported": the inquiry did not resolve to an answer at all — no
74
+ * mapping and no registered claim for the target. This value means "there
75
+ * is nothing here to compare against", and nothing else: a claim that is
76
+ * registered but merely awaiting review badges "proposed", and one with no
77
+ * evidence badges "unknown".
78
+ *
79
+ * Every other badge is the answer's `TrustStatus` passed through unchanged,
80
+ * so there is no fall-through path that can report a real status under a
81
+ * label meaning "no such claim".
82
+ */
83
+ export type StatementBadge = TrustStatus | "contradicted" | "unsupported";
84
+ /**
85
+ * How a statement's asserted value compares to the bundle's answer value.
86
+ *
87
+ * - "agrees": both are comparable scalars and equivalent under
88
+ * `assertionComparisonKey`.
89
+ * - "contradicts": both are comparable scalars and NOT equivalent.
90
+ * - "not-compared": no comparison was possible — the inquiry produced no
91
+ * answer, or the extractor parsed no value out of the statement
92
+ * (`ExtractedStatement.value` absent), or one side is not a scalar.
93
+ * Never treated as agreement.
94
+ */
95
+ export type StatementValueComparison = "agrees" | "contradicts" | "not-compared";
96
+ /**
97
+ * How the Extraction's `text-span:` locator was resolved for a statement.
98
+ *
99
+ * - "span": the extractor supplied an explicit character span; the locator
100
+ * points where the extractor said it does.
101
+ * - "excerpt-match": no span, but the excerpt was found verbatim in the
102
+ * utterance; the locator points at that occurrence.
103
+ * - "unanchored-fallback": no span AND the excerpt does not occur in the
104
+ * utterance. The locator is still well-formed (`text-span:0-<length>`) so
105
+ * downstream producer discipline holds, but it is a length-shaped
106
+ * placeholder anchored at offset 0 — it does NOT point at the excerpt, and
107
+ * the text it spans is unrelated prose. Anything that resolves the locator
108
+ * against the source MUST check this field first; a hallucinated excerpt
109
+ * lands here.
64
110
  */
65
- export type StatementBadge = "verified" | "assumed" | "stale" | "disputed" | "rejected" | "unsupported";
111
+ export type LocatorResolution = "span" | "excerpt-match" | "unanchored-fallback";
66
112
  export interface UtteranceStatement {
67
113
  excerpt: string;
68
114
  span?: {
@@ -70,6 +116,29 @@ export interface UtteranceStatement {
70
116
  end: number;
71
117
  };
72
118
  target: CanonicalClaimTarget;
119
+ /**
120
+ * The value the statement asserted, verbatim from the extractor
121
+ * (`ExtractedStatement.value`) — not normalized, not defaulted. Absent when
122
+ * the extractor parsed no value, which is exactly when `valueComparison`
123
+ * is "not-compared". This is the field a reader needs to see WHAT was
124
+ * compared against the bundle's answer.
125
+ */
126
+ assertedValue?: unknown;
127
+ /** How `assertedValue` compares to `inquiryRecord.answer?.value`. */
128
+ valueComparison: StatementValueComparison;
129
+ /** Human-readable account of the comparison, naming both sides. */
130
+ comparisonRationale: string;
131
+ /**
132
+ * The Survey provenance records this statement produced: its Extraction,
133
+ * its Candidate, and the per-target Candidate Set it belongs to. The
134
+ * Candidate Set carries the Candidate Conflict verdict and rationale when
135
+ * two statements in the SAME utterance disagree about one target
136
+ * (`candidateSet.status === "conflict"`), which is a different signal from
137
+ * `valueComparison` (statement vs bundle).
138
+ */
139
+ records: UtteranceStatementRecords;
140
+ /** How this statement's Extraction locator was resolved. */
141
+ locatorResolution: LocatorResolution;
73
142
  inquiryRecord: InquiryRecord;
74
143
  badge: StatementBadge;
75
144
  }
@@ -107,6 +176,13 @@ interface UtteranceRecordsResult {
107
176
  records: UtteranceStatementRecords[];
108
177
  extractions: Extraction[];
109
178
  candidateSets: CandidateSet[];
179
+ /**
180
+ * `locatorResolutions[idx]` is how `records[idx]`'s Extraction locator was
181
+ * resolved — a parallel array rather than a fourth key on
182
+ * `UtteranceStatementRecords`, whose shape is a pinned contract. The same
183
+ * value is also on `records[idx].extraction.metadata.agentUtterance`.
184
+ */
185
+ locatorResolutions: LocatorResolution[];
110
186
  }
111
187
  /**
112
188
  * Build the full set of Survey records for every extracted statement in one
@@ -59,8 +59,10 @@ function buildUtteranceExtraction(params) {
59
59
  const candidateId = `${statementId}.candidate`;
60
60
  // Compute locator — required for non-manual-entry sources
61
61
  // (assertProducerDiscipline throws without it). Source Locator rule:
62
- // span-first, excerpt-fallback — UNCHANGED from Slice 1.
63
- const locator = spanToLocator(statement.span) ?? excerptLocator(utterance, statement.excerpt);
62
+ // span-first, excerpt-fallback — locator VALUES unchanged from Slice 1;
63
+ // what is new is that the record now says which branch produced them, so
64
+ // an unanchored placeholder is never mistaken for a resolved pointer.
65
+ const { locator, resolution: locatorResolution } = resolveUtteranceLocator(utterance, statement);
64
66
  const extraction = {
65
67
  id: extractionId,
66
68
  sourceId,
@@ -77,6 +79,7 @@ function buildUtteranceExtraction(params) {
77
79
  excerpt: statement.excerpt,
78
80
  extractorName,
79
81
  confidence: statement.confidence,
82
+ locatorResolution,
80
83
  },
81
84
  },
82
85
  };
@@ -93,7 +96,7 @@ function buildUtteranceExtraction(params) {
93
96
  confidence: statement.confidence,
94
97
  },
95
98
  };
96
- return { extraction, proposal };
99
+ return { extraction, proposal, locatorResolution };
97
100
  }
98
101
  /**
99
102
  * Group extraction/proposal pairs by canonical target and project each
@@ -166,7 +169,7 @@ function groupUtteranceExtractionsByTarget(sourceId, items) {
166
169
  export function buildUtteranceRecords(params) {
167
170
  const { sourceId, utterance, extracted, extractorName, observedAt } = params;
168
171
  const items = extracted.map((statement, idx) => {
169
- const { extraction, proposal } = buildUtteranceExtraction({
172
+ const { extraction, proposal, locatorResolution } = buildUtteranceExtraction({
170
173
  sourceId,
171
174
  idx,
172
175
  statement,
@@ -174,7 +177,7 @@ export function buildUtteranceRecords(params) {
174
177
  extractorName,
175
178
  observedAt,
176
179
  });
177
- return { statement, extraction, proposal };
180
+ return { statement, extraction, proposal, locatorResolution };
178
181
  });
179
182
  const groups = groupUtteranceExtractionsByTarget(sourceId, items);
180
183
  const records = items.map((item) => {
@@ -186,6 +189,7 @@ export function buildUtteranceRecords(params) {
186
189
  records,
187
190
  extractions: items.map((i) => i.extraction),
188
191
  candidateSets: [...groups.values()].map((g) => g.candidateSet),
192
+ locatorResolutions: items.map((i) => i.locatorResolution),
189
193
  };
190
194
  }
191
195
  // ---------------------------------------------------------------------------
@@ -234,7 +238,7 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
234
238
  // core) — replaces the old per-statement builder call. Claims below stay
235
239
  // one-per-statement; `record.candidateSet.id`/`record.candidate.id` may be
236
240
  // shared across several claims when statements share a target (legal).
237
- const { records, extractions, candidateSets } = buildUtteranceRecords({
241
+ const { records, extractions, candidateSets, locatorResolutions } = buildUtteranceRecords({
238
242
  sourceId,
239
243
  utterance,
240
244
  extracted,
@@ -270,6 +274,9 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
270
274
  span: statement.span,
271
275
  confidence: statement.confidence,
272
276
  locator: record.extraction.locator,
277
+ // Travels with the locator so a downstream reader of the Claim
278
+ // never has to assume the locator resolved.
279
+ locatorResolution: locatorResolutions[idx],
273
280
  },
274
281
  },
275
282
  },
@@ -318,13 +325,14 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
318
325
  };
319
326
  // Step 2: Extract statements
320
327
  const extracted = await Promise.resolve(extractor.extract(utterance));
321
- // Batched, grouped provenance construction — kept for provenance/doc-comment
322
- // fidelity across the whole utterance (grouping needs every statement of a
323
- // target's group present at once; a per-statement call cannot compute it).
324
- // Still not surfaced on UtteranceStatement/UtteranceTrustReport this slice
325
- // (unchanged from Slice 1's own scoping note) — wiring is left for a future
326
- // slice, same as before.
327
- buildUtteranceRecords({
328
+ // Batched, grouped provenance construction. Grouping needs every statement
329
+ // of a target's group present at once, so this cannot be a per-statement
330
+ // call. The result is now carried onto every UtteranceStatement
331
+ // (`records`), so the extractor's confidence, locator, Candidate and
332
+ // Candidate Set — including the Candidate Conflict verdict when two
333
+ // statements in this utterance disagree about one target — are observable
334
+ // in the report instead of being computed and dropped on the floor.
335
+ const { records, locatorResolutions } = buildUtteranceRecords({
328
336
  sourceId,
329
337
  utterance,
330
338
  extracted,
@@ -333,7 +341,7 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
333
341
  });
334
342
  // Step 3 & 4: Resolve each statement and build the report
335
343
  const statements = [];
336
- for (const statement of extracted) {
344
+ for (const [idx, statement] of extracted.entries()) {
337
345
  // Resolve the claim
338
346
  let inquiryRecord;
339
347
  if (mappings && mappings.length > 0) {
@@ -358,11 +366,17 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
358
366
  // Resolve directly by canonical target
359
367
  inquiryRecord = resolveByTarget(bundle, statement.target, agentId, observedAt, rules, now);
360
368
  }
361
- const badge = badgeFromRecord(inquiryRecord);
369
+ const comparison = compareAssertedValue(statement, inquiryRecord);
370
+ const badge = badgeFor(inquiryRecord, comparison.valueComparison);
362
371
  statements.push({
363
372
  excerpt: statement.excerpt,
364
373
  span: statement.span,
365
374
  target: statement.target,
375
+ ...(hasAssertedValue(statement) ? { assertedValue: statement.value } : {}),
376
+ valueComparison: comparison.valueComparison,
377
+ comparisonRationale: comparison.rationale,
378
+ records: records[idx],
379
+ locatorResolution: locatorResolutions[idx],
366
380
  inquiryRecord,
367
381
  badge,
368
382
  });
@@ -389,45 +403,122 @@ function canonicalTargetKey(target) {
389
403
  function targetToQuestion(target) {
390
404
  return `${target.subjectId} ${target.fieldOrBehavior}`;
391
405
  }
392
- function badgeFromRecord(record) {
406
+ /**
407
+ * Answer statuses a reader takes as support for what the statement said.
408
+ * These — and only these — are the statuses a contradiction must override:
409
+ * badging a statement "verified" when it asserts a value the verified claim
410
+ * denies is the exact failure this profile exists to prevent. For any other
411
+ * status the claim's own standing is already the more informative thing to
412
+ * show, and the contradiction stays legible in `valueComparison`.
413
+ */
414
+ const SUPPORTING_STATUSES = new Set(["verified", "assumed", "stale"]);
415
+ function hasAssertedValue(statement) {
416
+ return statement.value !== undefined;
417
+ }
418
+ /**
419
+ * Whether a value can take part in a statement-vs-answer comparison at all.
420
+ * Scalars can; objects and arrays cannot, because an utterance extractor
421
+ * pulls a token out of prose and there is no defensible way to decide
422
+ * whether that token "is" a structured value. Those report "not-compared"
423
+ * rather than being asserted to contradict.
424
+ */
425
+ function isComparableScalar(value) {
426
+ return value === null || ["string", "number", "boolean"].includes(typeof value);
427
+ }
428
+ /**
429
+ * The statement-vs-answer comparison key.
430
+ *
431
+ * This is deliberately NOT `utteranceEquivalenceKey`. That key compares two
432
+ * values from the SAME extractor, which share one type discipline, so it
433
+ * refuses cross-type equality on purpose (5 and "5" from one extractor
434
+ * really are different findings). A statement-vs-answer comparison crosses a
435
+ * boundary: the left side is a token the extractor pulled out of prose, the
436
+ * right side is a value the producer typed. Comparing those two by
437
+ * `typeof` would badge every true statement about a numeric or boolean field
438
+ * as a contradiction — a false accusation, which damages the badge exactly
439
+ * as much as a false green does.
440
+ *
441
+ * So scalars are compared by their canonical TEXT rendering, trimmed and
442
+ * lowercased. This bridges "the agent wrote 95" to "the producer stored 95"
443
+ * without losing a genuine disagreement: "5" and "6" still differ, and the
444
+ * bridge is named in `comparisonRationale` rather than applied silently.
445
+ */
446
+ function assertionComparisonKey(value) {
447
+ return String(value).trim().toLowerCase();
448
+ }
449
+ /**
450
+ * Compare the statement's own asserted value against the bundle's answer.
451
+ *
452
+ * An absent asserted value is never treated as agreement: an extractor that
453
+ * parsed no value has asserted nothing to check, so it reports
454
+ * "not-compared".
455
+ */
456
+ function compareAssertedValue(statement, record) {
457
+ const targetKey = canonicalTargetKey(statement.target);
458
+ const answer = record.answer;
459
+ if (!answer) {
460
+ return {
461
+ valueComparison: "not-compared",
462
+ rationale: `No answer for ${targetKey} (inquiry outcome: ${record.outcome}); nothing to compare the statement against.`,
463
+ };
464
+ }
465
+ if (!hasAssertedValue(statement)) {
466
+ return {
467
+ valueComparison: "not-compared",
468
+ rationale: `The extractor parsed no value out of this statement, so nothing was compared against the ${answer.status} answer for ${targetKey}.`,
469
+ };
470
+ }
471
+ if (!isComparableScalar(statement.value) || !isComparableScalar(answer.value)) {
472
+ return {
473
+ valueComparison: "not-compared",
474
+ rationale: `Statement value ${JSON.stringify(statement.value) ?? "undefined"} and the ${answer.status} answer ${JSON.stringify(answer.value) ?? "undefined"} for ${targetKey} are not both scalars; no comparison was attempted.`,
475
+ };
476
+ }
477
+ const asserted = assertionComparisonKey(statement.value);
478
+ const answered = assertionComparisonKey(answer.value);
479
+ const shown = `statement "${asserted}" vs ${answer.status} answer "${answered}" (compared as text)`;
480
+ return asserted === answered
481
+ ? { valueComparison: "agrees", rationale: `Agrees for ${targetKey}: ${shown}.` }
482
+ : { valueComparison: "contradicts", rationale: `Contradicts for ${targetKey}: ${shown}.` };
483
+ }
484
+ /**
485
+ * Grade the STATEMENT: the answer's status, overridden by "contradicted"
486
+ * when the statement asserts something the answer denies and that answer
487
+ * would otherwise have read as support.
488
+ */
489
+ function badgeFor(record, valueComparison) {
393
490
  if (record.outcome === "unsupported")
394
491
  return "unsupported";
395
492
  const status = record.answer?.status;
396
493
  if (!status)
397
494
  return "unsupported";
398
- if (status === "verified")
399
- return "verified";
400
- if (status === "assumed")
401
- return "assumed";
402
- if (status === "stale")
403
- return "stale";
404
- if (status === "disputed")
405
- return "disputed";
406
- if (status === "rejected" || status === "superseded")
407
- return "rejected";
408
- return "unsupported";
495
+ if (valueComparison === "contradicts" && SUPPORTING_STATUSES.has(status))
496
+ return "contradicted";
497
+ return status;
409
498
  }
410
499
  /**
411
- * Convert a text-span to a locator string.
412
- */
413
- function spanToLocator(span) {
414
- if (!span)
415
- return undefined;
416
- return `text-span:${span.start}-${span.end}`;
417
- }
418
- /**
419
- * Best-effort locator from excerpt text — find the first occurrence of the
420
- * excerpt in the utterance and use that as a text-span locator.
421
- * Falls back to text-span:0-0 if the excerpt is not found.
500
+ * The single Source Locator rule this module guarantees: span-first,
501
+ * excerpt-match second, unanchored placeholder last — and it always reports
502
+ * WHICH of the three produced the locator.
503
+ *
504
+ * The locator strings are unchanged from Slice 1, including the last branch's
505
+ * `text-span:0-<excerpt.length>` placeholder. That placeholder is deliberate
506
+ * (producer discipline requires a locator on a non-manual-entry source), but
507
+ * it is well-formed and therefore resolvable — it will happily span real,
508
+ * unrelated prose at the head of the utterance. Returning the resolution
509
+ * alongside it is what keeps a hallucinated excerpt from acquiring a pointer
510
+ * that looks exactly like a found one: the record now says the lookup failed
511
+ * instead of leaving the reader to re-derive it.
422
512
  */
423
- function excerptLocator(utterance, excerpt) {
424
- const idx = utterance.indexOf(excerpt);
513
+ function resolveUtteranceLocator(utterance, statement) {
514
+ if (statement.span) {
515
+ return { locator: `text-span:${statement.span.start}-${statement.span.end}`, resolution: "span" };
516
+ }
517
+ const idx = utterance.indexOf(statement.excerpt);
425
518
  if (idx >= 0) {
426
- return `text-span:${idx}-${idx + excerpt.length}`;
519
+ return { locator: `text-span:${idx}-${idx + statement.excerpt.length}`, resolution: "excerpt-match" };
427
520
  }
428
- // Fallback: anchor to start (preserves discipline contract; locator is
429
- // best-effort for span-less extractors)
430
- return `text-span:0-${excerpt.length}`;
521
+ return { locator: `text-span:0-${statement.excerpt.length}`, resolution: "unanchored-fallback" };
431
522
  }
432
523
  // ---------------------------------------------------------------------------
433
524
  // Reference extractor (deterministic, for tests — not for production use)
@@ -10,6 +10,7 @@ export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry
10
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
11
11
  export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionOccurrence, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
12
12
  export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
13
+ export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
13
14
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
14
15
  export type { CandidateReviewRecordInput, SurveyClaimRecord, SurveyInputBuilderArgs, SurveyObservationInput, } from "./builder.js";
15
16
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
@@ -41,7 +42,7 @@ export type { ApiRecordSourceInput, ChecksumInput, ManualEntrySourceInput, Polic
41
42
  export { applyAutoAcceptPolicy, applyMappingReview, buildMappingReviewItems, lookupMapping, lookupRejectedMapping, normalizeQuestion, proposalsToCandidateSet, referenceMappingProposer, resolveQuestion, } from "./inquiry-mapping.js";
42
43
  export type { AutoAcceptPolicy, InquiryMapping, MappingProposal, MappingProposer, } from "./inquiry-mapping.js";
43
44
  export { referenceUtteranceExtractor, surveyAgentUtterance, utteranceToSurveyInput, } from "./agent-utterance.js";
44
- export type { ExtractedStatement, StatementBadge, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
45
+ export type { ExtractedStatement, LocatorResolution, StatementBadge, StatementValueComparison, UtteranceClaimExtractor, UtteranceStatement, UtteranceStatementRecords, UtteranceTrustReport, } from "./agent-utterance.js";
45
46
  export { mappingReviewToSurface, referenceSchemaExtractor, surveySchemaMapping, } from "./schema-mapping.js";
46
47
  export type { MappingProposalRecord, ReviewedMapping, SchemaMappingExtractor, SchemaMappingOptions, SystemFieldRef, } from "./schema-mapping.js";
47
48
  export { buildAuthorizedActionAuthorizing, buildPromptRef, isValidAuthorizing, validateAuthorizing } from "./review-authorizing.js";
package/dist/src/index.js CHANGED
@@ -4,6 +4,7 @@ export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeR
4
4
  export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
5
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
6
6
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
7
+ export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
7
8
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
8
9
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
9
10
  export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
@@ -1,30 +1,16 @@
1
- import { createInterface } from "node:readline";
2
1
  import { readFile, writeFile, rename } from "node:fs/promises";
3
2
  import { resolve, dirname } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/server";
4
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
+ import { z } from "zod";
4
6
  import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
5
7
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
6
- /**
7
- * Minimal Model Context Protocol server over stdio for review-queue inspection
8
- * and decision-making against a session JSON file.
9
- *
10
- * Implemented without an SDK dependency — newline-delimited JSON-RPC 2.0 with
11
- * the MCP lifecycle (initialize / ping / tools) and an optional embedded UI
12
- * resource per tool call. The session file is the durable store; decisions
13
- * append events and write back atomically (write temp + rename).
14
- */
15
- const PROTOCOL_VERSION = "2025-06-18";
16
8
  const SESSION_NAME = "mcp-review-session";
17
- // MCP Apps extension (SEP-1865). The review card is offered under both UI
18
- // conventions so one server renders across hosts: the existing mcp-ui.dev
19
- // embedded resource in tool results, AND a declared `ui://` resource that the
20
- // official Apps hosts (ChatGPT/Claude) and Station's SEP-1865 resolver read via
21
- // resources/read. The canonical pointer is the FLAT `_meta["ui/resourceUri"]`
22
- // key (what registerAppTool emits); the nested `_meta.ui.resourceUri` is the
23
- // convenience shape some hosts read — we emit both.
24
9
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
25
10
  const UI_CAPABILITY_EXTENSION = "io.modelcontextprotocol/ui";
26
11
  const QUEUE_PANEL_URI = "ui://survey/review-card/queue";
27
12
  const UI_RESOURCE_MIME = "text/html;profile=mcp-app";
13
+ const SERVER_INSTRUCTIONS = "Use survey_review_queue to inspect the queue, survey_review_item to drill into one item, and survey_review_decide to record a decision. Decisions are validated and persisted to the session file and are irreversible within this session.";
28
14
  // MCP tool decision strings → ReviewWorkbenchDecision
29
15
  const MCP_DECISION_MAP = {
30
16
  accept: "accept-proposed",
@@ -449,213 +435,160 @@ function buildUiResource(item, snapshot, events, instance) {
449
435
  mimeType: "text/html;profile=mcp-app",
450
436
  text: buildReviewCardHtml(item, snapshot, events),
451
437
  _meta: {
438
+ ui: {
439
+ csp: {
440
+ connectDomains: [],
441
+ resourceDomains: [],
442
+ },
443
+ },
452
444
  "mcpui.dev/ui-preferred-frame-size": ["420px", "560px"],
453
445
  },
454
446
  },
455
447
  };
456
448
  }
457
- // Render the SEP-1865 declared review card: load the configured session, replay
458
- // to current state, and render the active item's card HTML (the same HTML the
459
- // embedded `queue` resource carries — here served via resources/read).
460
- async function readQueuePanelHtml(options) {
449
+ // Render the declared review card from the same function used by embedded tool
450
+ // results, so Apps and text-first hosts cannot drift.
451
+ async function readQueuePanelResource(options) {
461
452
  const { snapshot, events } = await readSessionFile(options.sessionPath);
462
453
  const current = currentSessionState(snapshot, events);
463
454
  const activeItem = currentReviewItem(current);
464
- return buildReviewCardHtml(activeItem, snapshot, events);
455
+ return buildUiResource(activeItem, snapshot, events, "queue").resource;
465
456
  }
466
457
  // ---- Domain error (maps to isError:true, not a JSON-RPC error) -----------
467
458
  class DomainError extends Error {
468
459
  isDomainError = true;
469
460
  }
470
- // ---- JSON-RPC dispatch ---------------------------------------------------
471
- async function handleLine(line, options, serverVersion) {
472
- const trimmed = line.trim();
473
- if (trimmed === "")
474
- return;
475
- let message;
476
- try {
477
- message = JSON.parse(trimmed);
478
- }
479
- catch {
480
- send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
481
- return;
461
+ // ---- Official dual-era MCP server ---------------------------------------
462
+ function uiResourceMeta(resourceUri) {
463
+ return {
464
+ ui: { resourceUri, visibility: ["model", "app"] },
465
+ [UI_RESOURCE_URI_META_KEY]: resourceUri,
466
+ };
467
+ }
468
+ function createReviewMcpServer(options, serverVersion) {
469
+ const server = new McpServer({
470
+ name: "survey-review-mcp",
471
+ title: "Survey Review MCP",
472
+ version: serverVersion,
473
+ }, {
474
+ instructions: SERVER_INSTRUCTIONS,
475
+ capabilities: options.noUi
476
+ ? {}
477
+ : {
478
+ extensions: {
479
+ [UI_CAPABILITY_EXTENSION]: {},
480
+ },
481
+ },
482
+ cacheHints: {
483
+ "server/discover": { ttlMs: 0, cacheScope: "private" },
484
+ "tools/list": { ttlMs: 0, cacheScope: "private" },
485
+ "resources/list": { ttlMs: 0, cacheScope: "private" },
486
+ "resources/read": { ttlMs: 0, cacheScope: "private" },
487
+ },
488
+ });
489
+ server.registerTool("survey_review_queue", {
490
+ title: "Review queue",
491
+ description: "Return a text summary and JSON of the current review queue: all items with their status, the active item, resolved/total counts, and session summary totals.",
492
+ inputSchema: z.object({}),
493
+ ...(options.noUi ? {} : { _meta: uiResourceMeta(QUEUE_PANEL_URI) }),
494
+ }, async () => runReviewTool(() => toolQueue(options)));
495
+ server.registerTool("survey_review_item", {
496
+ title: "Review item detail",
497
+ description: "Return full detail for one review item: current and proposed values, confidence, source references, excerpts, and any current decision.",
498
+ inputSchema: z.object({
499
+ itemName: z.string().min(1).describe("The ReviewItem name to inspect."),
500
+ }),
501
+ }, async ({ itemName }) => runReviewTool(() => toolItem(itemName, options)));
502
+ server.registerTool("survey_review_decide", {
503
+ title: "Record a review decision",
504
+ description: "Apply a decision to a review item and persist it through Survey's server-owned validation boundary. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
505
+ inputSchema: z.discriminatedUnion("decision", [
506
+ z.object({
507
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
508
+ decision: z
509
+ .enum(["accept", "hold", "reject"])
510
+ .describe("accept = accept-proposed, hold = keep-current, reject = reject-proposed."),
511
+ note: z.string().optional().describe("Optional reviewer note or rationale."),
512
+ }),
513
+ z.object({
514
+ itemName: z.string().min(1).describe("The ReviewItem name to decide."),
515
+ decision: z
516
+ .literal("could-not-confirm")
517
+ .describe("Record a terminal non-answer after evidence attempts are exhausted."),
518
+ reason: z
519
+ .string()
520
+ .trim()
521
+ .min(1)
522
+ .describe("Required non-empty reason for the could-not-confirm decision."),
523
+ attemptEvidenceIds: z
524
+ .array(z.string())
525
+ .optional()
526
+ .describe("Evidence ids attempted before a could-not-confirm decision."),
527
+ }),
528
+ ]),
529
+ }, async (input) => runReviewTool(() => input.decision === "could-not-confirm"
530
+ ? toolDecide(input.itemName, input.decision, input.reason, input.attemptEvidenceIds, options)
531
+ : toolDecide(input.itemName, input.decision, input.note, undefined, options)));
532
+ if (!options.noUi) {
533
+ server.registerResource("survey-review-workbench", QUEUE_PANEL_URI, {
534
+ title: "Survey review workbench",
535
+ description: "Interactive review card for the active item in the configured review session.",
536
+ mimeType: UI_RESOURCE_MIME,
537
+ cacheHint: { ttlMs: 0, cacheScope: "private" },
538
+ }, async () => {
539
+ const resource = await readQueuePanelResource(options);
540
+ return {
541
+ contents: [
542
+ {
543
+ uri: QUEUE_PANEL_URI,
544
+ mimeType: resource.mimeType,
545
+ text: sanitizeProtocolText(resource.text),
546
+ _meta: resource._meta,
547
+ },
548
+ ],
549
+ };
550
+ });
482
551
  }
483
- const { id, method, params } = message;
484
- const isNotification = id === undefined;
552
+ return server;
553
+ }
554
+ async function runReviewTool(operation) {
485
555
  try {
486
- if (method === "initialize") {
487
- send({
488
- jsonrpc: "2.0",
489
- id,
490
- result: {
491
- protocolVersion: PROTOCOL_VERSION,
492
- capabilities: {
493
- tools: { listChanged: false },
494
- // Resources back the SEP-1865 ui:// review card (unless --no-ui).
495
- ...(options.noUi ? {} : { resources: { listChanged: false } }),
496
- ...(options.noUi
497
- ? {}
498
- : { extensions: { [UI_CAPABILITY_EXTENSION]: {} } }),
499
- },
500
- serverInfo: { name: "survey-review-mcp", title: "Survey Review MCP", version: serverVersion },
501
- instructions: "Use survey_review_queue to inspect the queue, survey_review_item to drill into a single item, and survey_review_decide to record a decision. Decisions are persisted to the session file and are irreversible within this session.",
502
- },
503
- });
504
- }
505
- else if (method === "ping") {
506
- send({ jsonrpc: "2.0", id, result: {} });
507
- }
508
- else if (method === "tools/list") {
509
- send({
510
- jsonrpc: "2.0",
511
- id,
512
- result: {
513
- tools: [
514
- {
515
- name: "survey_review_queue",
516
- title: "Review queue",
517
- description: "Return a text summary and JSON of the current review queue: all items with their status (pending, in-review, resolved, rejected, escalated), the active item, resolved/total counts, and session summary totals.",
518
- inputSchema: { type: "object", properties: {} },
519
- // SEP-1865 UI pointer (both flat canonical + nested), unless --no-ui.
520
- ...(options.noUi
521
- ? {}
522
- : {
523
- _meta: {
524
- [UI_RESOURCE_URI_META_KEY]: QUEUE_PANEL_URI,
525
- ui: { resourceUri: QUEUE_PANEL_URI, visibility: ["model", "app"] },
526
- },
527
- }),
528
- },
529
- {
530
- name: "survey_review_item",
531
- title: "Review item detail",
532
- description: "Return full detail for a single review item: current vs proposed values, confidence, source references, excerpts, and the current decision (if any).",
533
- inputSchema: {
534
- type: "object",
535
- properties: {
536
- itemName: { type: "string", description: "The ReviewItem name to inspect." },
537
- },
538
- required: ["itemName"],
539
- },
540
- },
541
- {
542
- name: "survey_review_decide",
543
- title: "Record a review decision",
544
- description: "Apply a decision to a review item and persist it to the session file. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
545
- inputSchema: {
546
- type: "object",
547
- properties: {
548
- itemName: { type: "string", description: "The ReviewItem name to decide." },
549
- decision: {
550
- type: "string",
551
- enum: ["accept", "hold", "reject", "could-not-confirm"],
552
- description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed, could-not-confirm = terminal non-answer.",
553
- },
554
- note: { type: "string", description: "Optional reviewer note / rationale." },
555
- reason: { type: "string", minLength: 1, description: "Required non-empty reason when decision is could-not-confirm." },
556
- attemptEvidenceIds: {
557
- type: "array",
558
- items: { type: "string" },
559
- description: "Optional evidence ids recording what was attempted before could-not-confirm.",
560
- },
561
- },
562
- required: ["itemName", "decision"],
563
- allOf: [{
564
- if: { properties: { decision: { const: "could-not-confirm" } }, required: ["decision"] },
565
- then: { required: ["reason"] },
566
- }],
567
- },
568
- },
569
- ],
570
- },
571
- });
572
- }
573
- else if (method === "resources/list") {
574
- send({
575
- jsonrpc: "2.0",
576
- id,
577
- result: {
578
- resources: options.noUi
579
- ? []
580
- : [
581
- {
582
- uri: QUEUE_PANEL_URI,
583
- name: "Survey review workbench",
584
- description: "Interactive review card for the active item in the configured review session (MCP Apps UI resource).",
585
- mimeType: UI_RESOURCE_MIME,
586
- },
587
- ],
588
- },
589
- });
590
- }
591
- else if (method === "resources/read") {
592
- const uri = typeof params?.uri === "string" ? params.uri : "";
593
- if (options.noUi || uri !== QUEUE_PANEL_URI) {
594
- send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown resource: ${uri || "(missing uri)"}` } });
595
- return;
596
- }
597
- const html = await readQueuePanelHtml(options);
598
- send({
599
- jsonrpc: "2.0",
600
- id,
601
- result: { contents: [{ uri: QUEUE_PANEL_URI, mimeType: UI_RESOURCE_MIME, text: html }] },
602
- });
603
- }
604
- else if (method === "tools/call") {
605
- const name = typeof params?.name === "string" ? params.name : "";
606
- const toolArgs = (params?.arguments ?? {});
607
- try {
608
- let content;
609
- if (name === "survey_review_queue") {
610
- content = await toolQueue(options);
611
- }
612
- else if (name === "survey_review_item") {
613
- const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
614
- if (!itemName) {
615
- throw new DomainError("survey_review_item requires itemName");
616
- }
617
- content = await toolItem(itemName, options);
618
- }
619
- else if (name === "survey_review_decide") {
620
- const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
621
- const decision = typeof toolArgs.decision === "string" ? toolArgs.decision : "";
622
- const note = typeof toolArgs.note === "string" ? toolArgs.note : undefined;
623
- const reason = typeof toolArgs.reason === "string" ? toolArgs.reason : undefined;
624
- const attemptEvidenceIds = Array.isArray(toolArgs.attemptEvidenceIds)
625
- && toolArgs.attemptEvidenceIds.every((value) => typeof value === "string")
626
- ? toolArgs.attemptEvidenceIds
627
- : undefined;
628
- if (!itemName)
629
- throw new DomainError("survey_review_decide requires itemName");
630
- if (!decision)
631
- throw new DomainError("survey_review_decide requires decision");
632
- content = await toolDecide(itemName, decision, decision === "could-not-confirm" ? reason : note, attemptEvidenceIds, options);
633
- }
634
- else {
635
- send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${name || "(missing name)"}` } });
636
- return;
637
- }
638
- send({ jsonrpc: "2.0", id, result: { content, isError: false } });
639
- }
640
- catch (error) {
641
- const text = error instanceof Error ? error.message : String(error);
642
- send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }], isError: true } });
643
- }
644
- }
645
- else if (isNotification) {
646
- // Lifecycle notifications such as notifications/initialized need no reply.
647
- }
648
- else {
649
- send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method ?? "(none)"}` } });
650
- }
556
+ return {
557
+ content: (await operation()).map(sanitizeContentItem),
558
+ isError: false,
559
+ };
651
560
  }
652
561
  catch (error) {
653
- if (!isNotification) {
654
- const messageText = error instanceof Error ? error.message : String(error);
655
- send({ jsonrpc: "2.0", id, error: { code: -32603, message: messageText } });
656
- }
562
+ return {
563
+ content: [
564
+ {
565
+ type: "text",
566
+ text: sanitizeProtocolText(error instanceof Error ? error.message : String(error)),
567
+ },
568
+ ],
569
+ isError: true,
570
+ };
657
571
  }
658
572
  }
573
+ const UNSAFE_TEXT_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u0080-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u206f]/g;
574
+ function sanitizeProtocolText(text) {
575
+ return text.replace(UNSAFE_TEXT_CHARS_RE, "");
576
+ }
577
+ function sanitizeContentItem(item) {
578
+ if (item.type === "text") {
579
+ return { ...item, text: sanitizeProtocolText(item.text) };
580
+ }
581
+ return {
582
+ ...item,
583
+ resource: {
584
+ ...item.resource,
585
+ text: sanitizeProtocolText(item.resource.text),
586
+ },
587
+ };
588
+ }
589
+ function sanitizeDiagnostic(text) {
590
+ return sanitizeProtocolText(text).replaceAll(/\s*\r?\n\s*/g, " ").trim();
591
+ }
659
592
  // ---- Entry point ---------------------------------------------------------
660
593
  function parseMcpArgs(args) {
661
594
  const defaultSession = resolve(dirname(new URL(import.meta.url).pathname), "../../../example-data/mcp-review-session.json");
@@ -688,17 +621,19 @@ async function readPackageVersion() {
688
621
  return "0.0.0";
689
622
  }
690
623
  }
691
- function send(message) {
692
- process.stdout.write(`${JSON.stringify(message)}\n`);
693
- }
694
624
  export async function runReviewMcp(args) {
695
625
  const options = parseMcpArgs(args);
696
626
  const serverVersion = await readPackageVersion();
697
- const rl = createInterface({ input: process.stdin, terminal: false });
698
- rl.on("line", (line) => {
699
- void handleLine(line, options, serverVersion);
627
+ const inputClosed = new Promise((resolveClosed) => {
628
+ process.stdin.once("end", resolveClosed);
629
+ process.stdin.once("close", resolveClosed);
700
630
  });
701
- await new Promise((resolveClosed) => {
702
- rl.on("close", resolveClosed);
631
+ const handle = serveStdio(() => createReviewMcpServer(options, serverVersion), {
632
+ legacy: "serve",
633
+ onerror: (error) => {
634
+ process.stderr.write(`survey-review-mcp: ${sanitizeDiagnostic(error.message)}\n`);
635
+ },
703
636
  });
637
+ await inputClosed;
638
+ await handle.close();
704
639
  }
@@ -0,0 +1,6 @@
1
+ import type { SurveyExtractionEnvelopeImport, SurveyExtractionReviewDecision, SurveyExtractionReviewItem } from "@kontourai/surface";
2
+ import type { ExtractionEnvelopeImport } from "./extraction-envelope.js";
3
+ import type { ReviewDecision, ReviewItem } from "./review-resource.js";
4
+ export declare function toSurfaceReviewedExtractionImport(record: ExtractionEnvelopeImport): SurveyExtractionEnvelopeImport;
5
+ export declare function toSurfaceReviewedExtractionItem(item: ReviewItem): SurveyExtractionReviewItem;
6
+ export declare function toSurfaceReviewedExtractionDecision(decision: ReviewDecision): SurveyExtractionReviewDecision;
@@ -0,0 +1,9 @@
1
+ export function toSurfaceReviewedExtractionImport(record) {
2
+ return record;
3
+ }
4
+ export function toSurfaceReviewedExtractionItem(item) {
5
+ return item;
6
+ }
7
+ export function toSurfaceReviewedExtractionDecision(decision) {
8
+ return decision;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "2.4.3",
3
+ "version": "3.0.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -50,9 +50,10 @@
50
50
  "prepare": "npm run build",
51
51
  "typecheck": "tsc --noEmit",
52
52
  "test": "npm run build && node --test dist/tests/*.test.js",
53
+ "test:package-smoke": "npm run build && node --test dist/tests/package-smoke/*.test.js",
53
54
  "test:browser": "playwright test",
54
55
  "test:browser:concurrent": "npm run build && node scripts/test-playwright-concurrency.mjs",
55
- "verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
56
+ "verify": "npm run check:content-boundary && npm run check:decisions && npm run typecheck && npm test && npm run test:package-smoke && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
56
57
  "check:content-boundary": "node --test tests/content-boundary-script.test.cjs && node scripts/check-content-boundary.cjs",
57
58
  "check:decisions": "node scripts/check-decisions.cjs check",
58
59
  "check:guards": "node scripts/check-guards.mjs",
@@ -71,7 +72,9 @@
71
72
  "workflow:validate-artifacts": "flow-agents-validate-artifacts"
72
73
  },
73
74
  "dependencies": {
74
- "@kontourai/surface": "^2.9.0"
75
+ "@kontourai/surface": "^2.13.0",
76
+ "@modelcontextprotocol/server": "2.0.0",
77
+ "zod": "4.2.0"
75
78
  },
76
79
  "peerDependencies": {
77
80
  "typescript": ">=5.0.0"