@clien-ai/mcp 0.11.0 → 0.12.1

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.
@@ -12,12 +12,16 @@
12
12
  * — see how research.ts handles it.
13
13
  */
14
14
  import { z } from 'zod';
15
+ import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_PRIORITIES, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, hasCoherentRobustnessWorking, normalizeFinalHypothesisState, readRobustnessCounts, robustnessWorkingContradictsSummary, } from '../hypothesis-semantics.js';
15
16
  // ⚠️ A types module importing from `tools/` reads backwards, and it is deliberate.
16
17
  // `content-type-display.ts` imports NOTHING — it is the package's content-type vocabulary, which
17
18
  // happens to live beside the two renderers that were its first callers, and the FUL-341 coupling
18
19
  // test parses `CANONICAL_CONTENT_TYPES` out of that exact path as TEXT. Moving the file to break
19
20
  // the layering would cost more than the layering does.
20
21
  import { CONTENT_TYPE_EXCLUSIONS, CONTENT_TYPE_STATES } from '../tools/content-type-display.js';
22
+ import { parseReceiptId } from '../tools/receipt-children.js';
23
+ import { readSemanticTheme } from './semantic-theme.js';
24
+ export { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, } from '../hypothesis-semantics.js';
21
25
  /**
22
26
  * A competitor discovered during the research run.
23
27
  *
@@ -190,8 +194,6 @@ export const ReportSourceSchema = z
190
194
  'single excerpt in `quote`.'),
191
195
  })
192
196
  .passthrough();
193
- /** The machine verdict statuses a hypothesis / robustness re-ask can return. MIRROR of `VERDICT_STATUSES`. */
194
- export const VERDICT_STATUSES = ['validated', 'invalidated', 'inconclusive'];
195
197
  /**
196
198
  * One reworded re-ask of a hypothesis verdict (robustness probe). MIRROR of
197
199
  * `RobustnessVariantSchema` in `agent/src/types.ts`. Failed/flipped variants are
@@ -201,7 +203,7 @@ export const RobustnessVariantSchema = z
201
203
  .object({
202
204
  framing: z.string().describe('The exact reworded question the verdict was re-asked under.'),
203
205
  returnedStatus: z.enum(VERDICT_STATUSES).describe('The verdict this framing returned.'),
204
- returnedConfidence: z.number().describe('Confidence (0-1) of this framing\'s verdict.'),
206
+ returnedConfidence: z.number().min(0).max(1).describe('Confidence (0-1) of this framing\'s verdict.'),
205
207
  agreed: z.boolean().describe('True when this framing returned the same verdict as the original synthesis.'),
206
208
  reasoning: z.string().optional().describe('One-line reason for this framing\'s verdict.'),
207
209
  })
@@ -216,16 +218,70 @@ export const RobustnessVariantSchema = z
216
218
  export const RobustnessResultSchema = z
217
219
  .object({
218
220
  originalStatus: z.enum(VERDICT_STATUSES).describe('The synthesiser verdict that was re-tested.'),
219
- survived: z.number().describe('How many framings returned the same verdict.'),
220
- total: z.number().describe('How many framings were re-asked (may be < intended if budget-capped).'),
221
+ survived: z.number().int().min(0).describe('How many framings returned the same verdict.'),
222
+ total: z.number().int().positive().describe('How many framings were re-asked (may be < intended if budget-capped).'),
221
223
  flipped: z.boolean().describe('True when at least one framing returned a different verdict.'),
222
224
  downgradedStatus: z
223
225
  .enum(VERDICT_STATUSES)
224
226
  .optional()
225
227
  .describe('Weaker verdict displayed when the original flipped under a rephrasing; absent when it held.'),
226
228
  variants: z.array(RobustnessVariantSchema).describe('Every re-ask, passed AND failed — the inspectable working behind the survival count.'),
229
+ })
230
+ .passthrough()
231
+ .superRefine((value, ctx) => {
232
+ if (!readRobustnessCounts(value)) {
233
+ ctx.addIssue({
234
+ code: 'custom',
235
+ path: ['survived'],
236
+ message: 'Robustness survival count must be a possible survived/total ratio',
237
+ });
238
+ }
239
+ if (!hasCoherentRobustnessWorking(value)) {
240
+ ctx.addIssue({
241
+ code: 'custom',
242
+ path: ['variants'],
243
+ message: 'Robustness variants must prove the recorded survival summary',
244
+ });
245
+ }
246
+ });
247
+ /**
248
+ * The effective-result vocabulary (FUL-358). MIRRORS of `CONFIDENCE_LEVELS`,
249
+ * `EVIDENCE_SUFFICIENCY_LEVELS`, `ROBUSTNESS_OUTCOMES` and `CONFIDENCE_BASES` in
250
+ * `agent/src/types.ts`, pinned by the schema-coupling test at the repo root.
251
+ */
252
+ /**
253
+ * THE effective hypothesis result — verdict, reliability, coverage and robustness outcome,
254
+ * reconciled once by the producer. MIRROR of `FinalHypothesisStateSchema` in
255
+ * `agent/src/types.ts`.
256
+ *
257
+ * ⚠️ CODE-DERIVED by the agent, never model-written, so it can be quoted as-is. Three separate
258
+ * axes on purpose: `verdict` is which way the evidence pointed, `sufficiency` is how much of it
259
+ * there was, and `confidence` is how RELIABLE the verdict is — never the probability the
260
+ * hypothesis is true. `confidence` ABSENT means WITHHELD; read `confidenceBasis` for why.
261
+ *
262
+ * Absent on a legacy report, where the row's numeric `confidence` is the retired measure and is
263
+ * not comparable with these words.
264
+ */
265
+ export const FinalHypothesisStateSchema = z
266
+ .object({
267
+ version: z.literal(HYPOTHESIS_SEMANTICS_VERSION).describe('Which semantics contract this block was written under.'),
268
+ verdict: z.enum(VERDICT_STATUSES).describe('The EFFECTIVE verdict every surface shows. A robustness downgrade is applied only when it makes a strictly weaker claim, so a malformed re-ask block can never upgrade a row.'),
269
+ originalVerdict: z.enum(VERDICT_STATUSES).describe('The synthesiser\'s own verdict, preserved for audit even when the row was downgraded.'),
270
+ confidence: z.enum(CONFIDENCE_LEVELS).optional().describe('How reliable the final verdict is. ABSENT means withheld — see confidenceBasis. Never a probability that the hypothesis is true.'),
271
+ confidenceBasis: z.enum(CONFIDENCE_BASES).describe('Why confidence says what it says: read off the evidence, recomputed after the verdict flipped under rephrasing, or withheld because the run attached no evidence.'),
272
+ sufficiency: z.enum(EVIDENCE_SUFFICIENCY_LEVELS).describe('How much evidence sits behind the verdict, across how many research methods. A coverage axis, independent of which way the verdict pointed.'),
273
+ robustness: z.enum(ROBUSTNESS_OUTCOMES).describe('What the robustness pass did: held, flipped, or never re-asked. `not_retested` is explicit — it is not the same as held.'),
274
+ priority: z.enum(HYPOTHESIS_PRIORITIES).optional().catch(undefined).describe('The product priority of the underlying hypothesis, for ranking what to go and ask about first.'),
227
275
  })
228
276
  .passthrough();
277
+ /**
278
+ * The cross-field trust boundary for a producer-owned `final` block. Shape-valid enums are not
279
+ * enough: the block must name the row's original verdict, never strengthen any recorded verdict,
280
+ * and carry an axis combination the producer can actually emit.
281
+ */
282
+ export function isSemanticallyValidFinalHypothesisState(row) {
283
+ return normalizeFinalHypothesisState(row) !== null;
284
+ }
229
285
  /** One piece of evidence for/against a hypothesis. MIRROR of `EvidenceSchema`. */
230
286
  export const EvidenceSchema = z
231
287
  .object({
@@ -246,14 +302,31 @@ export const HypothesisResultSchema = z
246
302
  statement: z.string(),
247
303
  category: z.enum(['problem', 'solution', 'market', 'willingness_to_pay']),
248
304
  status: z.enum(VERDICT_STATUSES).describe('The verdict for this hypothesis.'),
249
- confidence: z.number().describe('Confidence (0-1) in the verdict.'),
305
+ confidence: z.number().describe('Legacy numeric confidence stored on either the retired 0-1 or 0-100 scale. Preserve and label it as legacy; never compare it with final.confidence, rank on it, or trend it.'),
250
306
  supportingEvidence: z.array(EvidenceSchema).optional(),
251
307
  contradictingEvidence: z.array(EvidenceSchema).optional(),
252
308
  robustness: RobustnessResultSchema.optional().describe('Robustness re-ask survival count for this verdict (absent on legacy reports / when not re-tested).'),
253
309
  scopeCaveat: z.string().optional().describe('Belief-adjacent scope-mismatch note (FUL-102): present when this hypothesis\'s persona support came from a persona speaking outside its credibility domain. Treat such support as directional, not grounded.'),
310
+ final: FinalHypothesisStateSchema.optional().describe('THE effective result (FUL-358) — verdict, reliability, evidence sufficiency and robustness outcome, reconciled once and read by every surface. Prefer `final.verdict` over `status` and `final.confidence` over the numeric `confidence` above. Absent on legacy reports, where the numeric `confidence` is the retired measure.'),
254
311
  evidenceReading: z.string().optional().describe('One plain-language line naming what this verdict rests on — how many findings support and contradict it, and which research methods they came from. CODE-COMPOSED from this row\'s own evidence arrays, never written by the model, so it can be quoted as-is. It deliberately says nothing about whether the personas disagreed: that is `sycophancySignals.disagreements`, a stricter predicate over persona identity, and reading a split out of this line would contradict it. Absent on reports written before FUL-396 and on a hypothesis the run attached no evidence to.'),
255
312
  })
256
- .passthrough();
313
+ .passthrough()
314
+ .superRefine((row, ctx) => {
315
+ if (row.robustness && row.robustness.originalStatus !== row.status) {
316
+ ctx.addIssue({
317
+ code: 'custom',
318
+ path: ['robustness', 'originalStatus'],
319
+ message: 'Robustness must re-test this hypothesis result status',
320
+ });
321
+ }
322
+ if (row.final !== undefined && !isSemanticallyValidFinalHypothesisState(row)) {
323
+ ctx.addIssue({
324
+ code: 'custom',
325
+ path: ['final'],
326
+ message: 'Final hypothesis semantics are incoherent with the row',
327
+ });
328
+ }
329
+ });
257
330
  /**
258
331
  * Something a persona explicitly rejected — the willingness-to-say-no signal.
259
332
  *
@@ -429,6 +502,19 @@ export const ReportIdentityPriorsSchema = z
429
502
  .describe('How the persona got to this role and where they are heading — a short trajectory arc, e.g. "engineer → engineering manager".'),
430
503
  })
431
504
  .passthrough();
505
+ /**
506
+ * Frozen, decorative persona portraits a report may carry (FUL-771).
507
+ *
508
+ * This is deliberately a closed list of bundled Clien assets, not a URL schema.
509
+ * Treating any syntactically valid URL as a portrait would turn report_data into
510
+ * an arbitrary remote-image channel and could also leak a live persona identity.
511
+ */
512
+ export const REPORT_PORTRAIT_PATHS = [
513
+ '/avatars/maya-chen.png',
514
+ '/avatars/james-rodriguez.png',
515
+ '/avatars/sarah-thompson.png',
516
+ ];
517
+ export const ReportPortraitSchema = z.enum(REPORT_PORTRAIT_PATHS);
432
518
  /**
433
519
  * A report persona carrying its source RECEIPTS. Lean surface — `.passthrough()`
434
520
  * keeps the full persona profile (goals, painPoints, psychographic, tools) the
@@ -440,6 +526,9 @@ export const ReportPersonaSchema = z
440
526
  .object({
441
527
  name: z.string(),
442
528
  role: z.string().optional(),
529
+ portrait: ReportPortraitSchema.optional()
530
+ .catch(undefined)
531
+ .describe('Frozen decorative portrait selected from Clien bundled assets. Never a live persona join or an arbitrary remote URL.'),
443
532
  sources: z.array(ReportSourceSchema).optional().describe('The real forum posts this persona is composited from — the receipts a claim\'s sourceId points at (may be empty when evidence was sparse).'),
444
533
  insufficientEvidence: z.boolean().optional().describe('True when the forum pre-pass found too few real posts to ground this persona.'),
445
534
  sourcesFound: z.number().optional().describe('Count of unique real posts the pre-pass surfaced for this persona.'),
@@ -764,13 +853,64 @@ export const MethodologyNoteSchema = z
764
853
  * ⚠️ DELIBERATELY NOT `.passthrough()`. The producer also carries
765
854
  * `researchTaskId`, an internal `validation_research_tasks.id` that is not an
766
855
  * interview-link contract. Zod's default strip behaviour is the privacy boundary:
767
- * only these four fields cross into MCP's structured report payload.
856
+ * only these fields cross into MCP's structured report payload. The content-addressed evidence
857
+ * block is public report identity; the internal research-task id remains excluded.
768
858
  */
769
859
  export const InterviewHighlightSchema = z.object({
770
860
  personaName: z.string(),
771
861
  personaRole: z.string(),
772
862
  overallImpression: z.string(),
773
863
  quotesByHypothesis: z.record(z.string(), z.array(z.string())),
864
+ evidenceReference: z.object({
865
+ interviewId: z.string().min(1),
866
+ quotes: z.array(z.object({
867
+ quoteId: z.string().min(1),
868
+ hypothesisId: z.string().min(1),
869
+ text: z.string(),
870
+ })),
871
+ }).optional(),
872
+ });
873
+ export const EvidenceRefSchema = z.discriminatedUnion('kind', [
874
+ z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
875
+ z.object({
876
+ kind: z.literal('forum_receipt'),
877
+ receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
878
+ message: 'Invalid receipt id',
879
+ }),
880
+ }),
881
+ // Kept in the parser for compatibility, but stripped by projectReportDataForMcp because the
882
+ // researchTaskId is private and this surface has no external interview-receipt contract.
883
+ z.object({ kind: z.literal('interview_quote'), researchTaskId: z.string(), quoteId: z.string() }),
884
+ z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
885
+ z.object({ kind: z.literal('hypothesis'), hypothesisId: z.string().min(1) }),
886
+ ]);
887
+ export const CrossCuttingEvidenceRefSchema = z.discriminatedUnion('kind', [
888
+ z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
889
+ z.object({
890
+ kind: z.literal('forum_receipt'),
891
+ receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
892
+ message: 'Invalid receipt id',
893
+ }),
894
+ }),
895
+ z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
896
+ ]);
897
+ export const CrossCuttingFindingSchema = z.object({
898
+ claim: z.string().min(1),
899
+ whyItMatters: z.string().min(1),
900
+ confidence: z.enum(['low', 'medium', 'high']),
901
+ evidenceRefs: z.array(CrossCuttingEvidenceRefSchema),
902
+ });
903
+ export const MCP_OVERVIEW_SPAN_CAP = 8;
904
+ export const MCP_OVERVIEW_REF_CAP = 4;
905
+ export const ReportOverviewSchema = z.object({
906
+ text: z.string(),
907
+ claimSpansTruncated: z.boolean().optional(),
908
+ claimSpans: z.array(z.object({
909
+ span: z.string(),
910
+ claimType: z.enum(['externally_checkable', 'inference']),
911
+ evidenceRefsTruncated: z.boolean().optional(),
912
+ evidenceRefs: z.array(EvidenceRefSchema).transform((refs) => refs.slice(0, MCP_OVERVIEW_REF_CAP)),
913
+ })).transform((spans) => spans.slice(0, MCP_OVERVIEW_SPAN_CAP)),
774
914
  });
775
915
  /**
776
916
  * The MCP-exposed shape of `_meta.report_data`. All fields optional —
@@ -797,14 +937,22 @@ export const ResearchReportDataSchema = z
797
937
  hypothesisResults: z
798
938
  .array(HypothesisResultSchema)
799
939
  .optional()
800
- .describe('Per-hypothesis verdicts (status + confidence + evidence), each carrying its robustness re-ask survival count when re-tested.'),
940
+ .describe('Per-hypothesis results. Prefer `final`: it is the effective verdict, categorical reliability, evidence sufficiency and robustness outcome. Raw `status` and numeric `confidence` are legacy compatibility fields; do not rank or compare the numeric measure.'),
801
941
  insights: z
802
942
  .array(InsightSchema)
803
943
  .optional()
804
944
  .describe('Typed insight findings. Fresh source rows carry exact RRCP ids; legacy string rows remain inert text.'),
945
+ crossCuttingFindings: z
946
+ .array(CrossCuttingFindingSchema.optional().catch(undefined))
947
+ .optional()
948
+ .catch(undefined)
949
+ .describe('Evidence-gated cross-method findings. Present empty means the gate ran and no candidate qualified.'),
805
950
  keyFindings: z.array(z.string()).optional(),
806
951
  recommendations: z.array(z.unknown()).optional(),
807
952
  executiveSummary: z.string().optional(),
953
+ reportTitle: z.string().optional(),
954
+ marketOverview: ReportOverviewSchema.optional().catch(undefined),
955
+ communityOverview: ReportOverviewSchema.optional().catch(undefined),
808
956
  personasSynthesis: PersonasSynthesisSchema
809
957
  .optional()
810
958
  .catch(undefined)
@@ -934,19 +1082,195 @@ export const ResearchReportDataSchema = z
934
1082
  * rather than represented as empty; absence says "unreadable", while `[]` would
935
1083
  * falsely say the run recorded no highlights.
936
1084
  */
1085
+ const MCP_REPORT_TITLE_MAX_CODE_POINTS = 160;
1086
+ export function sanitizeMcpMarkdownTitle(value) {
1087
+ if (typeof value !== 'string')
1088
+ return 'Report';
1089
+ const title = value
1090
+ .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
1091
+ .replace(/\s+/g, ' ')
1092
+ .trim();
1093
+ return title && Array.from(title).length <= MCP_REPORT_TITLE_MAX_CODE_POINTS ? title : 'Report';
1094
+ }
1095
+ export function sanitizeMcpReportTitle(value) {
1096
+ const title = sanitizeMcpMarkdownTitle(value);
1097
+ const latinWords = title.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? [];
1098
+ const cjkCharacters = title.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? [];
1099
+ const words = latinWords.filter((word) => !/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(word)).length + cjkCharacters.length;
1100
+ return words >= 6 && words <= 12 ? title : 'Report';
1101
+ }
937
1102
  export function projectReportDataForMcp(raw) {
938
1103
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
939
1104
  return raw;
940
1105
  const record = raw;
1106
+ const asProjectionRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
1107
+ ? value
1108
+ : null;
1109
+ const safeProjectionUrl = (value) => {
1110
+ if (typeof value !== 'string' || !/^https?:\/\//i.test(value))
1111
+ return false;
1112
+ try {
1113
+ const url = new URL(value);
1114
+ return (url.protocol === 'http:' || url.protocol === 'https:') &&
1115
+ !url.username && !url.password;
1116
+ }
1117
+ catch {
1118
+ return false;
1119
+ }
1120
+ };
1121
+ const hasProjectionVisibleContent = (value) => typeof value === 'string' &&
1122
+ value.replace(/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}]/gu, '').length > 0;
941
1123
  const hasHighlights = Object.prototype.hasOwnProperty.call(record, 'interviewHighlights');
1124
+ const hasHypothesisResults = Object.prototype.hasOwnProperty.call(record, 'hypothesisResults');
1125
+ const hasReportTitle = Object.prototype.hasOwnProperty.call(record, 'reportTitle');
1126
+ const hasOverview = Object.prototype.hasOwnProperty.call(record, 'marketOverview') ||
1127
+ Object.prototype.hasOwnProperty.call(record, 'communityOverview');
1128
+ const hasCrossCutting = Object.prototype.hasOwnProperty.call(record, 'crossCuttingFindings');
1129
+ const hasPersonas = Object.prototype.hasOwnProperty.call(record, 'personas');
1130
+ const hasForumResearch = Object.prototype.hasOwnProperty.call(record, 'forumResearch');
942
1131
  const methodology = record.methodology;
943
1132
  const hasRedditNote = methodology !== null &&
944
1133
  typeof methodology === 'object' &&
945
1134
  !Array.isArray(methodology) &&
946
1135
  Object.prototype.hasOwnProperty.call(methodology, 'redditRetrieval');
947
- if (!hasHighlights && !hasRedditNote)
1136
+ if (!hasHighlights && !hasRedditNote && !hasHypothesisResults && !hasOverview && !hasReportTitle && !hasCrossCutting && !hasPersonas && !hasForumResearch)
948
1137
  return raw;
949
1138
  const projected = { ...record };
1139
+ if (hasReportTitle)
1140
+ projected.reportTitle = sanitizeMcpReportTitle(record.reportTitle);
1141
+ // Validate the frozen decoration before either the typed parse or raw schema-drift exit.
1142
+ // Rebuilding only this field preserves the full forward-compatible persona object while an
1143
+ // object, identifier-bearing value or arbitrary URL fails closed to the normal initials path.
1144
+ if (hasPersonas && Array.isArray(record.personas)) {
1145
+ projected.personas = record.personas.map((rawPersona) => {
1146
+ const persona = asProjectionRecord(rawPersona);
1147
+ if (!persona)
1148
+ return rawPersona;
1149
+ const nextPersona = { ...persona };
1150
+ const portrait = ReportPortraitSchema.safeParse(persona.portrait);
1151
+ if (portrait.success)
1152
+ nextPersona.portrait = portrait.data;
1153
+ else
1154
+ delete nextPersona.portrait;
1155
+ return nextPersona;
1156
+ });
1157
+ }
1158
+ else if (hasPersonas) {
1159
+ // A malformed container must not survive the raw schema-drift exit with an arbitrary nested
1160
+ // URL or identifier. Absence is the only honest forward-compatible projection here.
1161
+ delete projected.personas;
1162
+ }
1163
+ // `forumResearch` is intentionally forward-compatible and currently crosses the full-report
1164
+ // schema through `.passthrough()`. Scrub its optional display label before both the typed and
1165
+ // raw-drift exits so a source-title copy cannot survive in `_meta.report_data` even when the
1166
+ // prose renderer correctly withholds it.
1167
+ if (hasForumResearch) {
1168
+ const forum = asProjectionRecord(record.forumResearch);
1169
+ if (forum) {
1170
+ const nextForum = { ...forum };
1171
+ if (Array.isArray(forum.threads)) {
1172
+ nextForum.threads = forum.threads.map((rawThread) => {
1173
+ const thread = asProjectionRecord(rawThread);
1174
+ if (!thread)
1175
+ return rawThread;
1176
+ const nextThread = { ...thread };
1177
+ const theme = typeof thread.title === 'string'
1178
+ ? readSemanticTheme(thread.theme, thread.title)
1179
+ : null;
1180
+ if (theme === null) {
1181
+ delete nextThread.theme;
1182
+ }
1183
+ else {
1184
+ nextThread.theme = theme;
1185
+ }
1186
+ return nextThread;
1187
+ });
1188
+ }
1189
+ else {
1190
+ delete nextForum.threads;
1191
+ }
1192
+ projected.forumResearch = nextForum;
1193
+ }
1194
+ else {
1195
+ delete projected.forumResearch;
1196
+ }
1197
+ }
1198
+ const projectOverviewRef = (rawRef) => {
1199
+ if (rawRef === null || typeof rawRef !== 'object' || Array.isArray(rawRef))
1200
+ return null;
1201
+ const ref = rawRef;
1202
+ if (ref.kind === 'report_evidence' && typeof ref.sourceId === 'string' && ref.sourceId.length > 0) {
1203
+ return { kind: 'report_evidence', sourceId: ref.sourceId };
1204
+ }
1205
+ if (ref.kind === 'forum_receipt' &&
1206
+ parseReceiptId(ref.receiptId)) {
1207
+ return { kind: 'forum_receipt', receiptId: ref.receiptId };
1208
+ }
1209
+ if (ref.kind === 'interview_quote_v2' &&
1210
+ typeof ref.interviewId === 'string' && ref.interviewId.length > 0 &&
1211
+ typeof ref.quoteId === 'string' && ref.quoteId.length > 0) {
1212
+ return { kind: 'interview_quote_v2', interviewId: ref.interviewId, quoteId: ref.quoteId };
1213
+ }
1214
+ if (ref.kind === 'hypothesis' && typeof ref.hypothesisId === 'string' && ref.hypothesisId.length > 0) {
1215
+ return { kind: 'hypothesis', hypothesisId: ref.hypothesisId };
1216
+ }
1217
+ // Interview references and unknown future variants are private by default. Rebuilding every
1218
+ // surviving object from an allowlist prevents schema-drift fallback from carrying opaque ids.
1219
+ return null;
1220
+ };
1221
+ for (const key of ['marketOverview', 'communityOverview']) {
1222
+ const overview = record[key];
1223
+ if (overview === null || typeof overview !== 'object' || Array.isArray(overview)) {
1224
+ delete projected[key];
1225
+ continue;
1226
+ }
1227
+ const overviewRecord = overview;
1228
+ if (typeof overviewRecord.text !== 'string' || !Array.isArray(overviewRecord.claimSpans)) {
1229
+ delete projected[key];
1230
+ continue;
1231
+ }
1232
+ const claimSpans = overviewRecord.claimSpans.slice(0, MCP_OVERVIEW_SPAN_CAP).flatMap((rawClaim) => {
1233
+ if (rawClaim === null || typeof rawClaim !== 'object' || Array.isArray(rawClaim))
1234
+ return [];
1235
+ const claim = rawClaim;
1236
+ if (typeof claim.span !== 'string' ||
1237
+ (claim.claimType !== 'externally_checkable' && claim.claimType !== 'inference'))
1238
+ return [];
1239
+ const rawEvidenceRefs = Array.isArray(claim.evidenceRefs) ? claim.evidenceRefs : [];
1240
+ const evidenceRefs = [];
1241
+ const seenRefs = new Set();
1242
+ for (const rawRef of rawEvidenceRefs.slice(0, MCP_OVERVIEW_REF_CAP)) {
1243
+ const safeRef = projectOverviewRef(rawRef);
1244
+ if (!safeRef)
1245
+ continue;
1246
+ const refKey = safeRef.kind === 'report_evidence'
1247
+ ? `${safeRef.kind}:${safeRef.sourceId}`
1248
+ : safeRef.kind === 'forum_receipt'
1249
+ ? `${safeRef.kind}:${safeRef.receiptId}`
1250
+ : safeRef.kind === 'interview_quote_v2'
1251
+ ? `${safeRef.kind}:${safeRef.interviewId}:${safeRef.quoteId}`
1252
+ : `${safeRef.kind}:${safeRef.hypothesisId}`;
1253
+ if (seenRefs.has(refKey))
1254
+ continue;
1255
+ seenRefs.add(refKey);
1256
+ evidenceRefs.push(safeRef);
1257
+ }
1258
+ return [{
1259
+ span: claim.span,
1260
+ claimType: claim.claimType,
1261
+ evidenceRefsTruncated: rawEvidenceRefs.length > MCP_OVERVIEW_REF_CAP,
1262
+ evidenceRefs,
1263
+ }];
1264
+ });
1265
+ projected[key] = {
1266
+ text: overviewRecord.text,
1267
+ claimSpansTruncated: overviewRecord.claimSpans.length > MCP_OVERVIEW_SPAN_CAP,
1268
+ claimSpans,
1269
+ };
1270
+ }
1271
+ // Cross-cutting refs must resolve against the exact public projection. A malformed sibling
1272
+ // withdraws the all-or-nothing highlight block below, so validating against the raw array would
1273
+ // retain a finding whose cited quote is absent from structured output and rendered evidence.
950
1274
  if (hasHighlights) {
951
1275
  const highlights = z.array(InterviewHighlightSchema).safeParse(record.interviewHighlights);
952
1276
  if (highlights.success)
@@ -954,6 +1278,143 @@ export function projectReportDataForMcp(raw) {
954
1278
  else
955
1279
  delete projected.interviewHighlights;
956
1280
  }
1281
+ if (hasCrossCutting) {
1282
+ // Current reports replace the legacy surface. The projection is itself externally visible
1283
+ // under `_meta.report_data`, so suppressing only the composed prose would still leak it.
1284
+ delete projected.insights;
1285
+ const resolvesCrossCuttingRef = (ref) => {
1286
+ if (ref.kind === 'report_evidence') {
1287
+ const match = /^RRCP-s(0|[1-9]\d*)$/.exec(ref.sourceId ?? '');
1288
+ const source = match && asProjectionRecord(Array.isArray(record.reportEvidence)
1289
+ ? record.reportEvidence[Number(match[1])]
1290
+ : undefined);
1291
+ return Boolean(source &&
1292
+ safeProjectionUrl(source.url) &&
1293
+ (source.section === 'market' || source.section === 'competitor' || source.section === 'summary'));
1294
+ }
1295
+ if (ref.kind === 'forum_receipt') {
1296
+ const matches = (Array.isArray(asProjectionRecord(record.forumResearch)?.threads)
1297
+ ? asProjectionRecord(record.forumResearch).threads
1298
+ : []).flatMap((rawThread) => {
1299
+ const thread = asProjectionRecord(rawThread);
1300
+ return (Array.isArray(thread?.receipts) ? thread.receipts : []).filter((rawReceipt) => {
1301
+ const receipt = asProjectionRecord(rawReceipt);
1302
+ return Boolean(receipt && receipt.receiptId === ref.receiptId &&
1303
+ hasProjectionVisibleContent(receipt.excerpt));
1304
+ });
1305
+ });
1306
+ return matches.length === 1;
1307
+ }
1308
+ if (ref.kind === 'interview_quote_v2') {
1309
+ const matches = (Array.isArray(projected.interviewHighlights) ? projected.interviewHighlights : [])
1310
+ .flatMap((rawHighlight) => {
1311
+ const evidence = asProjectionRecord(asProjectionRecord(rawHighlight)?.evidenceReference);
1312
+ if (!evidence || evidence.interviewId !== ref.interviewId)
1313
+ return [];
1314
+ return (Array.isArray(evidence.quotes) ? evidence.quotes : []).filter((rawQuote) => {
1315
+ const quote = asProjectionRecord(rawQuote);
1316
+ return Boolean(quote && quote.quoteId === ref.quoteId &&
1317
+ hasProjectionVisibleContent(quote.text));
1318
+ });
1319
+ });
1320
+ return matches.length === 1;
1321
+ }
1322
+ return false;
1323
+ };
1324
+ projected.crossCuttingFindings = Array.isArray(record.crossCuttingFindings)
1325
+ ? record.crossCuttingFindings.slice(0, 5).flatMap((rawFinding) => {
1326
+ if (rawFinding === null || typeof rawFinding !== 'object' || Array.isArray(rawFinding))
1327
+ return [];
1328
+ const finding = rawFinding;
1329
+ if (!hasProjectionVisibleContent(finding.claim) ||
1330
+ !hasProjectionVisibleContent(finding.whyItMatters) ||
1331
+ !['low', 'medium', 'high'].includes(String(finding.confidence)))
1332
+ return [];
1333
+ const rawEvidenceRefs = Array.isArray(finding.evidenceRefs) ? finding.evidenceRefs : [];
1334
+ const evidenceRefs = rawEvidenceRefs
1335
+ .slice(0, 8)
1336
+ .flatMap((rawRef) => {
1337
+ const ref = projectOverviewRef(rawRef);
1338
+ return ref ? [ref] : [];
1339
+ });
1340
+ const methods = new Set(evidenceRefs.map((ref) => ref.kind));
1341
+ const refKeys = new Set(evidenceRefs.map((ref) => JSON.stringify(ref)));
1342
+ if (rawEvidenceRefs.length < 2 ||
1343
+ rawEvidenceRefs.length > 8 ||
1344
+ evidenceRefs.length !== rawEvidenceRefs.length ||
1345
+ refKeys.size < 2 ||
1346
+ methods.size < 2 ||
1347
+ !evidenceRefs.every(resolvesCrossCuttingRef))
1348
+ return [];
1349
+ return [{
1350
+ claim: finding.claim,
1351
+ whyItMatters: finding.whyItMatters,
1352
+ confidence: finding.confidence,
1353
+ evidenceRefs,
1354
+ }];
1355
+ })
1356
+ : [];
1357
+ }
1358
+ if (hasHypothesisResults && Array.isArray(record.hypothesisResults)) {
1359
+ projected.hypothesisResults = record.hypothesisResults.map((rawRow) => {
1360
+ if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow))
1361
+ return rawRow;
1362
+ const row = rawRow;
1363
+ const safeRow = { ...row };
1364
+ let changed = false;
1365
+ if (Object.prototype.hasOwnProperty.call(row, 'final')) {
1366
+ const normalized = normalizeFinalHypothesisState(row);
1367
+ if (normalized)
1368
+ safeRow.final = normalized;
1369
+ else
1370
+ delete safeRow.final;
1371
+ changed = safeRow.final !== row.final;
1372
+ }
1373
+ const rawRobustness = row.robustness;
1374
+ const robustnessRecord = rawRobustness !== null &&
1375
+ typeof rawRobustness === 'object' &&
1376
+ !Array.isArray(rawRobustness)
1377
+ ? rawRobustness
1378
+ : null;
1379
+ const nonRecordRobustnessIsUnsafe = rawRobustness !== undefined && rawRobustness !== null && robustnessRecord === null;
1380
+ const parentStatusIsUnsafe = robustnessRecord !== null &&
1381
+ !VERDICT_STATUSES.includes(row.status);
1382
+ const carriesMeasuredCounts = robustnessRecord !== null &&
1383
+ (Object.prototype.hasOwnProperty.call(robustnessRecord, 'survived') ||
1384
+ Object.prototype.hasOwnProperty.call(robustnessRecord, 'total'));
1385
+ const carriesVariants = robustnessRecord !== null &&
1386
+ Object.prototype.hasOwnProperty.call(robustnessRecord, 'variants');
1387
+ const carriesOriginalStatus = robustnessRecord !== null &&
1388
+ Object.prototype.hasOwnProperty.call(robustnessRecord, 'originalStatus');
1389
+ const originalStatusMatches = robustnessRecord !== null &&
1390
+ robustnessRecord.originalStatus === row.status;
1391
+ const workingIsUnsafe = carriesVariants && robustnessWorkingContradictsSummary(robustnessRecord);
1392
+ const originalStatusIsUnsafe = carriesOriginalStatus && !originalStatusMatches;
1393
+ const measuredCountsAreUnsafe = carriesMeasuredCounts && !readRobustnessCounts(robustnessRecord);
1394
+ if (nonRecordRobustnessIsUnsafe || parentStatusIsUnsafe) {
1395
+ delete safeRow.robustness;
1396
+ changed = true;
1397
+ }
1398
+ else if (robustnessRecord && (workingIsUnsafe || originalStatusIsUnsafe || measuredCountsAreUnsafe)) {
1399
+ // Keep a readable downgrade: a broken diagnostic ratio must not erase the weakest
1400
+ // recorded verdict. Contradictory detail cannot authenticate the summary or dependent
1401
+ // final block, so neither may cross the typed or raw-drift exit.
1402
+ const safeRobustness = { ...robustnessRecord };
1403
+ delete safeRobustness.survived;
1404
+ delete safeRobustness.total;
1405
+ delete safeRobustness.flipped;
1406
+ if (workingIsUnsafe || originalStatusIsUnsafe)
1407
+ delete safeRobustness.variants;
1408
+ if (originalStatusIsUnsafe)
1409
+ delete safeRobustness.originalStatus;
1410
+ safeRow.robustness = safeRobustness;
1411
+ changed = true;
1412
+ }
1413
+ if (!changed)
1414
+ return rawRow;
1415
+ return safeRow;
1416
+ });
1417
+ }
957
1418
  // ⚠️ FUL-685 — THE REDDIT NOTE IS PROJECTED HERE FOR THE SAME REASON `researchTaskId` IS:
958
1419
  // `prepareReportDataForMcp` returns this projection UNPARSED when the full schema drifts, so a
959
1420
  // field stripped only by `RedditRetrievalNoteSchema` would come straight back on that path — and
@@ -1191,6 +1652,14 @@ export const MarketSizingLineSchema = z
1191
1652
  }
1192
1653
  }
1193
1654
  });
1655
+ export const MarketSignalReferenceSchema = z.discriminatedUnion('kind', [
1656
+ z.object({ kind: z.literal('size'), scope: z.enum(['tam', 'sam', 'som']) }),
1657
+ z.object({
1658
+ kind: z.enum(['growth', 'buying_behavior', 'category_dynamics']),
1659
+ claimId: z.string().regex(/^RCLM-m(?:0|[1-9]\d*)$/),
1660
+ sourceId: z.string().regex(/^RRCP-s(?:0|[1-9]\d*)$/).optional(),
1661
+ }),
1662
+ ]);
1194
1663
  /**
1195
1664
  * The market block a run established — the shape `scan_market` (FUL-479) surfaces to the caller
1196
1665
  * and the server promotes into the Research → Market dossier.
@@ -1201,28 +1670,28 @@ export const MarketSizingLineSchema = z
1201
1670
  * benefit, not an agent's. `extractMarket` does the flattening so this mirror describes what the
1202
1671
  * agent is actually being told, and every field here maps 1:1 onto a `project_market` column.
1203
1672
  *
1204
- * ⚠️ THESE FIGURES ARE UNGRADED ON THIS SURFACE, exactly as `get_market`'s are. Per-claim trust
1205
- * state is reconstructed app-side from the run's claim spine and does not cross the package
1206
- * boundary, so nothing here carries a `[GROUNDED]`-style bracket. Silence is not a clean bill of
1207
- * health, and the tool description says so beside the figures.
1673
+ * `marketSignals` below are producer selectors, not proof on their own. `extractMarket` resolves
1674
+ * them against exact claims, receipts, and derivations and exposes only survivors as
1675
+ * `resolvedSignals`. The other fields remain legacy recorded context and never inherit a
1676
+ * selector's authority.
1208
1677
  */
1209
1678
  export const MarketFindingsSchema = z
1210
1679
  .object({
1211
1680
  estimatedMarketSize: z
1212
1681
  .string()
1213
1682
  .optional()
1214
- .describe('The run\'s headline market SIZE, in the source\'s own words. Derived by the producer as ' +
1683
+ .describe('Legacy raw recorded market-size context, in the source\'s own words. Derived by the producer as ' +
1215
1684
  'the first claim that is a size (never a rate). Absent or empty when the run established ' +
1216
- 'no size — which is a result, not a zero.'),
1685
+ 'no size — which is a result, not a zero. Neutral unless emitted separately as a strict resolved signal.'),
1217
1686
  growthTrend: z
1218
1687
  .string()
1219
1688
  .optional()
1220
- .describe('"growing" | "stable" | "declining" | "unknown". "unknown" is a real finding: the sources disagreed or said nothing.'),
1221
- keyTrends: z.array(z.string()).optional().describe('Industry trends this run found moving the market.'),
1689
+ .describe('Raw reported direction: "growing" | "stable" | "declining" | "unknown". Neutral recorded context, never cited growth; "unknown" means the sources disagreed or said nothing.'),
1690
+ keyTrends: z.array(z.string()).optional().describe('Neutral legacy context: industry trends this run recorded as moving the market.'),
1222
1691
  claims: z
1223
1692
  .array(MarketClaimLineSchema)
1224
1693
  .optional()
1225
- .describe('Every atomic market figure the run recorded, one figure per entry, each with its own scope label where the source named one.'),
1694
+ .describe('Raw recorded atomic market figures, one per entry, with source-named scope where present. Neutral legacy context, not independently citable.'),
1226
1695
  marketSizing: z
1227
1696
  .array(z.unknown())
1228
1697
  .transform((items) => items.flatMap((item) => {
@@ -1230,11 +1699,19 @@ export const MarketFindingsSchema = z
1230
1699
  return parsed.success ? [parsed.data] : [];
1231
1700
  }))
1232
1701
  .optional()
1233
- .describe('Typed TAM/SAM/SOM entries. Malformed siblings are omitted; never infer a replacement.'),
1702
+ .describe('Typed TAM/SAM/SOM proof candidates. Malformed siblings are omitted; only entries selected and proven in resolvedSignals are citable.'),
1703
+ marketSignals: z
1704
+ .array(z.unknown())
1705
+ .transform((items) => items.slice(0, 3).flatMap((item) => {
1706
+ const parsed = MarketSignalReferenceSchema.safeParse(item);
1707
+ return parsed.success ? [parsed.data] : [];
1708
+ }))
1709
+ .optional()
1710
+ .describe('Zero-to-three ordered producer selectors, not independently citable. extractMarket emits a selector in resolvedSignals only after strict sizing proof or an exact grounded claim, non-empty contained quote span, and receipt. Empty resolvedSignals means no selector survived proof, not that the raw context or market is absent, small, or zero.'),
1234
1711
  positioningGaps: z
1235
1712
  .array(z.string())
1236
1713
  .optional()
1237
- .describe('Segments the category under-serves and needs available tools do not meet, as the run\'s sources describe them — market-side observations, not a competitor comparison.'),
1714
+ .describe('Neutral legacy context: segments the category under-serves and needs available tools do not meet, as recorded by the run; not independently citable or a competitor comparison.'),
1238
1715
  })
1239
1716
  .passthrough();
1240
1717
  /**
@@ -1245,6 +1722,14 @@ export const MarketFindingsSchema = z
1245
1722
  export const ForumThreadSchema = z
1246
1723
  .object({
1247
1724
  title: z.string(),
1725
+ theme: z
1726
+ .string()
1727
+ .trim()
1728
+ .max(48)
1729
+ .regex(/^[\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*(?: [\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*)?$/u)
1730
+ .optional()
1731
+ .catch(undefined)
1732
+ .describe('Producer-authored 1-2-word semantic theme. Distinct from title, which remains source metadata and the discussion link label.'),
1248
1733
  url: z.string().optional(),
1249
1734
  platform: z.string().optional(),
1250
1735
  /**
@@ -1319,5 +1804,16 @@ export const ForumThreadSchema = z
1319
1804
  .describe('Receipt children of this discussion, when it yielded several bounded excerpts. Each names ' +
1320
1805
  'one exact excerpt in `relevantQuotes`; absent means no rail minted per-excerpt ids for it.'),
1321
1806
  })
1322
- .passthrough();
1807
+ .passthrough()
1808
+ .overwrite((thread) => {
1809
+ if (!thread.theme)
1810
+ return thread;
1811
+ const theme = readSemanticTheme(thread.theme, thread.title);
1812
+ if (theme === null) {
1813
+ const withoutTheme = { ...thread };
1814
+ delete withoutTheme.theme;
1815
+ return withoutTheme;
1816
+ }
1817
+ return theme === thread.theme ? thread : { ...thread, theme };
1818
+ });
1323
1819
  //# sourceMappingURL=report.js.map