@clien-ai/mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +105 -0
- package/README.md +32 -7
- package/dist/auth/oauth.js +69 -39
- package/dist/auth/oauth.js.map +1 -1
- package/dist/server.js +37 -9
- package/dist/server.js.map +1 -1
- package/dist/tools/collections.js +262 -23
- package/dist/tools/collections.js.map +1 -1
- package/dist/tools/interview.js +77 -6
- package/dist/tools/interview.js.map +1 -1
- package/dist/tools/output-schemas.js +130 -0
- package/dist/tools/output-schemas.js.map +1 -0
- package/dist/tools/personas.js +195 -25
- package/dist/tools/personas.js.map +1 -1
- package/dist/tools/projects.js +56 -11
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/registry.js +141 -58
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/report-digest.js +640 -0
- package/dist/tools/report-digest.js.map +1 -0
- package/dist/tools/reports.js +78 -6
- package/dist/tools/reports.js.map +1 -1
- package/dist/tools/research.js +249 -1
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/scoped-research.js +86 -22
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/tools/status.js +41 -5
- package/dist/tools/status.js.map +1 -1
- package/dist/util/single-flight.js +63 -0
- package/dist/util/single-flight.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `get_report` TRUST DIGEST (FUL-243) — renders the report's machine-checked
|
|
3
|
+
* trust spine into the tool result's TEXT.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. Every trust field this module renders was already returned by
|
|
6
|
+
* `get_report` — in `_meta.report_data`. But `_meta` is an OPTIONAL host channel:
|
|
7
|
+
* a host is not obliged to surface it to the model, and Claude Code does not. A
|
|
8
|
+
* wire capture against published `@clien-ai/mcp@0.3.0` showed the JSON-RPC
|
|
9
|
+
* `result` carrying both `_meta` and `content`, and the calling agent receiving
|
|
10
|
+
* only `content` — so in the ~58KB of markdown an agent actually reads, `RCP-`
|
|
11
|
+
* appeared 0 times, `RRCP-` 0 times, `identityPriors` 0 times, `qaFlagged` 0
|
|
12
|
+
* times. We had typed the whole Trust Engine and then delivered it down a channel
|
|
13
|
+
* the reader never sees.
|
|
14
|
+
*
|
|
15
|
+
* So the rule this module implements: ANY FACT AN AGENT MUST ACT ON HAS TO APPEAR
|
|
16
|
+
* IN THE RENDERED TEXT. Text works on every host, today, with no capability
|
|
17
|
+
* negotiation. `structuredContent` (the sanctioned machine-readable channel, added
|
|
18
|
+
* alongside this in the same change) is the richer path where supported, and
|
|
19
|
+
* `_meta` stays as-is for back-compat — but neither is allowed to be the ONLY
|
|
20
|
+
* place a trust signal lives.
|
|
21
|
+
*
|
|
22
|
+
* DEGRADATION PROPERTY (copied deliberately from `list_competitors`). Every line
|
|
23
|
+
* here is written so that a missing, absent, or unrecognised value makes an agent
|
|
24
|
+
* UNDER-trust the report rather than over-trust it:
|
|
25
|
+
* - No trust data at all → an explicit "treat every figure above as unverified"
|
|
26
|
+
* banner, not silence. Silence reads as "nothing to worry about".
|
|
27
|
+
* - A spine that is absent → said out loud ("not present on this run"), because
|
|
28
|
+
* an absent claim registry is NOT the same as a clean one.
|
|
29
|
+
* - A `state` we do not recognise → rendered verbatim and counted as
|
|
30
|
+
* un-grounded, never coerced into GROUNDED.
|
|
31
|
+
* - A capped list → the cap is STATED with the dropped count (no silent
|
|
32
|
+
* truncation, which reads as "that was all of them").
|
|
33
|
+
*
|
|
34
|
+
* READS DEFENSIVELY, ON PURPOSE. `getReport` passes the RAW payload through when
|
|
35
|
+
* `ResearchReportDataSchema` rejects it (never drop report data on backend drift),
|
|
36
|
+
* so this module is the one consumer guaranteed to sometimes meet a shape Zod
|
|
37
|
+
* refused. Hence the local `asArray` / `asRecord` / `str` / `num` accessors rather
|
|
38
|
+
* than typed field access: a reshaped field must degrade to "not shown", never
|
|
39
|
+
* throw and lose the whole digest.
|
|
40
|
+
*/
|
|
41
|
+
import { collapseWhitespace, safeInline, clipReceiptQuote, RECEIPT_QUOTE_MAX, } from './research.js';
|
|
42
|
+
/**
|
|
43
|
+
* Max claims rendered per spine. Above this the digest states how many were
|
|
44
|
+
* dropped and where the full list lives — never a silent cut. Sized so a typical
|
|
45
|
+
* run (~40 persona claims, ~15 report claims) renders COMPLETE; the cap is a
|
|
46
|
+
* runaway backstop, not a routine trim.
|
|
47
|
+
*/
|
|
48
|
+
const CLAIM_RENDER_CAP = 40;
|
|
49
|
+
/** Max chars of claim text per line. Keeps a 40-claim spine to roughly 5KB. */
|
|
50
|
+
const CLAIM_TEXT_MAX = 140;
|
|
51
|
+
/** Max personas / hypotheses / disagreements listed before capping. */
|
|
52
|
+
const LIST_RENDER_CAP = 25;
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Defensive accessors — every one of these answers "or nothing" rather than throwing
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function asRecord(value) {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
58
|
+
? value
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
function asArray(value) {
|
|
62
|
+
return Array.isArray(value) ? value : [];
|
|
63
|
+
}
|
|
64
|
+
function str(value) {
|
|
65
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
66
|
+
}
|
|
67
|
+
function num(value) {
|
|
68
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* TRI-STATE boolean read: `true`, `false`, or `null` for "absent or unreadable".
|
|
72
|
+
*
|
|
73
|
+
* A plain `value === true` collapses the third case into `false`, and every flag
|
|
74
|
+
* this module reads is a WARNING flag — so a schema-drifted `qaFlagged: "true"`
|
|
75
|
+
* would render as "none QA-flagged" and a drifted `flipped: "true"` as "no
|
|
76
|
+
* verdict flipped". That is the exact inversion this module exists to prevent:
|
|
77
|
+
* unreadable must never present as clean. Callers distinguish the three cases and
|
|
78
|
+
* say "unknown" for `null`.
|
|
79
|
+
*/
|
|
80
|
+
function tribool(value) {
|
|
81
|
+
return typeof value === 'boolean' ? value : null;
|
|
82
|
+
}
|
|
83
|
+
/** True only for a real `true`. Use where a `null` is handled separately. */
|
|
84
|
+
function bool(value) {
|
|
85
|
+
return tribool(value) === true;
|
|
86
|
+
}
|
|
87
|
+
/** True when a flag was present but not a boolean, or absent where required. */
|
|
88
|
+
function unreadable(value) {
|
|
89
|
+
return value !== undefined && typeof value !== 'boolean';
|
|
90
|
+
}
|
|
91
|
+
/** `count` + a correctly pluralised noun, e.g. `3 claims` / `1 claim`. */
|
|
92
|
+
function plural(count, noun, pluralNoun) {
|
|
93
|
+
return `${count} ${count === 1 ? noun : (pluralNoun ?? `${noun}s`)}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* "(showing N of M — …)" tail, or '' when everything rendered.
|
|
97
|
+
*
|
|
98
|
+
* Always names WHERE the rest is, because a capped list with no pointer is
|
|
99
|
+
* indistinguishable from a complete one to a reader who cannot see the source.
|
|
100
|
+
*/
|
|
101
|
+
function capNote(shown, total, channel, path) {
|
|
102
|
+
if (shown >= total)
|
|
103
|
+
return '';
|
|
104
|
+
return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
|
|
105
|
+
}
|
|
106
|
+
function tallyStates(claims) {
|
|
107
|
+
const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
|
|
108
|
+
for (const raw of claims) {
|
|
109
|
+
const state = str(asRecord(raw)?.state);
|
|
110
|
+
if (state === 'GROUNDED')
|
|
111
|
+
tally.grounded += 1;
|
|
112
|
+
else if (state === 'SPECULATION')
|
|
113
|
+
tally.speculation += 1;
|
|
114
|
+
else if (state === 'NO_RECEIPT')
|
|
115
|
+
tally.noReceipt += 1;
|
|
116
|
+
// An unrecognised (or missing) state counts as OTHER — deliberately not
|
|
117
|
+
// folded into any of the three known buckets, and never into `grounded`.
|
|
118
|
+
else
|
|
119
|
+
tally.other += 1;
|
|
120
|
+
}
|
|
121
|
+
return tally;
|
|
122
|
+
}
|
|
123
|
+
function tallyLine(tally, total) {
|
|
124
|
+
const parts = [
|
|
125
|
+
`${tally.grounded} GROUNDED`,
|
|
126
|
+
`${tally.speculation} SPECULATION`,
|
|
127
|
+
`${tally.noReceipt} NO_RECEIPT`,
|
|
128
|
+
];
|
|
129
|
+
if (tally.other > 0) {
|
|
130
|
+
parts.push(`${tally.other} with an unrecognised state (treat as UNGROUNDED)`);
|
|
131
|
+
}
|
|
132
|
+
return `${plural(total, 'claim')}: ${parts.join(' · ')}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* One persona-spine claim line: `[STATE] id ← RCP-… — "text"`.
|
|
136
|
+
*
|
|
137
|
+
* The receipt id is rendered INLINE rather than described, because the marker
|
|
138
|
+
* itself is what an agent pattern-matches on — a digest that says "this claim has
|
|
139
|
+
* a receipt" without printing `RCP-p0-s2` leaves the agent unable to follow it.
|
|
140
|
+
*/
|
|
141
|
+
function renderPersonaClaim(raw) {
|
|
142
|
+
const claim = asRecord(raw);
|
|
143
|
+
if (!claim)
|
|
144
|
+
return '- (unreadable claim entry)';
|
|
145
|
+
// FUL-253: the spine is the sharpest row in this file. Every line is a `- `
|
|
146
|
+
// entry whose leading `[STATE]` is what an agent keys on, so a newline in
|
|
147
|
+
// `text` — the one field here that is model-written prose over scraped input —
|
|
148
|
+
// forges a SIBLING CLAIM carrying `[GROUNDED] … ← RCP-p0-s0`: a receipt minted
|
|
149
|
+
// for a claim no check ever ran on, which is the exact invariant this digest
|
|
150
|
+
// exists to hold. `truncate` never stopped it; it bounds length, and a forged
|
|
151
|
+
// line needs one `\n` well inside the cap. Guarded on every field, not just
|
|
152
|
+
// `text`: they share the line, so any of them can end it early.
|
|
153
|
+
//
|
|
154
|
+
// The GROUNDED gates keep reading the RAW `str()` value: flattening trims, so
|
|
155
|
+
// gating on the flattened one would newly admit `" GROUNDED "` as grounded and
|
|
156
|
+
// put the row's badge at odds with `tallyStates`, which reads raw. Flattening
|
|
157
|
+
// is for the RENDER; it must not widen what counts as a receipt.
|
|
158
|
+
const rawState = str(claim.state);
|
|
159
|
+
const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
|
|
160
|
+
const id = safeInline(claim.id, 60) ?? '(no id)';
|
|
161
|
+
const personaId = safeInline(claim.personaId, 60);
|
|
162
|
+
const sourceId = safeInline(claim.sourceId, 60);
|
|
163
|
+
const text = safeInline(claim.text, CLAIM_TEXT_MAX);
|
|
164
|
+
// Gated on `state`, not on `sourceId` presence — see `renderReportClaim` for
|
|
165
|
+
// why a pointer on a non-GROUNDED claim must never render as a receipt.
|
|
166
|
+
let receipt = '';
|
|
167
|
+
if (rawState === 'GROUNDED') {
|
|
168
|
+
receipt = sourceId ? ` ← ${sourceId}` : ' · GROUNDED but no receipt id — unresolvable';
|
|
169
|
+
}
|
|
170
|
+
else if (sourceId) {
|
|
171
|
+
receipt = ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
|
|
172
|
+
}
|
|
173
|
+
const who = personaId ? ` (${personaId})` : '';
|
|
174
|
+
const body = text ? ` — "${text}"` : '';
|
|
175
|
+
return `- [${state}] ${id}${who}${receipt}${body}`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* One report-spine claim line. Differs from the persona one in two ways that
|
|
179
|
+
* matter to a reader: it carries a `section` (market/competitor) instead of a
|
|
180
|
+
* persona, and a NO_RECEIPT claim has TWO distinct provenance readings that must
|
|
181
|
+
* be told apart — model-attested (a page was read and independently vouched, no
|
|
182
|
+
* exact span pinned) versus plain model knowledge (recalled, unverified).
|
|
183
|
+
*
|
|
184
|
+
* Neither reading is grounding. The attestation's `sourceId` is printed under an
|
|
185
|
+
* explicit `model-attested →` label rather than the bare `←` used for real
|
|
186
|
+
* receipts, so the two can never be skim-read as the same thing.
|
|
187
|
+
*/
|
|
188
|
+
function renderReportClaim(raw) {
|
|
189
|
+
const claim = asRecord(raw);
|
|
190
|
+
if (!claim)
|
|
191
|
+
return '- (unreadable claim entry)';
|
|
192
|
+
// FUL-253: same guard and same raw-gate split as `renderPersonaClaim` — this
|
|
193
|
+
// spine's rows are the ones a forged `[GROUNDED] … ← RRCP-s0` would hide in.
|
|
194
|
+
const rawState = str(claim.state);
|
|
195
|
+
const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
|
|
196
|
+
const id = safeInline(claim.id, 60) ?? '(no id)';
|
|
197
|
+
const section = safeInline(claim.section, 40);
|
|
198
|
+
const sourceId = safeInline(claim.sourceId, 60);
|
|
199
|
+
const text = safeInline(claim.text, CLAIM_TEXT_MAX);
|
|
200
|
+
const attestation = asRecord(claim.attestation);
|
|
201
|
+
const attestedSourceId = safeInline(attestation?.sourceId, 60);
|
|
202
|
+
// The bare `←` arrow means RECEIPT, so it is gated on `state`, NOT on the mere
|
|
203
|
+
// presence of a `sourceId`. Keying on presence would render an explicitly
|
|
204
|
+
// unsupported claim with the same marker a span-verified one gets: the schema
|
|
205
|
+
// types `state` and `sourceId` independently and `.passthrough()`s unknown
|
|
206
|
+
// shapes, and `getReport` hands the RAW payload through when Zod rejects it, so
|
|
207
|
+
// `{ state: 'NO_RECEIPT', sourceId: 'RRCP-s0' }` genuinely reaches this code.
|
|
208
|
+
// An agent following that arrow would treat a NO_RECEIPT figure as source-checked.
|
|
209
|
+
let provenance = '';
|
|
210
|
+
if (rawState === 'GROUNDED') {
|
|
211
|
+
provenance = sourceId ? ` ← ${sourceId}` : ' · GROUNDED but no receipt id — unresolvable';
|
|
212
|
+
}
|
|
213
|
+
else if (rawState === 'NO_RECEIPT' && attestedSourceId) {
|
|
214
|
+
provenance = ` · model-attested → ${attestedSourceId} (NOT a receipt)`;
|
|
215
|
+
}
|
|
216
|
+
else if (rawState === 'NO_RECEIPT') {
|
|
217
|
+
provenance = ' · from model knowledge — unverified';
|
|
218
|
+
}
|
|
219
|
+
// A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
|
|
220
|
+
// neither hidden from the reader nor readable as grounding.
|
|
221
|
+
if (rawState !== 'GROUNDED' && sourceId) {
|
|
222
|
+
provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
|
|
223
|
+
}
|
|
224
|
+
const where = section ? ` (${section})` : '';
|
|
225
|
+
const body = text ? ` — "${text}"` : '';
|
|
226
|
+
return `- [${state}] ${id}${where}${provenance}${body}`;
|
|
227
|
+
}
|
|
228
|
+
function renderPersonaSpine(reportData, channel) {
|
|
229
|
+
const claims = asArray(reportData.claims);
|
|
230
|
+
if (claims.length === 0) {
|
|
231
|
+
return ('### Persona claim spine — NOT PRESENT on this run\n' +
|
|
232
|
+
'No persona claim registry was produced, so NOTHING in the persona/interview prose above ' +
|
|
233
|
+
'has been machine-checked. Absent is not clean: treat those statements as unverified.');
|
|
234
|
+
}
|
|
235
|
+
const tally = tallyStates(claims);
|
|
236
|
+
const shown = claims.slice(0, CLAIM_RENDER_CAP);
|
|
237
|
+
const lines = shown.map(renderPersonaClaim);
|
|
238
|
+
return (`### Persona claim spine — ${tallyLine(tally, claims.length)}\n` +
|
|
239
|
+
'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
|
|
240
|
+
'forum post its quote span was code-verified against. NO_RECEIPT can mean a bad claim OR merely ' +
|
|
241
|
+
'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
|
|
242
|
+
'discounting it. Anything not GROUNDED is unverified.\n' +
|
|
243
|
+
`${lines.join('\n')}` +
|
|
244
|
+
capNote(shown.length, claims.length, channel, 'report_data.claims'));
|
|
245
|
+
}
|
|
246
|
+
function renderReportSpine(reportData, channel) {
|
|
247
|
+
const claims = asArray(reportData.reportClaims);
|
|
248
|
+
if (claims.length === 0) {
|
|
249
|
+
return ('### Market/competitor claim spine — NOT PRESENT on this run\n' +
|
|
250
|
+
'No market/competitor claim registry was produced, so no sizing figure, trend, or competitor ' +
|
|
251
|
+
'fact in the prose above has been machine-checked. Treat every such figure as unverified.');
|
|
252
|
+
}
|
|
253
|
+
const tally = tallyStates(claims);
|
|
254
|
+
const attested = claims.filter((raw) => {
|
|
255
|
+
const claim = asRecord(raw);
|
|
256
|
+
return str(claim?.state) === 'NO_RECEIPT' && str(asRecord(claim?.attestation)?.sourceId) !== null;
|
|
257
|
+
}).length;
|
|
258
|
+
const attestedNote = attested > 0 ? ` (of which ${attested} model-attested)` : '';
|
|
259
|
+
const shown = claims.slice(0, CLAIM_RENDER_CAP);
|
|
260
|
+
const lines = shown.map(renderReportClaim);
|
|
261
|
+
return (`### Market/competitor claim spine — ${tallyLine(tally, claims.length)}${attestedNote}\n` +
|
|
262
|
+
'A SEPARATE pool from the persona spine — the two never cross. A GROUNDED claim\'s `RRCP-s{n}` ' +
|
|
263
|
+
'indexes `reportEvidence[n]`. A NO_RECEIPT claim marked `model-attested` means Claude read a ' +
|
|
264
|
+
'supporting page an independent check vouched for, but no exact span was pinned — stronger than ' +
|
|
265
|
+
'recall, still NOT a receipt. Unmarked NO_RECEIPT is recalled model knowledge, unverified. ' +
|
|
266
|
+
'Key on the state, never on whether a source id is present.\n' +
|
|
267
|
+
`${lines.join('\n')}` +
|
|
268
|
+
capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The verified spans each persona receipt has to be able to SHOW, keyed by the
|
|
272
|
+
* receipt id its claims point at (FUL-252).
|
|
273
|
+
*
|
|
274
|
+
* Gated on `state === 'GROUNDED'`, matching `renderPersonaClaim`'s own gate and for
|
|
275
|
+
* the same reason: only a GROUNDED claim's span earned a badge, so only a GROUNDED
|
|
276
|
+
* claim's span is what the excerpt owes the reader. Keying on the mere presence of
|
|
277
|
+
* `quoteSpan` would let a SPECULATION claim steer the window — pushing the excerpt
|
|
278
|
+
* away from the sentence a real receipt was verified against, in favour of one no
|
|
279
|
+
* check ever ran on. `getReport` hands the RAW payload through when Zod rejects it,
|
|
280
|
+
* so `{ state: 'SPECULATION', quoteSpan: … }` genuinely reaches this code.
|
|
281
|
+
*
|
|
282
|
+
* Read from the SHOWN slice, not from every claim: past `CLAIM_RENDER_CAP` a claim
|
|
283
|
+
* has no visible badge, so windowing a quote to support it would spend the excerpt
|
|
284
|
+
* on a claim the agent cannot see, at the cost of one it can.
|
|
285
|
+
*/
|
|
286
|
+
function collectVerifiedSpans(reportData) {
|
|
287
|
+
const spans = new Map();
|
|
288
|
+
for (const raw of asArray(reportData.claims).slice(0, CLAIM_RENDER_CAP)) {
|
|
289
|
+
const claim = asRecord(raw);
|
|
290
|
+
if (!claim || str(claim.state) !== 'GROUNDED')
|
|
291
|
+
continue;
|
|
292
|
+
const sourceId = str(claim.sourceId);
|
|
293
|
+
const span = str(claim.quoteSpan);
|
|
294
|
+
if (!sourceId || !span)
|
|
295
|
+
continue;
|
|
296
|
+
const existing = spans.get(sourceId);
|
|
297
|
+
if (existing)
|
|
298
|
+
existing.push(span);
|
|
299
|
+
else
|
|
300
|
+
spans.set(sourceId, [span]);
|
|
301
|
+
}
|
|
302
|
+
return spans;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The two receipt POOLS, so an agent that reads `← RCP-p0-s2` in a claim line can
|
|
306
|
+
* resolve the pointer without the structured channel.
|
|
307
|
+
*
|
|
308
|
+
* FUL-247: persona receipts now carry the VERBATIM QUOTE, truncated to
|
|
309
|
+
* `RECEIPT_QUOTE_MAX`. This comment used to say "the verbatim quotes stay in the
|
|
310
|
+
* structured payload", and that was the whole problem: `renderPersonaClaim` prints
|
|
311
|
+
* `claim.text`, which is a PARAPHRASE, and the pool printed only platform + url —
|
|
312
|
+
* so an agent could see that a GROUNDED claim had a receipt and where it pointed,
|
|
313
|
+
* but no agent-visible channel anywhere carried the sentence the claim was
|
|
314
|
+
* grounded ON. `structuredContent` is not a fallback for this: `_meta` is dropped
|
|
315
|
+
* by Claude Code outright, and a reader checking a claim against its evidence
|
|
316
|
+
* should not have to leave the text to do it.
|
|
317
|
+
*
|
|
318
|
+
* FUL-252: the quote is now WINDOWED around the spans of the claims pointing at it,
|
|
319
|
+
* within the same budget. Rendering the head was not merely a smaller view of the
|
|
320
|
+
* evidence — when the verified span sat past the cutoff it was a view that excluded
|
|
321
|
+
* the evidence, under a `[GROUNDED]` badge.
|
|
322
|
+
*
|
|
323
|
+
* Evidence receipts (`RRCP-`) are NOT given the same treatment: they point at web
|
|
324
|
+
* pages, whose "quote" would be a page excerpt chosen at fetch time rather than a
|
|
325
|
+
* human's own words, and the pool already carries the one thing a reader needs
|
|
326
|
+
* from them — `publishedDate`, the figure's actual recency (FUL-148).
|
|
327
|
+
*/
|
|
328
|
+
function renderReceiptPools(reportData, channel) {
|
|
329
|
+
const personas = asArray(reportData.personas);
|
|
330
|
+
const reportEvidence = asArray(reportData.reportEvidence);
|
|
331
|
+
const spansByReceipt = collectVerifiedSpans(reportData);
|
|
332
|
+
const personaReceipts = [];
|
|
333
|
+
personas.forEach((rawPersona, i) => {
|
|
334
|
+
const sources = asArray(asRecord(rawPersona)?.sources);
|
|
335
|
+
sources.forEach((rawSource, j) => {
|
|
336
|
+
const source = asRecord(rawSource);
|
|
337
|
+
// FUL-253: the quote below was already flattened — these two were not, and
|
|
338
|
+
// they sit on the SAME `- RCP-…` line, so a newline in either forges the
|
|
339
|
+
// receipt row the comment below says a forged quote would.
|
|
340
|
+
const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
|
|
341
|
+
const url = safeInline(source?.url, 200) ?? '(no url)';
|
|
342
|
+
// Indented under its own receipt line so the pool still scans as a list of
|
|
343
|
+
// pointers; a receipt with no cached quote simply has no second line, which
|
|
344
|
+
// is itself worth seeing — it is a pointer that grounds nothing until fetched.
|
|
345
|
+
//
|
|
346
|
+
// `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
|
|
347
|
+
// list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
|
|
348
|
+
// convincing `- RCP-` prefix would add an entry pointing at a source nobody
|
|
349
|
+
// retrieved. A whitespace-only quote also collapses to '' here and correctly
|
|
350
|
+
// renders as a bare pointer rather than as `""` — "the source said nothing".
|
|
351
|
+
//
|
|
352
|
+
// FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
|
|
353
|
+
// GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
|
|
354
|
+
// the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
|
|
355
|
+
// `renderPersonaClaim` prints, so the two sides of the arrow are built from
|
|
356
|
+
// the same expression and cannot drift into windowing the wrong receipt.
|
|
357
|
+
const receiptId = `RCP-p${i}-s${j}`;
|
|
358
|
+
const quote = collapseWhitespace(str(source?.quote) ?? '');
|
|
359
|
+
const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
|
|
360
|
+
const quoteLine = quote ? `\n "${clipped}"` : '';
|
|
361
|
+
personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}`);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
const evidenceReceipts = reportEvidence.map((raw, n) => {
|
|
365
|
+
const source = asRecord(raw);
|
|
366
|
+
// FUL-253: every field on this row is scraped-page metadata, and the row is
|
|
367
|
+
// what an `RRCP-s{n}` pointer resolves TO — a forged one answers a real
|
|
368
|
+
// claim's arrow with a source nobody fetched.
|
|
369
|
+
const section = safeInline(source?.section, 40) ?? 'unknown section';
|
|
370
|
+
const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
|
|
371
|
+
const url = safeInline(source?.url, 200) ?? '(no url)';
|
|
372
|
+
// FUL-148: `publishedDate` is the figure's recency; `retrievedAt` is only when
|
|
373
|
+
// WE fetched it. Rendering retrievedAt as recency would be a lie, so an absent
|
|
374
|
+
// publication date is shown honestly as unknown rather than substituted.
|
|
375
|
+
const published = safeInline(source?.publishedDate, 40) ?? 'publish date unknown';
|
|
376
|
+
return `- RRCP-s${n} (${section}) — ${platform} — ${published} — ${url}`;
|
|
377
|
+
});
|
|
378
|
+
if (personaReceipts.length === 0 && evidenceReceipts.length === 0) {
|
|
379
|
+
return ('### Receipt pools — EMPTY\n' +
|
|
380
|
+
'No cached sources were attached to this run, so no claim above can be followed to a source. ' +
|
|
381
|
+
'Any receipt id referenced in the prose is unresolvable.');
|
|
382
|
+
}
|
|
383
|
+
const sections = ['### Receipt pools'];
|
|
384
|
+
if (personaReceipts.length > 0) {
|
|
385
|
+
const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
|
|
386
|
+
sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — the target of an \`RCP-\` pointer:\n` +
|
|
387
|
+
shown.join('\n') +
|
|
388
|
+
capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
|
|
389
|
+
}
|
|
390
|
+
if (evidenceReceipts.length > 0) {
|
|
391
|
+
const shown = evidenceReceipts.slice(0, CLAIM_RENDER_CAP);
|
|
392
|
+
sections.push(`**Market/competitor evidence** (${plural(evidenceReceipts.length, 'cached page')}) — the target of an \`RRCP-\` pointer:\n` +
|
|
393
|
+
shown.join('\n') +
|
|
394
|
+
capNote(shown.length, evidenceReceipts.length, channel, 'report_data.reportEvidence'));
|
|
395
|
+
}
|
|
396
|
+
return sections.join('\n\n');
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Per-persona QA + identity grounding.
|
|
400
|
+
*
|
|
401
|
+
* `qaFlagged` is rendered as a LOUD leading warning rather than a trailing field:
|
|
402
|
+
* a flagged persona either failed a quality check or could not be checked at all
|
|
403
|
+
* (FUL-132 fail-safe), and an agent about to interview or quote it needs that
|
|
404
|
+
* before it reads the persona's name, not after.
|
|
405
|
+
*
|
|
406
|
+
* `identityPriors` and `identityProvenance` are labelled as NOT receipts, because
|
|
407
|
+
* they sit next to real receipts and the failure mode is an agent citing
|
|
408
|
+
* "calibrated against N real ICP profiles" as if it were a source.
|
|
409
|
+
*/
|
|
410
|
+
function renderPersonas(reportData, channel) {
|
|
411
|
+
const personas = asArray(reportData.personas);
|
|
412
|
+
if (personas.length === 0) {
|
|
413
|
+
return '### Personas — none carried on this run';
|
|
414
|
+
}
|
|
415
|
+
const shown = personas.slice(0, LIST_RENDER_CAP);
|
|
416
|
+
const lines = shown.map((raw, i) => {
|
|
417
|
+
const persona = asRecord(raw);
|
|
418
|
+
// FUL-253: `str()` answers "is this a string", not "is this safe on a line".
|
|
419
|
+
// These rows are `- ` entries carrying a QA-FLAGGED warning, so a newline in a
|
|
420
|
+
// persona name forges a SIBLING PERSONA that renders without the warning —
|
|
421
|
+
// the one field on this line an agent reads to decide whether to trust the
|
|
422
|
+
// rest of it. Applied to every free-text field on the row, not just the name:
|
|
423
|
+
// they share one line, so any of them can end it early.
|
|
424
|
+
const name = safeInline(persona?.name, 100) ?? `persona-${i}`;
|
|
425
|
+
const role = safeInline(persona?.role, 100);
|
|
426
|
+
const sources = asArray(persona?.sources);
|
|
427
|
+
const flagged = bool(persona?.qaFlagged);
|
|
428
|
+
const reasons = asArray(persona?.qaFlagReasons)
|
|
429
|
+
.map((r) => safeInline(r, 120))
|
|
430
|
+
.filter((r) => r !== null);
|
|
431
|
+
const insufficient = bool(persona?.insufficientEvidence);
|
|
432
|
+
const sourcesFound = num(persona?.sourcesFound);
|
|
433
|
+
const priors = asRecord(persona?.identityPriors);
|
|
434
|
+
const seniority = safeInline(priors?.seniority, 80);
|
|
435
|
+
const careerPath = safeInline(priors?.careerPath, 80);
|
|
436
|
+
const provenance = safeInline(persona?.identityProvenance, 200);
|
|
437
|
+
const bits = [];
|
|
438
|
+
bits.push(`${plural(sources.length, 'receipt')}`);
|
|
439
|
+
if (insufficient) {
|
|
440
|
+
bits.push(`⚠️ insufficientEvidence${sourcesFound !== null ? ` (only ${sourcesFound} real posts found)` : ''}`);
|
|
441
|
+
}
|
|
442
|
+
else if (sourcesFound !== null) {
|
|
443
|
+
bits.push(`sourcesFound: ${sourcesFound}`);
|
|
444
|
+
}
|
|
445
|
+
// identityPriors are TYPED ENRICHMENT, not evidence — the label says so on
|
|
446
|
+
// the same line so the two can't be conflated at a glance.
|
|
447
|
+
if (seniority)
|
|
448
|
+
bits.push(`identityPriors.seniority: ${seniority}`);
|
|
449
|
+
if (careerPath)
|
|
450
|
+
bits.push(`identityPriors.careerPath: ${careerPath}`);
|
|
451
|
+
// A flag we cannot READ is reported as unknown, never as "not flagged" — an
|
|
452
|
+
// unreadable quality signal is a reason for less confidence, not more.
|
|
453
|
+
const flag = flagged
|
|
454
|
+
? `⚠️ QA-FLAGGED${reasons.length > 0 ? ` (${reasons.join('; ')})` : ' (no reason recorded)'} — do not rely on this persona's claims. `
|
|
455
|
+
: unreadable(persona?.qaFlagged)
|
|
456
|
+
? '⚠️ qaFlagged UNREADABLE (malformed value) — treat this persona as unchecked. '
|
|
457
|
+
: '';
|
|
458
|
+
const provenanceLine = provenance
|
|
459
|
+
? `\n identityProvenance: "${provenance}" — aggregate calibration, NEVER a receipt; do not cite it as a source.`
|
|
460
|
+
: '';
|
|
461
|
+
return `- **${name}**${role ? ` (${role})` : ''} — ${flag}${bits.join(' · ')}${provenanceLine}`;
|
|
462
|
+
});
|
|
463
|
+
const flaggedCount = personas.filter((p) => bool(asRecord(p)?.qaFlagged)).length;
|
|
464
|
+
// "none QA-flagged" is a CLEAN BILL, so it is only claimed when every persona's
|
|
465
|
+
// flag was actually readable. One malformed value makes the aggregate unknown —
|
|
466
|
+
// otherwise drifted data would render as a passed quality check.
|
|
467
|
+
const unreadableCount = personas.filter((p) => unreadable(asRecord(p)?.qaFlagged)).length;
|
|
468
|
+
const header = flaggedCount > 0
|
|
469
|
+
? `### Personas — ${personas.length}, ⚠️ ${flaggedCount} QA-FLAGGED`
|
|
470
|
+
: unreadableCount > 0
|
|
471
|
+
? `### Personas — ${personas.length}, ⚠️ QA status UNREADABLE for ${unreadableCount} (not a clean result)`
|
|
472
|
+
: `### Personas — ${personas.length}, none QA-flagged`;
|
|
473
|
+
return (`${header}\n${lines.join('\n')}` +
|
|
474
|
+
capNote(shown.length, personas.length, channel, 'report_data.personas'));
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The anti-sycophancy readout — deterministically computed from the interviews,
|
|
478
|
+
* not model-opined, which is exactly why it belongs in the text: it is the one
|
|
479
|
+
* signal that says whether the personas actually pushed back or just agreed.
|
|
480
|
+
*/
|
|
481
|
+
function renderSycophancy(reportData, channel) {
|
|
482
|
+
const signals = asRecord(reportData.sycophancySignals);
|
|
483
|
+
if (!signals) {
|
|
484
|
+
return ('### Anti-sycophancy — NOT COMPUTED on this run\n' +
|
|
485
|
+
'No broken-persona / rejection readout was produced. You cannot tell from this report whether ' +
|
|
486
|
+
'the personas genuinely disagreed with the idea or simply agreed with everything.');
|
|
487
|
+
}
|
|
488
|
+
const totalRejections = num(signals.totalRejections);
|
|
489
|
+
const lowDiscriminationCount = num(signals.lowDiscriminationCount) ?? 0;
|
|
490
|
+
const personaSignals = asArray(signals.personaSignals);
|
|
491
|
+
const disagreements = asArray(signals.disagreements);
|
|
492
|
+
const lines = [];
|
|
493
|
+
// A healthy run has ≥1 explicit rejection; zero is itself the warning.
|
|
494
|
+
if (totalRejections !== null) {
|
|
495
|
+
lines.push(totalRejections === 0
|
|
496
|
+
? '⚠️ 0 explicit rejections across all personas — nobody said no to anything. A run with no ' +
|
|
497
|
+
'rejections is the classic sycophancy signature; discount the supportive findings above.'
|
|
498
|
+
: `${plural(totalRejections, 'explicit rejection')} across all personas.`);
|
|
499
|
+
}
|
|
500
|
+
if (lowDiscriminationCount > 0) {
|
|
501
|
+
const names = personaSignals
|
|
502
|
+
.filter((raw) => bool(asRecord(raw)?.lowDiscrimination))
|
|
503
|
+
// FUL-253: joined into one warning line, so a newline in a name would end
|
|
504
|
+
// the warning early and leave the rest of the flagged personas rendering
|
|
505
|
+
// as ordinary text below it.
|
|
506
|
+
.map((raw) => safeInline(asRecord(raw)?.personaName, 100) ?? '(unnamed)');
|
|
507
|
+
lines.push(`⚠️ ${lowDiscriminationCount} persona(s) flagged \`lowDiscrimination\` (uniformly supportive AND ` +
|
|
508
|
+
`rejected nothing — their answers carry little signal): ${names.join(', ')}`);
|
|
509
|
+
}
|
|
510
|
+
if (disagreements.length > 0) {
|
|
511
|
+
const shown = disagreements.slice(0, LIST_RENDER_CAP);
|
|
512
|
+
lines.push(`Hypotheses the personas SPLIT on (disagreement is signal, not noise):\n` +
|
|
513
|
+
shown
|
|
514
|
+
.map((raw) => {
|
|
515
|
+
const d = asRecord(raw);
|
|
516
|
+
const id = safeInline(d?.hypothesisId, 60) ?? '(no id)';
|
|
517
|
+
const supportive = asArray(d?.supportive).length;
|
|
518
|
+
const negative = asArray(d?.negative).length;
|
|
519
|
+
const neutral = asArray(d?.neutral).length;
|
|
520
|
+
return ` - ${id}: ${supportive} supportive / ${neutral} neutral / ${negative} pushed back`;
|
|
521
|
+
})
|
|
522
|
+
.join('\n') +
|
|
523
|
+
capNote(shown.length, disagreements.length, channel, 'report_data.sycophancySignals.disagreements'));
|
|
524
|
+
}
|
|
525
|
+
if (lines.length === 0) {
|
|
526
|
+
lines.push('Readout present but empty — treat the supportive findings above as unchecked.');
|
|
527
|
+
}
|
|
528
|
+
return `### Anti-sycophancy\n${lines.join('\n')}`;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Hypothesis verdict robustness.
|
|
532
|
+
*
|
|
533
|
+
* The load-bearing case is `flipped`: when a verdict changed under rephrasing the
|
|
534
|
+
* report's top-level `status` is NOT the verdict to act on — `downgradedStatus`
|
|
535
|
+
* is. That inversion is precisely the kind of thing an agent gets wrong when it
|
|
536
|
+
* reads only the prose, so it is stated per-hypothesis rather than as a footnote.
|
|
537
|
+
*/
|
|
538
|
+
function renderRobustness(reportData, channel) {
|
|
539
|
+
const results = asArray(reportData.hypothesisResults);
|
|
540
|
+
if (results.length === 0)
|
|
541
|
+
return '';
|
|
542
|
+
const withRobustness = results.filter((raw) => asRecord(asRecord(raw)?.robustness) !== null);
|
|
543
|
+
if (withRobustness.length === 0) {
|
|
544
|
+
return ('### Hypothesis robustness — NOT RE-TESTED on this run\n' +
|
|
545
|
+
'No verdict was re-asked under a reworded framing, so no verdict above carries a survival ' +
|
|
546
|
+
'count. Treat each as a single-shot answer.');
|
|
547
|
+
}
|
|
548
|
+
const shown = withRobustness.slice(0, LIST_RENDER_CAP);
|
|
549
|
+
const lines = shown.map((raw) => {
|
|
550
|
+
const result = asRecord(raw);
|
|
551
|
+
// FUL-253: a `- ` row whose whole point is the ⚠️ FLIPPED warning. A newline
|
|
552
|
+
// in `status` or `downgradedStatus` forges a sibling row for the same
|
|
553
|
+
// hypothesis id WITHOUT the warning — the reading that says the reported
|
|
554
|
+
// verdict is not the one to act on is exactly what a forgery would drop.
|
|
555
|
+
const id = safeInline(result?.hypothesisId, 60) ?? '(no id)';
|
|
556
|
+
const status = safeInline(result?.status, 40) ?? 'unknown';
|
|
557
|
+
const robustness = asRecord(result?.robustness);
|
|
558
|
+
const survived = num(robustness?.survived);
|
|
559
|
+
const total = num(robustness?.total);
|
|
560
|
+
const flipped = tribool(robustness?.flipped);
|
|
561
|
+
const downgraded = safeInline(robustness?.downgradedStatus, 40);
|
|
562
|
+
const count = survived !== null && total !== null ? `held ${survived}/${total} framings` : 'survival count unavailable';
|
|
563
|
+
if (flipped === true) {
|
|
564
|
+
return (`- ${id}: reported \`${status}\` — ⚠️ FLIPPED under rephrasing (${count}). ` +
|
|
565
|
+
`The verdict to act on is \`${downgraded ?? 'weaker than reported — downgrade unavailable'}\`, NOT \`${status}\`.`);
|
|
566
|
+
}
|
|
567
|
+
// An unreadable `flipped` is NOT "held" — say so rather than printing the
|
|
568
|
+
// verdict as if it had survived re-asking.
|
|
569
|
+
if (flipped === null) {
|
|
570
|
+
return `- ${id}: reported \`${status}\` — ⚠️ flip status UNREADABLE (${count}); treat this verdict as un-retested.`;
|
|
571
|
+
}
|
|
572
|
+
return `- ${id}: \`${status}\` — ${count}`;
|
|
573
|
+
});
|
|
574
|
+
const flippedCount = withRobustness.filter((raw) => bool(asRecord(asRecord(raw)?.robustness)?.flipped)).length;
|
|
575
|
+
const unreadableFlips = withRobustness.filter((raw) => tribool(asRecord(asRecord(raw)?.robustness)?.flipped) === null).length;
|
|
576
|
+
// The un-retested hypotheses are counted against the FULL result set, not just
|
|
577
|
+
// the re-tested subset: a bare "no verdict flipped" over a partially-tested run
|
|
578
|
+
// reads as "every verdict survived", when most may never have been re-asked.
|
|
579
|
+
const untested = results.length - withRobustness.length;
|
|
580
|
+
const untestedNote = untested > 0
|
|
581
|
+
? ` (${withRobustness.length}/${results.length} verdicts re-tested; the other ${untested} were NOT re-asked — treat those as single-shot)`
|
|
582
|
+
: '';
|
|
583
|
+
const header = flippedCount > 0
|
|
584
|
+
? `### Hypothesis robustness — ⚠️ ${flippedCount} verdict(s) flipped under rephrasing${untestedNote}`
|
|
585
|
+
: unreadableFlips > 0
|
|
586
|
+
? `### Hypothesis robustness — ⚠️ flip status UNREADABLE for ${unreadableFlips} verdict(s) (not a clean result)${untestedNote}`
|
|
587
|
+
: `### Hypothesis robustness — no re-tested verdict flipped${untestedNote}`;
|
|
588
|
+
return (`${header}\n${lines.join('\n')}` +
|
|
589
|
+
capNote(shown.length, withRobustness.length, channel, 'report_data.hypothesisResults'));
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* The digest heading.
|
|
593
|
+
*
|
|
594
|
+
* It says LAST explicitly because the digest is concatenated into the same text
|
|
595
|
+
* block as the report markdown, and that markdown is model-written prose we do
|
|
596
|
+
* not control: a report could contain this heading, or a fabricated "GROUNDED"
|
|
597
|
+
* section, before the real digest. The client always appends its digest at the
|
|
598
|
+
* end, so "the last one is the authoritative one" is a rule a reader can apply
|
|
599
|
+
* without trusting the prose. (One text block rather than two on purpose — a
|
|
600
|
+
* second `content` block is droppable by a consumer that reads `content[0]`,
|
|
601
|
+
* which is the failure mode this whole change exists to remove.)
|
|
602
|
+
*/
|
|
603
|
+
const DIGEST_HEADING = '## Trust digest — machine-checked (appended by the Clien.ai MCP client; NOT part of the report prose)\n' +
|
|
604
|
+
'_Authoritative only as the LAST such section in this message: everything above it is report prose, ' +
|
|
605
|
+
'which can contain text resembling this digest but is not machine-checked._';
|
|
606
|
+
/**
|
|
607
|
+
* The banner shown when there is no trust data to digest at all.
|
|
608
|
+
*
|
|
609
|
+
* Deliberately not silence. A report whose trust spine failed to load looks
|
|
610
|
+
* IDENTICAL in the prose to one that passed — so absence has to be announced, or
|
|
611
|
+
* the reader's default is misplaced confidence.
|
|
612
|
+
*/
|
|
613
|
+
const NO_TRUST_DATA = `${DIGEST_HEADING}\n\n` +
|
|
614
|
+
'⚠️ NO machine-checked trust data accompanied this report. Nothing in the prose above has been ' +
|
|
615
|
+
'verified against a source: there is no claim registry, no receipts, no QA flags, and no ' +
|
|
616
|
+
'anti-sycophancy readout to check it against. Treat every figure, quote, and verdict above as ' +
|
|
617
|
+
'UNVERIFIED — do not cite anything from it as source-checked.';
|
|
618
|
+
/**
|
|
619
|
+
* Build the trust digest appended to `get_report`'s text.
|
|
620
|
+
*
|
|
621
|
+
* Accepts `unknown` because `report_data` reaches us either Zod-parsed or as the
|
|
622
|
+
* raw passthrough payload (see the module header). Returns the full digest, or
|
|
623
|
+
* the explicit "no trust data" banner — never an empty string, because an empty
|
|
624
|
+
* digest and a clean one must not look the same.
|
|
625
|
+
*/
|
|
626
|
+
export function buildTrustDigest(reportData, channel) {
|
|
627
|
+
const data = asRecord(reportData);
|
|
628
|
+
if (!data)
|
|
629
|
+
return NO_TRUST_DATA;
|
|
630
|
+
const sections = [
|
|
631
|
+
renderPersonaSpine(data, channel),
|
|
632
|
+
renderReportSpine(data, channel),
|
|
633
|
+
renderReceiptPools(data, channel),
|
|
634
|
+
renderPersonas(data, channel),
|
|
635
|
+
renderSycophancy(data, channel),
|
|
636
|
+
renderRobustness(data, channel),
|
|
637
|
+
].filter((section) => section.length > 0);
|
|
638
|
+
return `${DIGEST_HEADING}\n\n${sections.join('\n\n')}`;
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=report-digest.js.map
|