@clien-ai/mcp 0.10.2 → 0.10.4

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.
@@ -217,23 +217,6 @@ function renderId(raw, absent = '(no id)') {
217
217
  return absent;
218
218
  return safeId(raw) ?? '(unrenderable id)';
219
219
  }
220
- /**
221
- * What a GROUNDED claim says when its receipt pointer did not render.
222
- *
223
- * The same absent-vs-unrenderable split as `renderId`, and here it changes what
224
- * the reader should DO. "no receipt id — unresolvable" tells an agent the claim
225
- * is grounded in something the report failed to record, which is a defect in the
226
- * run. A pointer that exists but is unrenderable is a defect in the POINTER: the
227
- * receipt is in `structuredContent` / `_meta` and can still be resolved there.
228
- * Printing the first for the second sends the agent to re-run research it does
229
- * not need.
230
- */
231
- function groundedWithoutReceipt(rawSourceId) {
232
- const absent = rawSourceId === undefined || rawSourceId === null || String(rawSourceId).trim() === '';
233
- return absent
234
- ? ' · GROUNDED but no receipt id — unresolvable'
235
- : ' · GROUNDED, receipt id present but unrenderable — resolve it from the raw report data';
236
- }
237
220
  function num(value) {
238
221
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
239
222
  }
@@ -273,12 +256,61 @@ function capNote(shown, total, channel, path) {
273
256
  return '';
274
257
  return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
275
258
  }
276
- function tallyStates(claims) {
259
+ /** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
260
+ function safeReceiptUrl(raw) {
261
+ if (typeof raw !== 'string' || !raw.trim())
262
+ return null;
263
+ try {
264
+ const url = new URL(raw.trim());
265
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
266
+ return null;
267
+ }
268
+ return url.href;
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ /** Resolve the single, persona-owned presentation grant behind a GROUNDED claim. */
275
+ function resolvePersonaReceipt(rawClaim, personas) {
276
+ const claim = asRecord(rawClaim);
277
+ if (str(claim?.state) !== 'GROUNDED')
278
+ return null;
279
+ const personaIndex = personas.findIndex((_, index) => str(claim?.personaId) === `persona-${index}`);
280
+ if (personaIndex < 0)
281
+ return null;
282
+ const sources = asArray(asRecord(personas[personaIndex])?.sources);
283
+ const sourceIndex = sources.findIndex((_, index) => str(claim?.sourceId) === `RCP-p${personaIndex}-s${index}`);
284
+ if (sourceIndex < 0)
285
+ return null;
286
+ const source = asRecord(sources[sourceIndex]);
287
+ const href = safeReceiptUrl(source?.url);
288
+ if (!source || !href)
289
+ return null;
290
+ return {
291
+ sourceId: `RCP-p${personaIndex}-s${sourceIndex}`,
292
+ href,
293
+ quote: collapseWhitespace(str(source.quote) ?? ''),
294
+ source,
295
+ };
296
+ }
297
+ function resolvePersonaRows(reportData) {
298
+ const personas = asArray(reportData.personas);
299
+ return asArray(reportData.claims).map((claim) => ({
300
+ claim,
301
+ receipt: resolvePersonaReceipt(claim, personas),
302
+ }));
303
+ }
304
+ function tallyPersonaStates(rows) {
277
305
  const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
278
- for (const raw of claims) {
306
+ for (const { claim: raw, receipt } of rows) {
279
307
  const state = str(asRecord(raw)?.state);
280
- if (state === 'GROUNDED')
281
- tally.grounded += 1;
308
+ if (state === 'GROUNDED') {
309
+ if (receipt)
310
+ tally.grounded += 1;
311
+ else
312
+ tally.noReceipt += 1;
313
+ }
282
314
  else if (state === 'SPECULATION')
283
315
  tally.speculation += 1;
284
316
  else if (state === 'NO_RECEIPT')
@@ -308,7 +340,7 @@ function tallyLine(tally, total) {
308
340
  * itself is what an agent pattern-matches on — a digest that says "this claim has
309
341
  * a receipt" without printing `RCP-p0-s2` leaves the agent unable to follow it.
310
342
  */
311
- function renderPersonaClaim(raw) {
343
+ function renderPersonaClaim(raw, resolvedReceipt) {
312
344
  const claim = asRecord(raw);
313
345
  if (!claim)
314
346
  return '- (unreadable claim entry)';
@@ -323,92 +355,103 @@ function renderPersonaClaim(raw) {
323
355
  //
324
356
  // The GROUNDED gates keep reading the RAW `str()` value: flattening trims, so
325
357
  // gating on the flattened one would newly admit `" GROUNDED "` as grounded and
326
- // put the row's badge at odds with `tallyStates`, which reads raw. Flattening
358
+ // put the row's badge at odds with `tallyPersonaStates`, which reads raw. Flattening
327
359
  // is for the RENDER; it must not widen what counts as a receipt.
328
360
  const rawState = str(claim.state);
329
- const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
361
+ const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
362
+ const state = rawState === 'GROUNDED' && !resolvedReceipt ? 'NO_RECEIPT' : storedState;
330
363
  const id = renderId(claim.id);
331
364
  const personaId = safeId(claim.personaId);
332
365
  const sourceId = safeId(claim.sourceId);
333
366
  const text = safeInline(claim.text, CLAIM_TEXT_MAX);
334
367
  // Gated on `state`, not on `sourceId` presence — see `renderReportClaim` for
335
368
  // why a pointer on a non-GROUNDED claim must never render as a receipt.
336
- let receipt = '';
337
- if (rawState === 'GROUNDED') {
338
- receipt = sourceId ? ` ← ${sourceId}` : groundedWithoutReceipt(claim.sourceId);
369
+ let provenance = '';
370
+ if (resolvedReceipt) {
371
+ provenance = ` ← ${resolvedReceipt.sourceId}`;
372
+ }
373
+ else if (rawState === 'GROUNDED') {
374
+ provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
339
375
  }
340
376
  else if (sourceId) {
341
- receipt = ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
377
+ provenance = ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
342
378
  }
343
379
  const who = personaId ? ` (${personaId})` : '';
344
380
  const body = text ? ` — "${text}"` : '';
345
- return `- [${state}] ${id}${who}${receipt}${body}`;
381
+ return `- [${state}] ${id}${who}${provenance}${body}`;
346
382
  }
347
383
  /**
348
- * One report-spine claim line. Differs from the persona one in two ways that
349
- * matter to a reader: it carries a `section` (market/competitor) instead of a
350
- * persona, and a NO_RECEIPT claim has TWO distinct provenance readings that must
351
- * be told apart — "Cited" (a page was read and independently vouched, no exact
352
- * span pinned) versus plain model knowledge (recalled, unverified).
353
- *
354
- * ⚠️ THE WORD IS THE ONE FROM `MODEL_ATTESTED_BADGE_LABEL` (FUL-416), lower-cased.
355
- * This digest is the THIRD mirror of that label inside the MCP package — the tool
356
- * advert in `registry.ts` is the other two — and it is the one that renders into
357
- * the payload an agent actually READS. When the re-map first landed it was missed:
358
- * the advert promised a claim "marked \"Inferred\"" while this line still emitted
359
- * `model-attested`, so an agent keying on the advert's word would have found
360
- * nothing, and the same server described one tier two different ways.
361
- *
362
- * Neither reading is grounding. The attestation's `sourceId` is printed under an
363
- * explicit `cited →` label rather than the bare `←` used for real receipts, so
364
- * the two can never be skim-read as the same thing. That distinction matters MORE now
365
- * that the word itself is shared with a span-verified receipt.
366
- *
367
- * FUL-615: an attested claim now also carries the WINDOW the judge read, clipped to
368
- * `RECEIPT_QUOTE_MAX` on a labelled continuation line. `cited` was the one provenance
369
- * tier on this surface whose supporting text reached no agent-visible channel at all —
370
- * a GROUNDED claim's span is readable as the `RCP-` excerpt it steers, but an attested
371
- * claim's pointer resolves into `reportEvidence`, whose rows deliberately carry no quote
372
- * (FUL-560). So the tier a reader is asked to weigh ("stronger than recall, still NOT a
373
- * receipt") was the one tier they could not see for themselves.
374
- *
375
- * ⚠️ THE ABSENCE OF A QUOTE USED TO BE HALF THE SEPARATION, and this change spends it.
376
- * What replaces it is `ATTESTED_WINDOW_LABEL` — see its doc for why a bare indented
377
- * quote would have made an attested row read as a grounded one.
378
- *
379
- * DIGEST COST, in the idiom `RECEIPT_QUOTE_MAX`'s own doc sets, because a digest an
380
- * agent truncates is a digest it does not read. Bounded by construction: this spine
381
- * renders at most `CLAIM_RENDER_CAP` (40) rows and only attested ones get a window, so
382
- * the worst case is 40 × (200 + ~40 for the label, indent, quotes and newline) ≈ 9.6 KB.
383
- * Today it is 0 on every prod report — the A2 grantor writes no attestations yet.
384
+ * Resolve the one presentation grant behind either report-level Cited treatment.
385
+ *
386
+ * A stored GROUNDED grade and a model-attestation marker are both untrusted report JSON, not proof
387
+ * by themselves. Either earns its presentation only when its canonical pointer indexes the exact
388
+ * `reportEvidence` pool and the target has a non-credentialed HTTP(S) destination. An attestation
389
+ * additionally needs its nonblank captured window. Keeping this as one resolver lets the row,
390
+ * tally, pointer and window consume the same answer instead of minting orphan citations.
384
391
  */
385
- function renderReportClaim(raw) {
392
+ function resolveReportReceipt(rawClaim, reportEvidence) {
393
+ const claim = asRecord(rawClaim);
394
+ const rawState = str(claim?.state);
395
+ let kind;
396
+ let sourceId = '';
397
+ let quote = '';
398
+ if (rawState === 'GROUNDED') {
399
+ kind = 'grounded';
400
+ sourceId = typeof claim?.sourceId === 'string' ? claim.sourceId.trim() : '';
401
+ }
402
+ else if (rawState === 'NO_RECEIPT') {
403
+ kind = 'attested';
404
+ const attestation = asRecord(claim?.attestation);
405
+ sourceId = typeof attestation?.sourceId === 'string' ? attestation.sourceId.trim() : '';
406
+ quote = typeof attestation?.quote === 'string' ? collapseWhitespace(attestation.quote) : '';
407
+ if (!quote)
408
+ return null;
409
+ }
410
+ else {
411
+ return null;
412
+ }
413
+ if (!sourceId)
414
+ return null;
415
+ const parsed = /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId);
416
+ if (!parsed)
417
+ return null;
418
+ const sourceIndex = Number(parsed[1]);
419
+ if (!Number.isSafeInteger(sourceIndex))
420
+ return null;
421
+ const source = asRecord(reportEvidence[sourceIndex]);
422
+ if (!source || !safeReceiptUrl(source.url))
423
+ return null;
424
+ return { kind, sourceId, quote };
425
+ }
426
+ function renderReportClaim(raw, receipt) {
386
427
  const claim = asRecord(raw);
387
428
  if (!claim)
388
429
  return '- (unreadable claim entry)';
389
430
  // FUL-253: same guard and same raw-gate split as `renderPersonaClaim` — this
390
431
  // spine's rows are the ones a forged `[GROUNDED] … ← RRCP-s0` would hide in.
391
432
  const rawState = str(claim.state);
392
- const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
433
+ const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
434
+ const state = rawState === 'GROUNDED' && receipt?.kind !== 'grounded'
435
+ ? 'NO_RECEIPT'
436
+ : storedState;
393
437
  const id = renderId(claim.id);
394
438
  const section = safeInline(claim.section, 40);
395
439
  const sourceId = safeId(claim.sourceId);
396
440
  const text = safeInline(claim.text, CLAIM_TEXT_MAX);
397
- const attestation = asRecord(claim.attestation);
398
- const attestedSourceId = safeId(attestation?.sourceId);
399
- // The bare `←` arrow means RECEIPT, so it is gated on `state`, NOT on the mere
400
- // presence of a `sourceId`. Keying on presence would render an explicitly
401
- // unsupported claim with the same marker a span-verified one gets: the schema
402
- // types `state` and `sourceId` independently and `.passthrough()`s unknown
403
- // shapes, and `getReport` hands the RAW payload through when Zod rejects it, so
404
- // `{ state: 'NO_RECEIPT', sourceId: 'RRCP-s0' }` genuinely reaches this code.
405
- // An agent following that arrow would treat a NO_RECEIPT figure as source-checked.
441
+ // The bare `←` arrow means RECEIPT, so raw state and pointer presence are both
442
+ // insufficient. The shared resolver must also find the exact evidence row and a
443
+ // safe destination; otherwise even a stored GROUNDED grade degrades to neutral
444
+ // audit copy. `getReport` hands RAW payload through after schema drift, so every
445
+ // one of those checks is a render-boundary responsibility.
406
446
  let provenance = '';
407
- if (rawState === 'GROUNDED') {
408
- provenance = sourceId ? ` ← ${sourceId}` : groundedWithoutReceipt(claim.sourceId);
447
+ if (receipt?.kind === 'grounded') {
448
+ provenance = ` ← ${receipt.sourceId}`;
449
+ }
450
+ else if (rawState === 'GROUNDED') {
451
+ provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
409
452
  }
410
- else if (rawState === 'NO_RECEIPT' && attestedSourceId) {
411
- provenance = ` · ${ATTESTED_LABEL} → ${attestedSourceId} (NOT a receipt)`;
453
+ else if (receipt?.kind === 'attested') {
454
+ provenance = ` · ${ATTESTED_LABEL} → ${receipt.sourceId} (NOT a receipt)`;
412
455
  }
413
456
  else if (rawState === 'NO_RECEIPT') {
414
457
  provenance = ` · ${UNSOURCED_LABEL} — from model knowledge, unverified`;
@@ -416,11 +459,11 @@ function renderReportClaim(raw) {
416
459
  // A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
417
460
  // neither hidden from the reader nor readable as grounding.
418
461
  if (rawState !== 'GROUNDED' && sourceId) {
419
- provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
462
+ provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
420
463
  }
421
464
  const where = section ? ` (${section})` : '';
422
465
  const body = text ? ` — "${text}"` : '';
423
- return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(rawState, attestedSourceId, attestation?.quote)}`;
466
+ return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(receipt)}`;
424
467
  }
425
468
  /**
426
469
  * The attested claim's window: the captured snapshot text an independent judge read
@@ -445,31 +488,48 @@ function renderReportClaim(raw) {
445
488
  * budget of its own, for the reason that constant's doc gives: an agent reading the
446
489
  * same source text clipped two different ways has to wonder which is the real wording.
447
490
  */
448
- function attestedWindow(rawState, attestedSourceId, rawQuote) {
449
- if (rawState !== 'NO_RECEIPT' || !attestedSourceId)
491
+ function attestedWindow(receipt) {
492
+ if (receipt?.kind !== 'attested')
450
493
  return '';
451
- const quote = collapseWhitespace(str(rawQuote) ?? '');
452
- if (!quote)
453
- return '';
454
- return `\n ${ATTESTED_WINDOW_LABEL}: "${clipReceiptQuote(quote, [], RECEIPT_QUOTE_MAX)}"`;
494
+ return `\n ${ATTESTED_WINDOW_LABEL}: "${clipReceiptQuote(receipt.quote, [], RECEIPT_QUOTE_MAX)}"`;
495
+ }
496
+ function tallyReportStates(rows) {
497
+ const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
498
+ for (const { claim: raw, receipt } of rows) {
499
+ const state = str(asRecord(raw)?.state);
500
+ if (state === 'GROUNDED') {
501
+ if (receipt?.kind === 'grounded')
502
+ tally.grounded += 1;
503
+ else
504
+ tally.noReceipt += 1;
505
+ }
506
+ else if (state === 'SPECULATION')
507
+ tally.speculation += 1;
508
+ else if (state === 'NO_RECEIPT')
509
+ tally.noReceipt += 1;
510
+ else
511
+ tally.other += 1;
512
+ }
513
+ return tally;
455
514
  }
456
515
  function renderPersonaSpine(reportData, channel) {
457
- const claims = asArray(reportData.claims);
458
- if (claims.length === 0) {
516
+ const rows = resolvePersonaRows(reportData);
517
+ if (rows.length === 0) {
459
518
  return ('### Persona claim spine — NOT PRESENT on this run\n' +
460
519
  'No persona claim registry was produced, so NOTHING in the persona/interview prose above ' +
461
520
  'has been machine-checked. Absent is not clean: treat those statements as unverified.');
462
521
  }
463
- const tally = tallyStates(claims);
464
- const shown = claims.slice(0, CLAIM_RENDER_CAP);
465
- const lines = shown.map(renderPersonaClaim);
466
- return (`### Persona claim spine — ${tallyLine(tally, claims.length)}\n` +
522
+ const tally = tallyPersonaStates(rows);
523
+ const shown = rows.slice(0, CLAIM_RENDER_CAP);
524
+ const lines = shown.map(({ claim, receipt }) => renderPersonaClaim(claim, receipt));
525
+ return (`### Persona claim spine — ${tallyLine(tally, rows.length)}\n` +
467
526
  'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
468
- 'forum post its quote span was code-verified against. NO_RECEIPT can mean a bad claim OR merely ' +
527
+ 'forum post its quote span was code-verified against. A stored GROUNDED grade whose canonical, ' +
528
+ 'persona-owned safe receipt is unavailable is rendered and counted NO_RECEIPT. NO_RECEIPT can mean a bad claim OR merely ' +
469
529
  'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
470
530
  'discounting it. Anything not GROUNDED is unverified.\n' +
471
531
  `${lines.join('\n')}` +
472
- capNote(shown.length, claims.length, channel, 'report_data.claims'));
532
+ capNote(shown.length, rows.length, channel, 'report_data.claims'));
473
533
  }
474
534
  function renderReportSpine(reportData, channel) {
475
535
  const claims = asArray(reportData.reportClaims);
@@ -479,14 +539,16 @@ function renderReportSpine(reportData, channel) {
479
539
  'executive-summary assertion in the prose above has been machine-checked. Treat every such ' +
480
540
  'figure as unverified.');
481
541
  }
482
- const tally = tallyStates(claims);
483
- const attested = claims.filter((raw) => {
484
- const claim = asRecord(raw);
485
- return str(claim?.state) === 'NO_RECEIPT' && str(asRecord(claim?.attestation)?.sourceId) !== null;
486
- }).length;
542
+ const reportEvidence = asArray(reportData.reportEvidence);
543
+ const rows = claims.map((claim) => ({
544
+ claim,
545
+ receipt: resolveReportReceipt(claim, reportEvidence),
546
+ }));
547
+ const tally = tallyReportStates(rows);
548
+ const attested = rows.filter(({ receipt }) => receipt?.kind === 'attested').length;
487
549
  const attestedNote = attested > 0 ? ` (of which ${attested} ${ATTESTED_LABEL})` : '';
488
- const shown = claims.slice(0, CLAIM_RENDER_CAP);
489
- const lines = shown.map(renderReportClaim);
550
+ const shown = rows.slice(0, CLAIM_RENDER_CAP);
551
+ const lines = shown.map(({ claim, receipt }) => renderReportClaim(claim, receipt));
490
552
  return (`### Report claim spine — ${tallyLine(tally, claims.length)}${attestedNote}\n` +
491
553
  'A SEPARATE pool from the persona spine — the two never cross. Sections are `market` (sizing/' +
492
554
  'trend figures), `competitor` (profile facts) and `summary` (an assertion quoted VERBATIM from ' +
@@ -497,8 +559,9 @@ function renderReportSpine(reportData, channel) {
497
559
  'supporting page an independent check vouched for, but no exact span was pinned — stronger than ' +
498
560
  `recall, still NOT a receipt. The \`${ATTESTED_WINDOW_LABEL}\` line beneath such a row is that page's ` +
499
561
  'text as we captured it: read it as the context the judge weighed, never as a span checked against ' +
500
- 'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. ' +
501
- 'Key on the state, never on whether a source id is present.\n' +
562
+ 'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. A stored GROUNDED grade ' +
563
+ 'whose receipt is unavailable is shown as neutral audit context and counted NO_RECEIPT. ' +
564
+ 'Key on the rendered state and receipt arrow, never on stored state or source-id presence alone.\n' +
502
565
  `${lines.join('\n')}` +
503
566
  capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
504
567
  }
@@ -506,9 +569,9 @@ function renderReportSpine(reportData, channel) {
506
569
  * The verified spans each persona receipt has to be able to SHOW, keyed by the
507
570
  * receipt id its claims point at (FUL-252).
508
571
  *
509
- * Gated on `state === 'GROUNDED'`, matching `renderPersonaClaim`'s own gate and for
510
- * the same reason: only a GROUNDED claim's span earned a badge, so only a GROUNDED
511
- * claim's span is what the excerpt owes the reader. Keying on the mere presence of
572
+ * Gated on the same resolved receipt as `renderPersonaClaim`: only a GROUNDED claim
573
+ * with a canonical, persona-owned safe receipt earned a badge, so only that claim's
574
+ * span is what the excerpt owes the reader. Keying on the mere presence of
512
575
  * `quoteSpan` would let a SPECULATION claim steer the window — pushing the excerpt
513
576
  * away from the sentence a real receipt was verified against, in favour of one no
514
577
  * check ever ran on. `getReport` hands the RAW payload through when Zod rejects it,
@@ -518,21 +581,20 @@ function renderReportSpine(reportData, channel) {
518
581
  * has no visible badge, so windowing a quote to support it would spend the excerpt
519
582
  * on a claim the agent cannot see, at the cost of one it can.
520
583
  */
521
- function collectVerifiedSpans(reportData) {
584
+ function collectVerifiedSpans(rows) {
522
585
  const spans = new Map();
523
- for (const raw of asArray(reportData.claims).slice(0, CLAIM_RENDER_CAP)) {
586
+ for (const { claim: raw, receipt } of rows.slice(0, CLAIM_RENDER_CAP)) {
524
587
  const claim = asRecord(raw);
525
- if (!claim || str(claim.state) !== 'GROUNDED')
588
+ if (!claim || !receipt)
526
589
  continue;
527
- const sourceId = str(claim.sourceId);
528
590
  const span = str(claim.quoteSpan);
529
- if (!sourceId || !span)
591
+ if (!span)
530
592
  continue;
531
- const existing = spans.get(sourceId);
593
+ const existing = spans.get(receipt.sourceId);
532
594
  if (existing)
533
595
  existing.push(span);
534
596
  else
535
- spans.set(sourceId, [span]);
597
+ spans.set(receipt.sourceId, [span]);
536
598
  }
537
599
  return spans;
538
600
  }
@@ -561,54 +623,55 @@ function collectVerifiedSpans(reportData) {
561
623
  * from them — `publishedDate`, the figure's actual recency (FUL-148).
562
624
  */
563
625
  function renderReceiptPools(reportData, channel) {
564
- const personas = asArray(reportData.personas);
565
626
  const reportEvidence = asArray(reportData.reportEvidence);
566
- const spansByReceipt = collectVerifiedSpans(reportData);
627
+ const personaRows = resolvePersonaRows(reportData).slice(0, CLAIM_RENDER_CAP);
628
+ const spansByReceipt = collectVerifiedSpans(personaRows);
629
+ const personaReceiptById = new Map();
630
+ for (const { receipt } of personaRows) {
631
+ if (receipt)
632
+ personaReceiptById.set(receipt.sourceId, receipt);
633
+ }
567
634
  const personaReceipts = [];
568
- personas.forEach((rawPersona, i) => {
569
- const sources = asArray(asRecord(rawPersona)?.sources);
570
- sources.forEach((rawSource, j) => {
571
- const source = asRecord(rawSource);
572
- // FUL-253: the quote below was already flattened — these two were not, and
573
- // they sit on the SAME `- RCP-…` line, so a newline in either forges the
574
- // receipt row the comment below says a forged quote would.
575
- const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
576
- const url = safeInline(source?.url, 200) ?? '(no url)';
577
- // Indented under its own receipt line so the pool still scans as a list of
578
- // pointers; a receipt with no cached quote simply has no second line, which
579
- // is itself worth seeing — it is a pointer that grounds nothing until fetched.
580
- //
581
- // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
582
- // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
583
- // convincing `- RCP-` prefix would add an entry pointing at a source nobody
584
- // retrieved. A whitespace-only quote also collapses to '' here and correctly
585
- // renders as a bare pointer rather than as `""` — "the source said nothing".
586
- //
587
- // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
588
- // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
589
- // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
590
- // `renderPersonaClaim` prints, so the two sides of the arrow are built from
591
- // the same expression and cannot drift into windowing the wrong receipt.
592
- const receiptId = `RCP-p${i}-s${j}`;
593
- const quote = collapseWhitespace(str(source?.quote) ?? '');
594
- const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
595
- const quoteLine = quote ? `\n "${clipped}"` : '';
596
- // FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
597
- // on a row that breaks the stated rule. This pool is the one place in the package where the
598
- // type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
599
- // source out of `persona.sources`, so a per-row line here would print the same word down
600
- // forty rows and bury the one that mattered. The header carries the claim; this carries the
601
- // exception, in the shared wording.
602
- //
603
- // ⚠️ ABSENT AND NON-CANONICAL ARE BOTH DEVIATIONS. `canonicalContentType` fails closed, so a
604
- // legacy untyped receipt (M0a predates plenty of stored runs) and a token from a newer
605
- // producer both land here rather than being silently absorbed by the header's claim — which
606
- // would be exactly the "silence reads as reassurance" fail-open this change exists to close.
607
- const personaType = canonicalContentType(source?.contentType);
608
- const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
609
- personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
610
- });
611
- });
635
+ for (const [receiptId, receipt] of personaReceiptById) {
636
+ const source = receipt.source;
637
+ // FUL-253: the quote below was already flattened — these two were not, and
638
+ // they sit on the SAME `- RCP-…` line, so a newline in either forges the
639
+ // receipt row the comment below says a forged quote would.
640
+ const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
641
+ const url = safeInline(receipt.href, 200) ?? '(no url)';
642
+ // Indented under its own receipt line so the pool still scans as a list of
643
+ // pointers; a receipt with no cached quote simply has no second line, which
644
+ // is itself worth seeing — it is a pointer that grounds nothing until fetched.
645
+ //
646
+ // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
647
+ // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
648
+ // convincing `- RCP-` prefix would add an entry pointing at a source nobody
649
+ // retrieved. A whitespace-only quote also collapses to '' here and correctly
650
+ // renders as a bare pointer rather than as `""` — "the source said nothing".
651
+ //
652
+ // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
653
+ // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
654
+ // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
655
+ // `renderPersonaClaim` prints, so the two sides of the arrow are built from
656
+ // the same expression and cannot drift into windowing the wrong receipt.
657
+ const quote = receipt.quote;
658
+ const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
659
+ const quoteLine = quote ? `\n "${clipped}"` : '';
660
+ // FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
661
+ // on a row that breaks the stated rule. This pool is the one place in the package where the
662
+ // type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
663
+ // source out of `persona.sources`, so a per-row line here would print the same word down
664
+ // forty rows and bury the one that mattered. The header carries the claim; this carries the
665
+ // exception, in the shared wording.
666
+ //
667
+ // ⚠️ ABSENT AND NON-CANONICAL ARE BOTH DEVIATIONS. `canonicalContentType` fails closed, so a
668
+ // legacy untyped receipt (M0a predates plenty of stored runs) and a token from a newer
669
+ // producer both land here rather than being silently absorbed by the header's claim — which
670
+ // would be exactly the "silence reads as reassurance" fail-open this change exists to close.
671
+ const personaType = canonicalContentType(source?.contentType);
672
+ const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
673
+ personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
674
+ }
612
675
  const evidenceReceipts = reportEvidence.map((raw, n) => {
613
676
  const source = asRecord(raw);
614
677
  // FUL-253: every field on this row is scraped-page metadata, and the row is
@@ -640,13 +703,13 @@ function renderReceiptPools(reportData, channel) {
640
703
  });
641
704
  if (personaReceipts.length === 0 && evidenceReceipts.length === 0) {
642
705
  return ('### Receipt pools — EMPTY\n' +
643
- 'No cached sources were attached to this run, so no claim above can be followed to a source. ' +
644
- 'Any receipt id referenced in the prose is unresolvable.');
706
+ 'No visible claim resolves to a canonical, persona-owned safe receipt and no report evidence ' +
707
+ 'was attached to this run. Any receipt id referenced in the prose is unresolvable.');
645
708
  }
646
709
  const sections = ['### Receipt pools'];
647
710
  if (personaReceipts.length > 0) {
648
711
  const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
649
- sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — the target of an \`RCP-\` pointer. ` +
712
+ sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — safe targets of the visible \`RCP-\` pointers above. ` +
650
713
  'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise:\n' +
651
714
  shown.join('\n') +
652
715
  capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
@@ -1057,6 +1120,87 @@ function renderRobustness(reportData, channel) {
1057
1120
  return (`${header}\n${lines.join('\n')}` +
1058
1121
  capNote(shown.length, withRobustness.length, channel, 'report_data.hypothesisResults'));
1059
1122
  }
1123
+ // ---------------------------------------------------------------------------
1124
+ // Model-authored framing — prose side of the proof separator (FUL-570)
1125
+ // ---------------------------------------------------------------------------
1126
+ /** Defensive ceiling for one model-authored framing value read from raw report_data. */
1127
+ const MODEL_FRAMING_TEXT_MAX = 400;
1128
+ /** Runaway backstop for assumptions / risks; normal briefs carry only a handful. */
1129
+ const MODEL_FRAMING_LIST_CAP = 10;
1130
+ export const MODEL_FRAMING_HEADING = '## Model-authored report framing — not evidence or findings';
1131
+ function framingList(raw) {
1132
+ return asArray(raw)
1133
+ .map((value) => safeInline(value, MODEL_FRAMING_TEXT_MAX))
1134
+ .filter((value) => Boolean(value));
1135
+ }
1136
+ function renderPersonasSynthesis(reportData) {
1137
+ const synthesis = asRecord(reportData.personasSynthesis);
1138
+ if (!synthesis)
1139
+ return '';
1140
+ const headline = safeInline(synthesis.headline, MODEL_FRAMING_TEXT_MAX);
1141
+ const body = safeInline(synthesis.body, MODEL_FRAMING_TEXT_MAX);
1142
+ // The producer treats the pair atomically: a headline without its justification is not a
1143
+ // degraded synthesis, it is an assertion with the reasoning removed. Preserve that rule on
1144
+ // the raw-passthrough path too.
1145
+ if (!headline || !body)
1146
+ return '';
1147
+ return ('### Persona-roster synthesis — model-authored, not evidence\n\n' +
1148
+ `**${headline}**\n\n${body}`);
1149
+ }
1150
+ function renderResearchBrief(reportData) {
1151
+ const brief = asRecord(reportData.researchBrief);
1152
+ if (!brief)
1153
+ return '';
1154
+ const lines = [];
1155
+ const targetMarket = safeInline(brief.targetMarket, MODEL_FRAMING_TEXT_MAX);
1156
+ const valueProposition = safeInline(brief.valueProposition, MODEL_FRAMING_TEXT_MAX);
1157
+ const competitiveLandscape = safeInline(brief.competitiveLandscape, MODEL_FRAMING_TEXT_MAX);
1158
+ if (targetMarket)
1159
+ lines.push(`- Target market: ${targetMarket}`);
1160
+ if (valueProposition)
1161
+ lines.push(`- Value proposition: ${valueProposition}`);
1162
+ const assumptions = framingList(brief.assumptions);
1163
+ if (assumptions.length > 0) {
1164
+ lines.push('- Assumptions to test:');
1165
+ lines.push(...assumptions
1166
+ .slice(0, MODEL_FRAMING_LIST_CAP)
1167
+ .map((assumption) => ` - ${assumption}`));
1168
+ if (assumptions.length > MODEL_FRAMING_LIST_CAP) {
1169
+ lines.push(` - … ${assumptions.length - MODEL_FRAMING_LIST_CAP} more in report_data`);
1170
+ }
1171
+ }
1172
+ if (competitiveLandscape) {
1173
+ lines.push(`- Competitive landscape: ${competitiveLandscape}`);
1174
+ }
1175
+ const risks = framingList(brief.keyRisks);
1176
+ if (risks.length > 0) {
1177
+ lines.push('- Key risks:');
1178
+ lines.push(...risks.slice(0, MODEL_FRAMING_LIST_CAP).map((risk) => ` - ${risk}`));
1179
+ if (risks.length > MODEL_FRAMING_LIST_CAP) {
1180
+ lines.push(` - … ${risks.length - MODEL_FRAMING_LIST_CAP} more in report_data`);
1181
+ }
1182
+ }
1183
+ if (lines.length === 0)
1184
+ return '';
1185
+ return '### Research brief — model-generated framing, not findings\n\n' + lines.join('\n');
1186
+ }
1187
+ /**
1188
+ * The approved model-authored context block.
1189
+ *
1190
+ * `productName` and `problemStatement` remain typed but are deliberately not repeated: the
1191
+ * former is already the report title and the latter restates the caller's idea. Only the five
1192
+ * actionable framing fields approved in FUL-570 render here. This block is composed ABOVE the
1193
+ * proof separator and labels itself twice; no line can be mistaken for a graded finding.
1194
+ */
1195
+ function renderModelFraming(reportData) {
1196
+ const data = asRecord(reportData);
1197
+ if (!data)
1198
+ return '';
1199
+ const sections = [renderPersonasSynthesis(data), renderResearchBrief(data)].filter(Boolean);
1200
+ if (sections.length === 0)
1201
+ return '';
1202
+ return `${MODEL_FRAMING_HEADING}\n\n${sections.join('\n\n')}`;
1203
+ }
1060
1204
  /**
1061
1205
  * The rendered budget for the run-level verdict rationale.
1062
1206
  *
@@ -1140,6 +1284,64 @@ const NO_TRUST_DATA = `${DIGEST_HEADING}\n\n` +
1140
1284
  'verified against a source: there is no claim registry, no receipts, no QA flags, and no ' +
1141
1285
  'anti-sycophancy readout to check it against. Treat every figure, quote, and verdict above as ' +
1142
1286
  'UNVERIFIED — do not cite anything from it as source-checked.';
1287
+ export const COMPETITOR_SOURCE_QUALITY_HEADING = '### Competitor source quality — code-derived flags';
1288
+ const MARKDOWN_LINK_NAME = /^\[([^\]\n]+)\]\([^)\n]+\)$/;
1289
+ const PARTIAL_MARKDOWN_LINK_NAME = /\[[^\]\n]*\]\(/;
1290
+ const URL_LIKE_NAME = /(?:\bhttps?:\/\/|\bwww\.)/i;
1291
+ /**
1292
+ * Render an identifier, never a link. A competitor name is model-authored and can
1293
+ * itself contain Markdown link syntax or a raw URL even when the separate `url`
1294
+ * field is withheld. An exact Markdown link keeps only its label; other URL-like
1295
+ * names fail closed to a placeholder. Remaining Markdown punctuation, including
1296
+ * reference-link brackets and dots that Markdown may autolink, is removed.
1297
+ */
1298
+ function competitorIdentifier(raw) {
1299
+ const flattened = safeInline(raw, CLAIM_TEXT_MAX);
1300
+ if (!flattened)
1301
+ return '(unnamed competitor)';
1302
+ const markdownLink = MARKDOWN_LINK_NAME.exec(flattened);
1303
+ const candidate = markdownLink?.[1] ?? flattened;
1304
+ if ((!markdownLink && PARTIAL_MARKDOWN_LINK_NAME.test(flattened)) ||
1305
+ URL_LIKE_NAME.test(candidate)) {
1306
+ return '(competitor name withheld: URL-like)';
1307
+ }
1308
+ const plain = collapseWhitespace(candidate.replace(/[^\p{L}\p{N}&'’+\- ]/gu, ' '));
1309
+ return plain || '(unnamed competitor)';
1310
+ }
1311
+ /**
1312
+ * FUL-587's compact disclosure. The aggregate and the decision to mark a row are code-derived
1313
+ * from the exact canonical `marketing_seo` token. Only the flagged rows are named; positioning,
1314
+ * notes, url and threat level remain model-authored prose and never cross the proof separator.
1315
+ *
1316
+ * The competitor name is the approved row identifier, not a prose block. It is flattened and
1317
+ * capped before rendering, so a stored name cannot mint a digest row or heading of its own.
1318
+ */
1319
+ function renderCompetitorSourceQuality(reportData) {
1320
+ const competitors = asArray(reportData.competitors);
1321
+ if (competitors.length === 0)
1322
+ return '';
1323
+ const readable = competitors
1324
+ .map(asRecord)
1325
+ .filter((competitor) => competitor !== null);
1326
+ const unreadableCount = competitors.length - readable.length;
1327
+ const flagged = readable.filter((competitor) => canonicalContentType(competitor.contentType) === 'marketing_seo');
1328
+ const shown = flagged.slice(0, LIST_RENDER_CAP);
1329
+ const lines = shown.map((competitor) => `- ⚠️ ${competitorIdentifier(competitor.name)} — \`marketing_seo\``);
1330
+ const aggregate = `${flagged.length} of ${readable.length} competitor rows are flagged \`marketing_seo\`. ` +
1331
+ 'The mark is a source-quality classification used by synthesis filters, not a claim-level ' +
1332
+ 'grounding verdict. Check reportClaims[] states and receipts: a competitor’s own page may ' +
1333
+ 'ground a claim about itself only after span verification. Unmarked rows are not certified ' +
1334
+ 'as grounding evidence by this summary.' +
1335
+ (unreadableCount > 0
1336
+ ? ` ${unreadableCount} additional competitor array ${unreadableCount === 1 ? 'entry was' : 'entries were'} unreadable and could not be classified; do not treat that gap as clean.`
1337
+ : '');
1338
+ const cap = flagged.length > shown.length
1339
+ ? `\n\n_Only ${shown.length} of ${flagged.length} flagged rows are named here; read report_data.competitors[] for the full structured list._`
1340
+ : '';
1341
+ return (`${COMPETITOR_SOURCE_QUALITY_HEADING}\n\n${aggregate}` +
1342
+ (lines.length > 0 ? `\n\n${lines.join('\n')}` : '') +
1343
+ cap);
1344
+ }
1143
1345
  /**
1144
1346
  * Build the trust digest appended to `get_report`'s text.
1145
1347
  *
@@ -1155,6 +1357,7 @@ export function buildTrustDigest(reportData, channel) {
1155
1357
  const sections = [
1156
1358
  renderPersonaSpine(data, channel),
1157
1359
  renderReportSpine(data, channel),
1360
+ renderCompetitorSourceQuality(data),
1158
1361
  renderReceiptPools(data, channel),
1159
1362
  renderPersonas(data, channel),
1160
1363
  renderSycophancy(data, channel),
@@ -1231,17 +1434,19 @@ function renderReportGaps(reportData) {
1231
1434
  * a field can reach a caller through one tool and not the others while every test stays
1232
1435
  * green. FUL-510's `verdictSummary` is the field that made the seam worth naming.
1233
1436
  *
1234
- * The order is the contract. Report prose first (the run's markdown, then its own stored
1235
- * verdict rationale), then `---`, then the machine-checked digest LAST — because the
1236
- * digest's authority rule is that the last such section in the message is the real one,
1237
- * and anything above the separator is prose that may resemble it.
1437
+ * The order is the contract. Report prose first (the run's markdown, then the explicitly
1438
+ * labelled model-authored framing and its own stored verdict rationale), then `---`, then
1439
+ * the machine-checked digest LAST — because the digest's authority rule is that the last
1440
+ * such section in the message is the real one, and anything above the separator is prose
1441
+ * that may resemble it.
1238
1442
  */
1239
1443
  export function composeReportText(reportMarkdown, reportData, channel) {
1240
1444
  const rationale = renderVerdictRationale(reportData);
1445
+ const framing = renderModelFraming(reportData);
1241
1446
  // FUL-586: the completeness banner goes FIRST — before the prose it is a caveat about.
1242
1447
  const gaps = renderReportGaps(reportData);
1243
1448
  return (`${gaps ? `${gaps}\n\n---\n\n` : ''}` +
1244
- `${reportMarkdown}${rationale ? `\n\n${rationale}` : ''}` +
1449
+ `${reportMarkdown}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
1245
1450
  `\n\n---\n\n${buildTrustDigest(reportData, channel)}`);
1246
1451
  }
1247
1452
  //# sourceMappingURL=report-digest.js.map