@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.
@@ -48,8 +48,12 @@
48
48
  */
49
49
  import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, containsSpan, hasVisibleContent, RECEIPT_QUOTE_MAX, } from './render-safety.js';
50
50
  import { canonicalContentType, contentTypeDeviationLine, contentTypeProvenanceLine, } from './content-type-display.js';
51
- import { resolveSourceReceipt } from './receipt-children.js';
52
- import { resolveSupportedMarketSizing } from './market-sizing-proof.js';
51
+ import { readFinalHypothesisState } from './hypothesis-final.js';
52
+ import { readRobustnessCounts } from '../hypothesis-semantics.js';
53
+ import { parseReceiptId, resolveSourceReceipt } from './receipt-children.js';
54
+ import { resolveMarketSignals } from './market-signals.js';
55
+ import { MCP_OVERVIEW_REF_CAP, MCP_OVERVIEW_SPAN_CAP, REPORT_PORTRAIT_PATHS, sanitizeMcpMarkdownTitle, sanitizeMcpReportTitle, } from '../types/report.js';
56
+ import { readSemanticTheme } from '../types/semantic-theme.js';
53
57
  /**
54
58
  * Max claims rendered per spine. Above this the digest states how many were
55
59
  * dropped and where the full list lives — never a silent cut. Sized so a typical
@@ -185,6 +189,8 @@ const UNSOURCED_LABEL = 'unsourced';
185
189
  const HYPOTHESIS_LABEL = 'hypothesis';
186
190
  const SCOPE_CAVEAT_LABEL = '⚠️ scope caveat — persona support from OUTSIDE its credibility domain; directional, NOT grounded';
187
191
  const EVIDENCE_READING_LABEL = 'rests on';
192
+ /** FUL-358 — the reliability continuation line's label. */
193
+ const RELIABILITY_LABEL = 'confidence in this verdict';
188
194
  /**
189
195
  * The marker on a receipt-CHILD line, and the pool sentence that explains it (FUL-685 / T8).
190
196
  *
@@ -303,12 +309,18 @@ function capNote(shown, total, channel, path) {
303
309
  return '';
304
310
  return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
305
311
  }
312
+ /** Honest overflow note for overview entries omitted before their public allowlist is evaluated. */
313
+ function overviewCapNote(truncated, cap, noun) {
314
+ if (!truncated)
315
+ return '';
316
+ return `\n_(showing at most ${cap} stored ${noun} — additional entries were omitted before public allowlisting)_`;
317
+ }
306
318
  /** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
307
319
  function safeReceiptUrl(raw) {
308
- if (typeof raw !== 'string' || !raw.trim())
320
+ if (typeof raw !== 'string' || !/^https?:\/\//i.test(raw))
309
321
  return null;
310
322
  try {
311
- const url = new URL(raw.trim());
323
+ const url = new URL(raw);
312
324
  if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
313
325
  return null;
314
326
  }
@@ -318,6 +330,112 @@ function safeReceiptUrl(raw) {
318
330
  return null;
319
331
  }
320
332
  }
333
+ const OVERVIEW_ASSERTION_MAX = 320;
334
+ function overviewReceiptSupportsClaim(claim, receipt) {
335
+ if (claim.length > OVERVIEW_ASSERTION_MAX || receipt.length > OVERVIEW_ASSERTION_MAX)
336
+ return false;
337
+ const normalize = (value) => value
338
+ .normalize('NFC')
339
+ .replace(/\s+/g, ' ')
340
+ .trim();
341
+ const normalizedClaim = normalize(claim);
342
+ return normalizedClaim !== '' && normalizedClaim === normalize(receipt);
343
+ }
344
+ function resolveOverviewRef(ref, claimSpan, reportEvidence, forumThreads) {
345
+ const record = asRecord(ref);
346
+ const kind = str(record?.kind);
347
+ if (kind === 'report_evidence') {
348
+ const sourceId = str(record?.sourceId);
349
+ const match = sourceId ? /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId) : null;
350
+ if (!match)
351
+ return null;
352
+ const source = asRecord(reportEvidence[Number(match[1])]);
353
+ const href = safeReceiptUrl(source?.url);
354
+ const quote = str(source?.quote);
355
+ if (!source || !href || !quote || !overviewReceiptSupportsClaim(claimSpan, quote))
356
+ return null;
357
+ return `${sourceId} -> ${href}`;
358
+ }
359
+ if (kind === 'forum_receipt') {
360
+ const receiptId = safeId(record?.receiptId);
361
+ if (!receiptId || !parseReceiptId(receiptId))
362
+ return null;
363
+ const matches = forumThreads.flatMap((rawThread) => {
364
+ const thread = asRecord(rawThread);
365
+ return asArray(thread?.receipts).flatMap((rawReceipt) => {
366
+ const receipt = asRecord(rawReceipt);
367
+ const excerpt = str(receipt?.excerpt);
368
+ if (str(receipt?.receiptId) !== receiptId ||
369
+ !excerpt ||
370
+ !hasVisibleContent(excerpt) ||
371
+ !overviewReceiptSupportsClaim(claimSpan, excerpt))
372
+ return [];
373
+ const href = safeReceiptUrl(thread?.url);
374
+ return [`${receiptId}${href
375
+ ? ` -> ${href}`
376
+ : ` -> cached excerpt: "${safeInline(excerpt, OVERVIEW_ASSERTION_MAX) ?? '[unavailable]'}"`}`];
377
+ });
378
+ });
379
+ return matches.length === 1 ? (matches[0] ?? null) : null;
380
+ }
381
+ // A hypothesis is reasoning context, not an external receipt. Interview refs are private.
382
+ return null;
383
+ }
384
+ function renderOverviewTrust(data) {
385
+ const reportEvidence = asArray(data.reportEvidence);
386
+ const forum = asRecord(data.forumResearch);
387
+ const forumThreads = asArray(forum?.threads);
388
+ const sections = ['marketOverview', 'communityOverview'].flatMap((key) => {
389
+ const overview = asRecord(data[key]);
390
+ const overviewText = str(overview?.text);
391
+ if (!overview || !overviewText || !hasVisibleContent(overviewText))
392
+ return [];
393
+ const rawClaims = asArray(overview.claimSpans);
394
+ const claimsTruncated = overview.claimSpansTruncated === true || rawClaims.length > MCP_OVERVIEW_SPAN_CAP;
395
+ const ranges = rawClaims.slice(0, MCP_OVERVIEW_SPAN_CAP).flatMap((rawClaim, claimIndex) => {
396
+ const claim = asRecord(rawClaim);
397
+ const span = str(claim?.span);
398
+ if (!span)
399
+ return [];
400
+ const start = overviewText.indexOf(span);
401
+ if (start < 0 || overviewText.indexOf(span, start + 1) >= 0)
402
+ return [];
403
+ return [{ start, end: start + span.length, span, claim, claimIndex }];
404
+ }).sort((a, b) => a.start - b.start);
405
+ const lines = [];
406
+ let cursor = 0;
407
+ for (const range of ranges) {
408
+ if (range.start < cursor)
409
+ continue;
410
+ const uncovered = overviewText.slice(cursor, range.start);
411
+ if (/[\p{L}\p{N}]/u.test(uncovered))
412
+ lines.push(`- Unsourced: "${safeInline(uncovered)}"`);
413
+ if (str(range.claim?.claimType) === 'inference') {
414
+ lines.push(`- Inferred: "${safeInline(range.span)}"`);
415
+ cursor = range.end;
416
+ continue;
417
+ }
418
+ const rawRefs = asArray(range.claim?.evidenceRefs);
419
+ const refsTruncated = range.claim?.evidenceRefsTruncated === true || rawRefs.length > MCP_OVERVIEW_REF_CAP;
420
+ const refs = [...new Set(rawRefs.slice(0, MCP_OVERVIEW_REF_CAP)
421
+ .map((ref) => resolveOverviewRef(ref, range.span, reportEvidence, forumThreads))
422
+ .filter((ref) => Boolean(ref)))];
423
+ const line = refs.length > 0
424
+ ? `- Cited (${refs.length}): "${safeInline(range.span, OVERVIEW_ASSERTION_MAX)}" — ${refs.join('; ')}`
425
+ : `- Unsourced: "${safeInline(range.span)}"`;
426
+ lines.push(line + overviewCapNote(refsTruncated, MCP_OVERVIEW_REF_CAP, 'evidence-reference positions'));
427
+ cursor = range.end;
428
+ }
429
+ const trailing = overviewText.slice(cursor);
430
+ if (/[\p{L}\p{N}]/u.test(trailing))
431
+ lines.push(`- Unsourced: "${safeInline(trailing)}"`);
432
+ if (lines.length === 0)
433
+ return [];
434
+ const heading = key === 'marketOverview' ? 'Market overview trust' : 'Community overview trust';
435
+ return [`### ${heading}\n${lines.join('\n')}${overviewCapNote(claimsTruncated, MCP_OVERVIEW_SPAN_CAP, 'claim-span positions')}`];
436
+ });
437
+ return sections.join('\n\n');
438
+ }
321
439
  /**
322
440
  * Resolve the single, persona-owned presentation grant behind a GROUNDED claim.
323
441
  *
@@ -694,7 +812,26 @@ function renderReportSpine(reportData, channel) {
694
812
  const attestedNote = attested > 0 ? ` (of which ${attested} ${ATTESTED_LABEL})` : '';
695
813
  const shown = rows.slice(0, CLAIM_RENDER_CAP);
696
814
  const lines = shown.map(({ claim, receipt }) => renderReportClaim(claim, receipt));
697
- return (`### Report claim spine — ${tallyLine(tally, claims.length)}${attestedNote}\n` +
815
+ // FUL-358 — SAY WHICH POPULATION THE HEADLINE COUNTED.
816
+ //
817
+ // The prose below has always explained that a `summary` claim usually restates a `market` or
818
+ // `competitor` one and "must not be counted as a second independent finding" — while the
819
+ // headline directly above it counted exactly that. The report markdown in the SAME tool
820
+ // result scopes its own tally to the market+competitor spine and names it
821
+ // (`of N market & competitor claims`), so the two numbers arrived side by side looking like
822
+ // one of them was an arithmetic error. Both are defensible; only one said what it counted.
823
+ //
824
+ // The tally still covers every claim — this section is the audit surface, and a graded
825
+ // summary claim must be visible in it. What is added is the split, so a reader can reconcile
826
+ // the two headlines instead of choosing between them.
827
+ const summaryClaims = rows.filter(({ claim }) => asRecord(claim)?.section === 'summary').length;
828
+ const spineClaims = claims.length - summaryClaims;
829
+ const populationNote = `\nDENOMINATOR: ${claims.length} = ${spineClaims} market & competitor claim(s) + ` +
830
+ `${summaryClaims} executive-summary restatement(s). The report markdown above tallies the ` +
831
+ `${spineClaims} market & competitor claims ONLY, and names that scope; this section tallies ` +
832
+ 'all three sections because it is the audit surface. The two headlines are the same run ' +
833
+ 'counted over two populations, not a discrepancy.';
834
+ return (`### Report claim spine — ${tallyLine(tally, claims.length)}${attestedNote}${populationNote}\n` +
698
835
  'A SEPARATE pool from the persona spine — the two never cross. Sections are `market` (sizing/' +
699
836
  'trend figures), `competitor` (profile facts) and `summary` (an assertion quoted VERBATIM from ' +
700
837
  'the executive summary, graded against the same evidence — so a grounded `summary` claim ' +
@@ -989,8 +1126,14 @@ function renderPersonas(reportData, channel) {
989
1126
  const seniority = safeInline(priors?.seniority, 80);
990
1127
  const careerPath = safeInline(priors?.careerPath, 80);
991
1128
  const provenance = safeInline(persona?.identityProvenance, 200);
1129
+ const hasFrozenPortrait = REPORT_PORTRAIT_PATHS.includes(persona?.portrait);
992
1130
  const bits = [];
993
1131
  bits.push(`${plural(sources.length, 'receipt')}`);
1132
+ // State availability, not the path: a raw drift payload must never interpolate an arbitrary
1133
+ // remote URL or an identifier-bearing decoration into agent-visible report text.
1134
+ bits.push(hasFrozenPortrait
1135
+ ? 'frozen portrait decoration available'
1136
+ : 'initials fallback');
994
1137
  if (insufficient) {
995
1138
  bits.push(`⚠️ insufficientEvidence${sourcesFound !== null ? ` (only ${sourcesFound} real posts found)` : ''}`);
996
1139
  }
@@ -1028,6 +1171,58 @@ function renderPersonas(reportData, channel) {
1028
1171
  return (`${header}\n${lines.join('\n')}` +
1029
1172
  capNote(shown.length, personas.length, channel, 'report_data.personas'));
1030
1173
  }
1174
+ /**
1175
+ * A literal full-report Community summary. Internal pain-point counts/examples stay available in
1176
+ * structured report_data; they are deliberately not promoted into a user-facing `Threads` column.
1177
+ */
1178
+ function renderCommunityPatterns(reportData, channel) {
1179
+ const threads = asArray(asRecord(reportData.forumResearch)?.threads).filter((rawThread) => safeInline(asRecord(rawThread)?.title, MODEL_FRAMING_TEXT_MAX) !== null);
1180
+ if (threads.length === 0)
1181
+ return '';
1182
+ const shown = threads.slice(0, LIST_RENDER_CAP);
1183
+ const lines = shown.map((rawThread) => {
1184
+ const thread = asRecord(rawThread);
1185
+ const sentiment = safeInline(thread?.sentimentSummary, MODEL_FRAMING_TEXT_MAX);
1186
+ const title = safeInline(thread?.title, MODEL_FRAMING_TEXT_MAX) ?? 'Untitled discussion';
1187
+ const theme = readSemanticTheme(thread?.theme, title);
1188
+ const href = safeReceiptUrl(thread?.url);
1189
+ return [
1190
+ `- Theme: ${theme ?? 'not recorded'}`,
1191
+ ` Sentiment: ${sentiment ?? 'not recorded'}`,
1192
+ ` Discussion: ${title}${href ? ` — ${href}` : ''}`,
1193
+ ].join('\n');
1194
+ });
1195
+ return (`### Community patterns — ${plural(threads.length, 'discussion')}\n` +
1196
+ 'Themes are producer-authored semantic labels; Discussion preserves the original source title.\n' +
1197
+ lines.join('\n') +
1198
+ capNote(shown.length, threads.length, channel, 'report_data.forumResearch.threads'));
1199
+ }
1200
+ /**
1201
+ * Current agent Markdown already owns the literal Community rows. Detect only its code-owned,
1202
+ * three-line row grammar so full-report tools do not append the same evidence a second time.
1203
+ * Legacy Markdown lacks this grammar and still receives the compatibility projection above.
1204
+ */
1205
+ function hasCurrentCommunityPatternRows(reportMarkdown, reportData) {
1206
+ const expectedRows = asArray(asRecord(reportData.forumResearch)?.threads).filter((rawThread) => safeInline(asRecord(rawThread)?.title, MODEL_FRAMING_TEXT_MAX) !== null).length;
1207
+ if (expectedRows === 0)
1208
+ return false;
1209
+ const lines = reportMarkdown.split('\n');
1210
+ const heading = lines.indexOf('### Relevant Discussions');
1211
+ if (heading < 0)
1212
+ return false;
1213
+ const nextSection = lines.findIndex((line, index) => index > heading && /^#{2,3}\s/u.test(line));
1214
+ const end = nextSection < 0 ? lines.length : nextSection;
1215
+ let foundRows = 0;
1216
+ for (let index = heading + 1; index + 2 < end; index += 1) {
1217
+ if (lines[index]?.startsWith('- **Theme:** ') &&
1218
+ lines[index + 1]?.startsWith(' - **Sentiment:** ') &&
1219
+ lines[index + 2]?.startsWith(' - **Discussion:** ')) {
1220
+ foundRows += 1;
1221
+ index += 2;
1222
+ }
1223
+ }
1224
+ return foundRows === expectedRows;
1225
+ }
1031
1226
  /**
1032
1227
  * The anti-sycophancy readout — deterministically computed from the interviews,
1033
1228
  * not model-opined, which is exactly why it belongs in the text: it is the one
@@ -1127,18 +1322,61 @@ function renderSycophancy(reportData, channel) {
1127
1322
  * FUL-263 — before anything rendered them, which is that file's whole premise — so its
1128
1323
  * assertions bit on this change with no fixture edit.
1129
1324
  */
1130
- function hypothesisDetail(result) {
1325
+ function hypothesisDetail(result, includeScopeCaveat = true) {
1131
1326
  const statement = safeInline(result?.statement, HYPOTHESIS_STATEMENT_MAX);
1132
1327
  const evidenceReading = safeInline(result?.evidenceReading, HYPOTHESIS_NOTE_MAX);
1133
1328
  return ((statement ? `\n ${HYPOTHESIS_LABEL}: "${statement}"` : '') +
1134
- scopeCaveatDetail(result) +
1329
+ (includeScopeCaveat ? scopeCaveatDetail(result) : '') +
1330
+ reliabilityDetail(result) +
1135
1331
  (evidenceReading ? `\n ${EVIDENCE_READING_LABEL}: ${evidenceReading}` : ''));
1136
1332
  }
1333
+ /**
1334
+ * The FUL-358 reliability line — how much to trust this verdict, and how much evidence is
1335
+ * behind it.
1336
+ *
1337
+ * ## Why this section renders a reliability at all, having settled not to render `confidence`
1338
+ *
1339
+ * FUL-614 deliberately withheld the numeric `confidence`: it was model-assigned, and printing
1340
+ * it beside the code-derived survival count invited a reader to average two things that are not
1341
+ * comparable. That reasoning holds and this does not contradict it — `final.confidence` is
1342
+ * itself CODE-DERIVED, from the evidence the row carries and from what these very re-asks did.
1343
+ * It is the same KIND of value as the survival count beside it, which is exactly what the
1344
+ * withheld number was not. The retired numeric field stays withheld.
1345
+ *
1346
+ * Values come from a closed enum, so they cannot forge a line and need no `safeInline`; an
1347
+ * unrecognised value emits nothing rather than being quoted through. A legacy row has no block
1348
+ * and emits nothing — its numeric `confidence` is a different measure and is not restated here.
1349
+ */
1350
+ function reliabilityDetail(result) {
1351
+ const final = readFinalHypothesisState(result);
1352
+ if (!final.fromContract)
1353
+ return '';
1354
+ const { confidence, sufficiency, confidenceBasis: basis } = final;
1355
+ const reliability = confidence
1356
+ ? `\`${confidence}\``
1357
+ : basis === 'withheld_no_evidence'
1358
+ ? 'WITHHELD — the run attached no evidence to this hypothesis'
1359
+ : null;
1360
+ if (!reliability && !sufficiency)
1361
+ return '';
1362
+ const parts = [
1363
+ reliability ? `${RELIABILITY_LABEL}: ${reliability}` : null,
1364
+ sufficiency ? `evidence ${sufficiency}` : null,
1365
+ ].filter(Boolean);
1366
+ return `\n ${parts.join('; ')} (reliability of the verdict, NOT the probability the hypothesis is true)`;
1367
+ }
1137
1368
  /** The exact quoted/labelled FUL-614 shape, shared by robust and caveat-only rows. */
1138
1369
  function scopeCaveatDetail(result) {
1139
1370
  const scopeCaveat = safeInline(result?.scopeCaveat, HYPOTHESIS_NOTE_MAX);
1140
1371
  return scopeCaveat ? `\n ${SCOPE_CAVEAT_LABEL}: "${scopeCaveat}"` : '';
1141
1372
  }
1373
+ /** A present, non-null value records an attempted re-test even when its shape is unreadable. */
1374
+ function hasRawRobustnessAttempt(result) {
1375
+ return (result !== null &&
1376
+ Object.prototype.hasOwnProperty.call(result, 'robustness') &&
1377
+ result.robustness !== null &&
1378
+ result.robustness !== undefined);
1379
+ }
1142
1380
  /**
1143
1381
  * Scope caveats for hypotheses that do not have a robustness row (FUL-626).
1144
1382
  *
@@ -1157,7 +1395,7 @@ function scopeCaveatDetail(result) {
1157
1395
  function renderUnretestedScopeCaveats(reportData) {
1158
1396
  const lines = asArray(reportData.hypothesisResults).flatMap((raw) => {
1159
1397
  const result = asRecord(raw);
1160
- if (!result || asRecord(result.robustness) !== null)
1398
+ if (!result || hasRawRobustnessAttempt(result))
1161
1399
  return [];
1162
1400
  const detail = scopeCaveatDetail(result);
1163
1401
  if (!detail)
@@ -1340,13 +1578,16 @@ function renderRobustness(reportData, channel) {
1340
1578
  const results = asArray(reportData.hypothesisResults);
1341
1579
  if (results.length === 0)
1342
1580
  return '';
1343
- const withRobustness = results.filter((raw) => asRecord(asRecord(raw)?.robustness) !== null);
1344
- if (withRobustness.length === 0) {
1581
+ const renderable = results.filter((raw) => {
1582
+ const result = asRecord(raw);
1583
+ return hasRawRobustnessAttempt(result) || readFinalHypothesisState(result).fromContract;
1584
+ });
1585
+ if (renderable.length === 0) {
1345
1586
  return ('### Hypothesis robustness — NOT RE-TESTED on this run\n' +
1346
1587
  'No verdict was re-asked under a reworded framing, so no verdict above carries a survival ' +
1347
1588
  'count. Treat each as a single-shot answer.');
1348
1589
  }
1349
- const shown = withRobustness.slice(0, LIST_RENDER_CAP);
1590
+ const shown = renderable.slice(0, LIST_RENDER_CAP);
1350
1591
  const lines = shown.map((raw) => {
1351
1592
  const result = asRecord(raw);
1352
1593
  // FUL-253: a `- ` row whose whole point is the ⚠️ FLIPPED warning. A newline
@@ -1356,43 +1597,62 @@ function renderRobustness(reportData, channel) {
1356
1597
  const id = renderId(result?.hypothesisId);
1357
1598
  const status = safeInline(result?.status, 40) ?? 'unknown';
1358
1599
  const robustness = asRecord(result?.robustness);
1359
- const survived = num(robustness?.survived);
1360
- const total = num(robustness?.total);
1361
- const flipped = tribool(robustness?.flipped);
1362
- const downgraded = safeInline(robustness?.downgradedStatus, 40);
1363
- const count = survived !== null && total !== null ? `held ${survived}/${total} framings` : 'survival count unavailable';
1600
+ const counts = readRobustnessCounts(robustness);
1601
+ const reading = readFinalHypothesisState(result);
1602
+ const outcome = reading.robustness;
1603
+ const effectiveVerdict = reading.verdict ?? status;
1604
+ const count = counts ? `held ${counts.survived}/${counts.total} framings` : 'survival count unavailable';
1364
1605
  // Appended to EVERY branch, not just the flipped one. A held verdict is the row a
1365
1606
  // reader is most likely to act on unexamined, so it is the last one that should be
1366
1607
  // identified by an id alone.
1367
- const detail = hypothesisDetail(result);
1368
- if (flipped === true) {
1608
+ // A single-shot row's caveat stays in the dedicated un-retested-caveats block below rather
1609
+ // than being duplicated here. Its statement, reliability and evidence reading still belong
1610
+ // on this final-state row.
1611
+ const detail = hypothesisDetail(result, outcome !== 'not_retested');
1612
+ if (outcome === 'not_retested') {
1613
+ return `- ${id}: final verdict \`${effectiveVerdict}\` — NOT RE-TESTED under rephrasing; treat this verdict as a single-shot answer.${detail}`;
1614
+ }
1615
+ if (outcome === 'flipped') {
1616
+ const verdictInstruction = effectiveVerdict === status
1617
+ ? `The final verdict remains \`${effectiveVerdict}\`; the flip lowers its reliability.`
1618
+ : `The verdict to act on is \`${effectiveVerdict}\`, NOT \`${status}\`.`;
1369
1619
  return (`- ${id}: reported \`${status}\` — ⚠️ FLIPPED under rephrasing (${count}). ` +
1370
- `The verdict to act on is \`${downgraded ?? 'weaker than reported — downgrade unavailable'}\`, NOT \`${status}\`.` +
1620
+ verdictInstruction +
1371
1621
  detail);
1372
1622
  }
1373
1623
  // An unreadable `flipped` is NOT "held" — say so rather than printing the
1374
1624
  // verdict as if it had survived re-asking.
1375
- if (flipped === null) {
1376
- return `- ${id}: reported \`${status}\` — ⚠️ flip status UNREADABLE (${count}); treat this verdict as un-retested.${detail}`;
1625
+ if (outcome === null) {
1626
+ const weakerVerdict = effectiveVerdict !== status
1627
+ ? ` The verdict to act on is \`${effectiveVerdict}\`, NOT \`${status}\`.`
1628
+ : '';
1629
+ return `- ${id}: reported \`${status}\` — ⚠️ flip status UNREADABLE (${count}); the robustness outcome could not be read safely, so do not rely on it.${weakerVerdict}${detail}`;
1377
1630
  }
1378
- return `- ${id}: \`${status}\` — ${count}${detail}`;
1631
+ return `- ${id}: \`${effectiveVerdict}\` — ${count}${detail}`;
1379
1632
  });
1380
- const flippedCount = withRobustness.filter((raw) => bool(asRecord(asRecord(raw)?.robustness)?.flipped)).length;
1381
- const unreadableFlips = withRobustness.filter((raw) => tribool(asRecord(asRecord(raw)?.robustness)?.flipped) === null).length;
1633
+ const outcomes = results.map((raw) => {
1634
+ const result = asRecord(raw);
1635
+ return readFinalHypothesisState(result).robustness;
1636
+ });
1637
+ const flippedCount = outcomes.filter((outcome) => outcome === 'flipped').length;
1638
+ const unreadableFlips = outcomes.filter((outcome) => outcome === null).length;
1382
1639
  // The un-retested hypotheses are counted against the FULL result set, not just
1383
1640
  // the re-tested subset: a bare "no verdict flipped" over a partially-tested run
1384
1641
  // reads as "every verdict survived", when most may never have been re-asked.
1385
- const untested = results.length - withRobustness.length;
1642
+ const untested = outcomes.filter((outcome) => outcome === 'not_retested').length;
1643
+ const retested = results.length - untested;
1386
1644
  const untestedNote = untested > 0
1387
- ? ` (${withRobustness.length}/${results.length} verdicts re-tested; the other ${untested} were NOT re-asked — treat those as single-shot)`
1645
+ ? ` (${retested}/${results.length} verdicts re-tested; the other ${untested} were NOT re-asked — treat those as single-shot)`
1388
1646
  : '';
1389
- const header = flippedCount > 0
1390
- ? `### Hypothesis robustness — ⚠️ ${flippedCount} verdict(s) flipped under rephrasing${untestedNote}`
1391
- : unreadableFlips > 0
1392
- ? `### Hypothesis robustness — ⚠️ flip status UNREADABLE for ${unreadableFlips} verdict(s) (not a clean result)${untestedNote}`
1393
- : `### Hypothesis robustness — no re-tested verdict flipped${untestedNote}`;
1647
+ const header = untested === results.length
1648
+ ? '### Hypothesis robustness — NOT RE-TESTED on this run'
1649
+ : flippedCount > 0
1650
+ ? `### Hypothesis robustness — ⚠️ ${flippedCount} verdict(s) flipped under rephrasing${untestedNote}`
1651
+ : unreadableFlips > 0
1652
+ ? `### Hypothesis robustness — ⚠️ flip status UNREADABLE for ${unreadableFlips} verdict(s) (not a clean result)${untestedNote}`
1653
+ : `### Hypothesis robustness — no re-tested verdict flipped${untestedNote}`;
1394
1654
  return (`${header}\n${lines.join('\n')}` +
1395
- capNote(shown.length, withRobustness.length, channel, 'report_data.hypothesisResults'));
1655
+ capNote(shown.length, renderable.length, channel, 'report_data.hypothesisResults'));
1396
1656
  }
1397
1657
  // ---------------------------------------------------------------------------
1398
1658
  // Model-authored framing — prose side of the proof separator (FUL-570)
@@ -1505,6 +1765,109 @@ function renderInsightCrossReferences(reportData) {
1505
1765
  return ('## Insight source cross-references — model-authored findings, code-resolved destinations\n\n' +
1506
1766
  rows.join('\n'));
1507
1767
  }
1768
+ function resolveCrossCuttingEvidence(reportData, ref) {
1769
+ if (ref.kind === 'report_evidence') {
1770
+ const sourceId = safeId(ref.sourceId);
1771
+ const match = sourceId ? /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId) : null;
1772
+ const source = match ? asRecord(asArray(reportData.reportEvidence)[Number(match[1])]) : null;
1773
+ if (!source)
1774
+ return null;
1775
+ const label = safeInline(source.platform, MODEL_FRAMING_TEXT_MAX) || 'Web source';
1776
+ const href = safeReceiptUrl(source.url);
1777
+ const quote = safeInline(source.quote, RECEIPT_QUOTE_MAX);
1778
+ if (!href)
1779
+ return null;
1780
+ return `${label}${href ? ` — ${href}` : ''}${quote ? `\n > ${quote}` : ''}`;
1781
+ }
1782
+ if (ref.kind === 'forum_receipt') {
1783
+ const receiptId = safeId(ref.receiptId);
1784
+ if (!receiptId)
1785
+ return null;
1786
+ const matches = asArray(asRecord(reportData.forumResearch)?.threads).flatMap((threadRaw) => {
1787
+ const thread = asRecord(threadRaw);
1788
+ return asArray(thread?.receipts).flatMap((receiptRaw) => {
1789
+ const receipt = asRecord(receiptRaw);
1790
+ return safeId(receipt?.receiptId) === receiptId ? [{ thread, receipt }] : [];
1791
+ });
1792
+ });
1793
+ if (matches.length !== 1)
1794
+ return null;
1795
+ const match = matches[0];
1796
+ const label = safeInline(match.thread?.platform, MODEL_FRAMING_TEXT_MAX) || 'Community source';
1797
+ const href = safeReceiptUrl(match.thread?.url);
1798
+ const quote = safeInline(match.receipt?.excerpt, RECEIPT_QUOTE_MAX);
1799
+ if (!quote)
1800
+ return null;
1801
+ return `${label}${href ? ` — ${href}` : ''}${quote ? `\n > ${quote}` : ''}`;
1802
+ }
1803
+ if (ref.kind === 'interview_quote_v2') {
1804
+ const interviewId = safeId(ref.interviewId);
1805
+ const quoteId = safeId(ref.quoteId);
1806
+ if (!interviewId || !quoteId)
1807
+ return null;
1808
+ const matches = asArray(reportData.interviewHighlights).flatMap((highlightRaw) => {
1809
+ const highlight = asRecord(highlightRaw);
1810
+ const evidence = asRecord(highlight?.evidenceReference);
1811
+ if (safeId(evidence?.interviewId) !== interviewId)
1812
+ return [];
1813
+ return asArray(evidence?.quotes).flatMap((quoteRaw) => {
1814
+ const quote = asRecord(quoteRaw);
1815
+ return safeId(quote?.quoteId) === quoteId ? [{ highlight, quote }] : [];
1816
+ });
1817
+ });
1818
+ if (matches.length !== 1)
1819
+ return null;
1820
+ const match = matches[0];
1821
+ const persona = safeInline(match.highlight?.personaName, MODEL_FRAMING_TEXT_MAX) || 'Persona';
1822
+ const quote = safeInline(match.quote?.text, RECEIPT_QUOTE_MAX);
1823
+ return quote ? `Synthetic interview · ${persona}\n > ${quote}` : null;
1824
+ }
1825
+ return null;
1826
+ }
1827
+ function renderCrossCuttingFindings(reportData) {
1828
+ if (!Object.prototype.hasOwnProperty.call(reportData, 'crossCuttingFindings'))
1829
+ return '';
1830
+ const rows = asArray(reportData.crossCuttingFindings).slice(0, 5).flatMap((rawFinding) => {
1831
+ const finding = asRecord(rawFinding);
1832
+ const claim = safeInline(finding?.claim, MODEL_FRAMING_TEXT_MAX);
1833
+ const why = safeInline(finding?.whyItMatters, MODEL_FRAMING_TEXT_MAX);
1834
+ const confidence = safeInline(finding?.confidence, 12);
1835
+ if (!claim || !why || !confidence || !['low', 'medium', 'high'].includes(confidence))
1836
+ return [];
1837
+ const rawRefs = asArray(finding?.evidenceRefs).slice(0, 8);
1838
+ const resolvedRefs = rawRefs.flatMap((rawRef) => {
1839
+ const ref = asRecord(rawRef);
1840
+ const rendered = ref ? resolveCrossCuttingEvidence(reportData, ref) : null;
1841
+ return ref && rendered ? [{ ref, rendered }] : [];
1842
+ });
1843
+ const methods = new Set(resolvedRefs.flatMap(({ ref }) => ref.kind === 'report_evidence' ? ['web']
1844
+ : ref.kind === 'forum_receipt' ? ['forum']
1845
+ : ref.kind === 'interview_quote_v2' ? ['interview'] : []));
1846
+ const uniqueRefs = new Set(resolvedRefs.map(({ ref }) => JSON.stringify(ref)));
1847
+ if (rawRefs.length < 2 || resolvedRefs.length !== rawRefs.length || uniqueRefs.size < 2 || methods.size < 2)
1848
+ return [];
1849
+ const evidence = resolvedRefs.map(({ rendered }) => ` - Evidence: ${rendered}`);
1850
+ return [[
1851
+ `- ${claim}`,
1852
+ ` - Why it matters: ${why}`,
1853
+ ` - Confidence: ${confidence}`,
1854
+ ...evidence,
1855
+ ].join('\n')];
1856
+ });
1857
+ return rows.length > 0
1858
+ ? `## Cross-cutting findings\n\n${rows.join('\n')}`
1859
+ : '## Cross-cutting findings\n\nNo cross-cutting finding met the evidence bar.';
1860
+ }
1861
+ function removeLegacyInsightOutput(markdown) {
1862
+ return markdown
1863
+ .replace(/^## (?:Key )?Insights(?: \(legacy\))?\s*\n[\s\S]*?(?=^## |$(?![\s\S]))/gm, '')
1864
+ .replace(/^## Cross-cutting findings\s*\n[\s\S]*?(?=^## |$(?![\s\S]))/gm, '')
1865
+ .replace(/\n{3,}/g, '\n\n')
1866
+ .trimEnd();
1867
+ }
1868
+ function labelLegacyInsightOutput(markdown) {
1869
+ return markdown.replace(/^## (?:Key )?Insights$/gm, '## Insights (legacy)');
1870
+ }
1508
1871
  /**
1509
1872
  * The approved model-authored context block.
1510
1873
  *
@@ -1667,48 +2030,26 @@ function renderMarketSizing(reportData) {
1667
2030
  const market = asRecord(asRecord(reportData.webResearch)?.marketData);
1668
2031
  if (!market)
1669
2032
  return '';
1670
- const raw = asArray(market.marketSizing);
1671
2033
  const reportClaims = asArray(reportData.reportClaims).map(asRecord).filter((v) => v !== null);
1672
2034
  const reportEvidence = asArray(reportData.reportEvidence);
1673
- const supported = resolveSupportedMarketSizing(raw, reportClaims, reportEvidence);
1674
- const labels = { tam: 'TAM', sam: 'SAM', som: 'SOM' };
1675
- const scopes = ['tam', 'sam', 'som'];
1676
- const lines = scopes.map((scope) => {
1677
- const contenders = supported.map(asRecord).filter((entry) => entry?.scope === scope);
1678
- if (contenders.length !== 1)
1679
- return `- ${labels[scope]}: — (Not established)`;
1680
- const entry = contenders[0];
1681
- const value = num(entry.value);
1682
- const currency = safeInline(entry.currency, 3);
1683
- const period = safeInline(entry.period, 60);
1684
- const geography = safeInline(entry.geography, 120);
1685
- const audience = safeInline(entry.audience, 160);
1686
- if (!value || !currency?.match(/^[A-Z]{3}$/) || !period || !geography || !audience) {
1687
- return `- ${labels[scope]}: — (Not established)`;
1688
- }
1689
- if (entry.evidenceState === 'source_stated') {
1690
- return `- ${labels[scope]}: ${currency} ${value} [Cited] — ${geography}; ${audience}; ${period}`;
1691
- }
1692
- const derivation = asRecord(entry.derivation);
1693
- const inputs = asArray(derivation?.inputs).map(asRecord);
1694
- const formula = safeInline(derivation?.formula, 240);
1695
- if (entry.evidenceState !== 'derived' || derivation?.operation !== 'multiply' || !formula || inputs.length < 2 || inputs.some((input) => !input)) {
1696
- return `- ${labels[scope]}: — (Not established)`;
1697
- }
1698
- const validInputs = inputs.filter((input) => input !== null);
1699
- const sameBoundary = validInputs.every((input) => input.kind === 'ratio_assumption' || (input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience));
1700
- const inputValues = validInputs.map((input) => num(input.value));
1701
- const oneCurrency = validInputs.filter((input) => input.kind === 'currency' && input.currency === currency).length === 1;
1702
- const product = inputValues.every((input) => input !== null)
1703
- ? inputValues.reduce((total, input) => total * input, 1)
1704
- : Number.NaN;
1705
- if (!sameBoundary || !oneCurrency || !Number.isFinite(product) || Math.abs(product - value) > Math.max(0.01, value * 1e-9)) {
1706
- return `- ${labels[scope]}: — (Not established)`;
1707
- }
1708
- const assumptions = asArray(derivation.assumptions).map((item) => safeInline(item, 160)).filter(Boolean);
1709
- return `- ${labels[scope]}: ${currency} ${value} [Estimate / Inferred] — ${geography}; ${audience}; ${period}; method: ${formula}${assumptions.length ? `; assumptions: ${assumptions.join('; ')}` : ''}`;
2035
+ const signals = resolveMarketSignals(market.marketSignals, asArray(market.marketSizing), reportClaims, reportEvidence);
2036
+ if (signals.length === 0)
2037
+ return '### Market signals\nNo cited market signals were established.';
2038
+ const lines = signals.map((signal) => {
2039
+ const context = safeInline(signal.context, 360) ?? 'Context unavailable';
2040
+ const sources = (signal.sources ?? (signal.source ? [signal.source] : []))
2041
+ .map((source) => safeInline(source.url, 240))
2042
+ .filter((url) => Boolean(url));
2043
+ const citations = sources.length > 0 ? ` ← ${sources.join(', ')}` : '';
2044
+ if (signal.kind !== 'size')
2045
+ return `- ${signal.label}: ${context} [Cited]${citations}`;
2046
+ const formula = safeInline(signal.formula, 240);
2047
+ const assumptions = (signal.assumptions ?? []).map((item) => safeInline(item, 160)).filter(Boolean);
2048
+ return `- ${signal.label}: ${signal.currency} ${signal.value} — ${context}` +
2049
+ (formula ? ` [Estimate / Inferred]; method: ${formula}${assumptions.length ? `; assumptions: ${assumptions.join('; ')}` : ''}` : ' [Cited]') +
2050
+ citations;
1710
2051
  });
1711
- return `### TAM / SAM / SOM — typed sizing\n${lines.join('\n')}`;
2052
+ return `### Market signals\n${lines.join('\n')}`;
1712
2053
  }
1713
2054
  /**
1714
2055
  * The Reddit community-evidence rail's typed outcome (FUL-685 / T8, D6).
@@ -1923,6 +2264,7 @@ export function buildTrustDigest(reportData, channel) {
1923
2264
  const sections = [
1924
2265
  renderPersonaSpine(data, channel),
1925
2266
  renderReportSpine(data, channel),
2267
+ renderOverviewTrust(data),
1926
2268
  renderMarketSizing(data),
1927
2269
  renderCompetitorSourceQuality(data),
1928
2270
  // FUL-685: immediately above the pool the accepted rows land in, so a reader meets the
@@ -2015,11 +2357,67 @@ export function composeReportText(reportMarkdown, reportData, channel) {
2015
2357
  const rationale = renderVerdictRationale(reportData);
2016
2358
  const framing = renderModelFraming(reportData);
2017
2359
  const data = asRecord(reportData);
2018
- const insightReferences = data ? renderInsightCrossReferences(data) : '';
2360
+ const markdownTitle = reportMarkdown.startsWith('# ')
2361
+ ? reportMarkdown.slice(2, reportMarkdown.indexOf('\n') >= 0 ? reportMarkdown.indexOf('\n') : undefined)
2362
+ : undefined;
2363
+ const safeTitle = data?.reportTitle === undefined
2364
+ ? normalizeLegacyMcpHeading(markdownTitle)
2365
+ : escapeMcpMarkdownHeading(sanitizeMcpReportTitle(data.reportTitle));
2366
+ const titledReportMarkdown = reportMarkdown.startsWith('# ')
2367
+ ? `# ${safeTitle}${reportMarkdown.includes('\n') ? reportMarkdown.slice(reportMarkdown.indexOf('\n')) : ''}`
2368
+ : reportMarkdown;
2369
+ const hasCrossCutting = Boolean(data && Object.prototype.hasOwnProperty.call(data, 'crossCuttingFindings'));
2370
+ const safeReportMarkdown = hasCrossCutting
2371
+ ? removeLegacyInsightOutput(titledReportMarkdown)
2372
+ : labelLegacyInsightOutput(titledReportMarkdown);
2373
+ const insightReferences = data && !hasCrossCutting ? renderInsightCrossReferences(data) : '';
2374
+ const crossCutting = data && hasCrossCutting ? renderCrossCuttingFindings(data) : '';
2375
+ // Community themes, sentiment, and source titles are producer/source-authored framing, not
2376
+ // machine-checked claims. Keep them above the authority separator while preserving the literal
2377
+ // Theme → Sentiment → Discussion contract for every full-report caller.
2378
+ const communityPatterns = data && !hasCurrentCommunityPatternRows(safeReportMarkdown, data)
2379
+ ? renderCommunityPatterns(data, channel)
2380
+ : '';
2019
2381
  // FUL-586: the completeness banner goes FIRST — before the prose it is a caveat about.
2020
2382
  const gaps = renderReportGaps(reportData);
2021
2383
  return (`${gaps ? `${gaps}\n\n---\n\n` : ''}` +
2022
- `${reportMarkdown}${insightReferences ? `\n\n${insightReferences}` : ''}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
2384
+ `${safeReportMarkdown}${insightReferences ? `\n\n${insightReferences}` : ''}${crossCutting ? `\n\n${crossCutting}` : ''}${communityPatterns ? `\n\n${communityPatterns}` : ''}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
2023
2385
  `\n\n---\n\n${buildTrustDigest(reportData, channel)}`);
2024
2386
  }
2387
+ /** Keep a model-authored structured title inert when replacing the report Markdown H1. */
2388
+ function escapeMcpMarkdownHeading(value) {
2389
+ if (/(?:\b[a-z][a-z0-9+.-]{1,31}:\/\/|\bwww\.|[^\s@]+@[^\s@]+)/i.test(value)) {
2390
+ const longestRun = (value.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0);
2391
+ const fence = '`'.repeat(longestRun + 1);
2392
+ const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
2393
+ return `${fence}${pad}${value}${pad}${fence}`;
2394
+ }
2395
+ return value
2396
+ .replace(/[\\`*_[\]~&]/g, '\\$&')
2397
+ .replace(/<(?=[a-zA-Z/?!])/g, '\\<')
2398
+ .replace(/#+$/, (run) => run.replace(/#/g, '\\#'));
2399
+ }
2400
+ /** Normalize producer-escaped legacy H1 text before applying the current inert-heading rules. */
2401
+ function normalizeLegacyMcpHeading(value) {
2402
+ if (typeof value !== 'string')
2403
+ return 'Report';
2404
+ const serialized = value
2405
+ .replace(/[\p{Cc}\p{Cf}]/gu, ' ')
2406
+ .replace(/\s+/g, ' ')
2407
+ .trim();
2408
+ if (!serialized)
2409
+ return 'Report';
2410
+ const openingFence = /^`+/.exec(serialized)?.[0];
2411
+ const closingFence = /`+$/.exec(serialized)?.[0];
2412
+ if (openingFence && closingFence?.length === openingFence.length && serialized.length >= openingFence.length * 2) {
2413
+ const inner = serialized.slice(openingFence.length, -openingFence.length);
2414
+ const innerRuns = inner.match(/`+/g) ?? [];
2415
+ if (innerRuns.every((run) => run.length < openingFence.length)) {
2416
+ const visible = /^ .* $/.test(inner) && /\S/.test(inner) ? inner.slice(1, -1) : inner;
2417
+ return sanitizeMcpMarkdownTitle(visible) === visible ? serialized : 'Report';
2418
+ }
2419
+ }
2420
+ const plain = serialized.replace(/\\([\\`*_[\]~&<#])/g, '$1');
2421
+ return escapeMcpMarkdownHeading(sanitizeMcpMarkdownTitle(plain));
2422
+ }
2025
2423
  //# sourceMappingURL=report-digest.js.map