@vibecheckai/cli 3.1.8 → 3.2.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.
Files changed (36) hide show
  1. package/bin/registry.js +106 -116
  2. package/bin/runners/context/generators/mcp.js +18 -0
  3. package/bin/runners/context/index.js +72 -4
  4. package/bin/runners/context/proof-context.js +293 -1
  5. package/bin/runners/context/security-scanner.js +311 -73
  6. package/bin/runners/lib/analyzers.js +607 -20
  7. package/bin/runners/lib/detectors-v2.js +172 -15
  8. package/bin/runners/lib/entitlements-v2.js +48 -1
  9. package/bin/runners/lib/evidence-pack.js +678 -0
  10. package/bin/runners/lib/html-proof-report.js +913 -0
  11. package/bin/runners/lib/missions/plan.js +231 -41
  12. package/bin/runners/lib/missions/templates.js +125 -0
  13. package/bin/runners/lib/scan-output.js +492 -253
  14. package/bin/runners/lib/ship-output.js +901 -641
  15. package/bin/runners/runCheckpoint.js +44 -3
  16. package/bin/runners/runContext.d.ts +4 -0
  17. package/bin/runners/runDoctor.js +10 -2
  18. package/bin/runners/runFix.js +51 -341
  19. package/bin/runners/runInit.js +11 -0
  20. package/bin/runners/runPolish.d.ts +4 -0
  21. package/bin/runners/runPolish.js +608 -29
  22. package/bin/runners/runProve.js +210 -25
  23. package/bin/runners/runReality.js +846 -101
  24. package/bin/runners/runScan.js +238 -4
  25. package/bin/runners/runShip.js +19 -3
  26. package/bin/runners/runWatch.js +14 -1
  27. package/bin/vibecheck.js +32 -2
  28. package/mcp-server/consolidated-tools.js +408 -42
  29. package/mcp-server/index.js +152 -15
  30. package/mcp-server/proof-tools.js +571 -0
  31. package/mcp-server/tier-auth.js +22 -19
  32. package/mcp-server/tools-v3.js +744 -0
  33. package/mcp-server/truth-firewall-tools.js +190 -4
  34. package/package.json +3 -1
  35. package/bin/runners/runInstall.js +0 -281
  36. package/bin/runners/runLabs.js +0 -341
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Evidence Pack Builder
3
+ *
4
+ * Creates shareable "evidence packs" from vibecheck proof runs.
5
+ * Bundles videos, traces, screenshots, and findings into a single artifact.
6
+ *
7
+ * Usage:
8
+ * const { buildEvidencePack } = require('./lib/evidence-pack');
9
+ * const pack = await buildEvidencePack(projectRoot, { includeVideos: true });
10
+ */
11
+
12
+ "use strict";
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const crypto = require("crypto");
17
+ const archiver = require("archiver");
18
+
19
+ // ═══════════════════════════════════════════════════════════════════════════════
20
+ // EVIDENCE PACK SCHEMA
21
+ // ═══════════════════════════════════════════════════════════════════════════════
22
+
23
+ /**
24
+ * Evidence Pack Structure:
25
+ * {
26
+ * meta: { ... },
27
+ * summary: { ... },
28
+ * findings: [...],
29
+ * allowlist: { ... },
30
+ * artifacts: { ... }
31
+ * }
32
+ */
33
+
34
+ const EVIDENCE_PACK_VERSION = "1.0.0";
35
+
36
+ // ═══════════════════════════════════════════════════════════════════════════════
37
+ // ALLOWLIST SCHEMA
38
+ // ═══════════════════════════════════════════════════════════════════════════════
39
+
40
+ /**
41
+ * Allowlist Entry Structure:
42
+ * {
43
+ * id: string,
44
+ * findingId: string,
45
+ * pattern: string | RegExp,
46
+ * reason: string, // Why this is allowed
47
+ * addedAt: ISO timestamp,
48
+ * addedBy: string, // user/ci/auto
49
+ * expiresAt?: ISO timestamp,
50
+ * scope: 'global' | 'file' | 'line',
51
+ * file?: string,
52
+ * lines?: [start, end]
53
+ * }
54
+ */
55
+
56
+ const DEFAULT_ALLOWLIST_PATH = ".vibecheck/allowlist.json";
57
+
58
+ function loadAllowlist(projectRoot) {
59
+ const allowlistPath = path.join(projectRoot, DEFAULT_ALLOWLIST_PATH);
60
+ try {
61
+ return JSON.parse(fs.readFileSync(allowlistPath, "utf8"));
62
+ } catch {
63
+ return { version: "1.0.0", entries: [], lastUpdated: null };
64
+ }
65
+ }
66
+
67
+ function saveAllowlist(projectRoot, allowlist) {
68
+ const allowlistPath = path.join(projectRoot, DEFAULT_ALLOWLIST_PATH);
69
+ fs.mkdirSync(path.dirname(allowlistPath), { recursive: true });
70
+ allowlist.lastUpdated = new Date().toISOString();
71
+ fs.writeFileSync(allowlistPath, JSON.stringify(allowlist, null, 2), "utf8");
72
+ return allowlistPath;
73
+ }
74
+
75
+ function addToAllowlist(projectRoot, entry) {
76
+ const allowlist = loadAllowlist(projectRoot);
77
+
78
+ // Generate ID if not provided
79
+ if (!entry.id) {
80
+ entry.id = `AL_${crypto.randomBytes(4).toString("hex")}`;
81
+ }
82
+
83
+ // Set defaults
84
+ entry.addedAt = entry.addedAt || new Date().toISOString();
85
+ entry.addedBy = entry.addedBy || "user";
86
+ entry.scope = entry.scope || "global";
87
+
88
+ // Check for duplicates
89
+ const existing = allowlist.entries.find(e =>
90
+ e.pattern === entry.pattern && e.scope === entry.scope
91
+ );
92
+
93
+ if (existing) {
94
+ // Update existing entry
95
+ Object.assign(existing, entry);
96
+ } else {
97
+ allowlist.entries.push(entry);
98
+ }
99
+
100
+ saveAllowlist(projectRoot, allowlist);
101
+ return entry;
102
+ }
103
+
104
+ function isAllowlisted(finding, allowlist) {
105
+ if (!allowlist || !allowlist.entries) return false;
106
+
107
+ const now = new Date();
108
+
109
+ for (const entry of allowlist.entries) {
110
+ // Check expiration
111
+ if (entry.expiresAt && new Date(entry.expiresAt) < now) {
112
+ continue;
113
+ }
114
+
115
+ // Check by finding ID
116
+ if (entry.findingId && entry.findingId === finding.id) {
117
+ return { allowed: true, reason: entry.reason, entry };
118
+ }
119
+
120
+ // Check by pattern
121
+ if (entry.pattern) {
122
+ const pattern = typeof entry.pattern === 'string'
123
+ ? new RegExp(entry.pattern, 'i')
124
+ : entry.pattern;
125
+
126
+ // Match against finding title, page, or file
127
+ if (
128
+ pattern.test(finding.title || '') ||
129
+ pattern.test(finding.page || '') ||
130
+ pattern.test(finding.file || '') ||
131
+ (finding.evidence && finding.evidence.some(e => pattern.test(e.file || '')))
132
+ ) {
133
+ // Check scope
134
+ if (entry.scope === 'global') {
135
+ return { allowed: true, reason: entry.reason, entry };
136
+ }
137
+ if (entry.scope === 'file' && entry.file === finding.file) {
138
+ return { allowed: true, reason: entry.reason, entry };
139
+ }
140
+ if (entry.scope === 'line' && entry.file === finding.file) {
141
+ const findingLine = finding.line || (finding.evidence?.[0]?.line);
142
+ if (findingLine && entry.lines) {
143
+ const [start, end] = entry.lines;
144
+ if (findingLine >= start && findingLine <= end) {
145
+ return { allowed: true, reason: entry.reason, entry };
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ return { allowed: false };
154
+ }
155
+
156
+ function filterByAllowlist(findings, allowlist) {
157
+ const filtered = [];
158
+ const allowed = [];
159
+
160
+ for (const finding of findings) {
161
+ const result = isAllowlisted(finding, allowlist);
162
+ if (result.allowed) {
163
+ allowed.push({
164
+ finding,
165
+ reason: result.reason,
166
+ entryId: result.entry?.id
167
+ });
168
+ } else {
169
+ filtered.push(finding);
170
+ }
171
+ }
172
+
173
+ return { filtered, allowed };
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════════
177
+ // EVIDENCE STRUCTURE (What, Where, Why)
178
+ // ═══════════════════════════════════════════════════════════════════════════════
179
+
180
+ /**
181
+ * Enhanced Evidence Structure:
182
+ * {
183
+ * what: string, // What was found
184
+ * where: { // Where it was found
185
+ * file: string,
186
+ * line: number,
187
+ * column?: number,
188
+ * snippet?: string,
189
+ * url?: string // For runtime findings
190
+ * },
191
+ * why: string, // Why this is a problem
192
+ * confidence: number, // 0.0 - 1.0
193
+ * category: string,
194
+ * severity: 'BLOCK' | 'WARN' | 'INFO',
195
+ * fixHint?: string,
196
+ * related?: Evidence[]
197
+ * }
198
+ */
199
+
200
+ function enrichEvidence(finding) {
201
+ const evidence = {
202
+ what: finding.title || finding.message || 'Unknown finding',
203
+ where: {},
204
+ why: finding.reason || finding.why || 'Potential issue detected',
205
+ confidence: finding.confidence || 0.8,
206
+ category: finding.category || 'Unknown',
207
+ severity: finding.severity || 'WARN'
208
+ };
209
+
210
+ // Populate where
211
+ if (finding.file) {
212
+ evidence.where.file = finding.file;
213
+ }
214
+ if (finding.line) {
215
+ evidence.where.line = finding.line;
216
+ }
217
+ if (finding.page || finding.url) {
218
+ evidence.where.url = finding.page || finding.url;
219
+ }
220
+ if (finding.snippet || finding.code) {
221
+ evidence.where.snippet = finding.snippet || finding.code;
222
+ }
223
+ if (finding.screenshot) {
224
+ evidence.where.screenshot = finding.screenshot;
225
+ }
226
+
227
+ // Add legacy evidence array if present
228
+ if (finding.evidence && Array.isArray(finding.evidence)) {
229
+ evidence.where.file = evidence.where.file || finding.evidence[0]?.file;
230
+ evidence.where.line = evidence.where.line || finding.evidence[0]?.line;
231
+ evidence.where.snippet = evidence.where.snippet || finding.evidence[0]?.snippet;
232
+ }
233
+
234
+ // Add fix hint
235
+ if (finding.fixHints && finding.fixHints.length > 0) {
236
+ evidence.fixHint = finding.fixHints[0];
237
+ } else if (finding.fix) {
238
+ evidence.fixHint = finding.fix;
239
+ }
240
+
241
+ return evidence;
242
+ }
243
+
244
+ // ═══════════════════════════════════════════════════════════════════════════════
245
+ // EVIDENCE PACK BUILDER
246
+ // ═══════════════════════════════════════════════════════════════════════════════
247
+
248
+ async function buildEvidencePack(projectRoot, options = {}) {
249
+ const {
250
+ includeVideos = true,
251
+ includeTraces = true,
252
+ includeScreenshots = true,
253
+ includeHar = true,
254
+ outputPath = null,
255
+ proveDir = null,
256
+ realityDir = null,
257
+ applyAllowlist = true
258
+ } = options;
259
+
260
+ const outDir = path.join(projectRoot, ".vibecheck", "evidence-packs");
261
+ fs.mkdirSync(outDir, { recursive: true });
262
+
263
+ // Load latest reports
264
+ const proveReportPath = proveDir
265
+ ? path.join(proveDir, "prove_report.json")
266
+ : findLatestReport(projectRoot, "prove");
267
+ const realityReportPath = realityDir
268
+ ? path.join(realityDir, "reality_report.json")
269
+ : findLatestReport(projectRoot, "reality");
270
+ const shipReportPath = path.join(projectRoot, ".vibecheck", "last_ship.json");
271
+
272
+ let proveReport = null;
273
+ let realityReport = null;
274
+ let shipReport = null;
275
+
276
+ try { proveReport = JSON.parse(fs.readFileSync(proveReportPath, "utf8")); } catch {}
277
+ try { realityReport = JSON.parse(fs.readFileSync(realityReportPath, "utf8")); } catch {}
278
+ try { shipReport = JSON.parse(fs.readFileSync(shipReportPath, "utf8")); } catch {}
279
+
280
+ if (!proveReport && !realityReport && !shipReport) {
281
+ throw new Error("No proof reports found. Run vibecheck prove or reality first.");
282
+ }
283
+
284
+ // Load allowlist
285
+ const allowlist = applyAllowlist ? loadAllowlist(projectRoot) : null;
286
+
287
+ // Collect all findings
288
+ let allFindings = [];
289
+ if (proveReport?.findings) allFindings.push(...proveReport.findings);
290
+ if (realityReport?.findings) allFindings.push(...realityReport.findings);
291
+ if (shipReport?.findings) allFindings.push(...shipReport.findings);
292
+
293
+ // Dedupe findings by ID
294
+ const seenIds = new Set();
295
+ allFindings = allFindings.filter(f => {
296
+ if (seenIds.has(f.id)) return false;
297
+ seenIds.add(f.id);
298
+ return true;
299
+ });
300
+
301
+ // Apply allowlist filtering
302
+ let findings = allFindings;
303
+ let allowedFindings = [];
304
+ if (allowlist) {
305
+ const result = filterByAllowlist(allFindings, allowlist);
306
+ findings = result.filtered;
307
+ allowedFindings = result.allowed;
308
+ }
309
+
310
+ // Enrich evidence
311
+ const enrichedFindings = findings.map(enrichEvidence);
312
+
313
+ // Build summary
314
+ const summary = {
315
+ totalFindings: allFindings.length,
316
+ filteredFindings: findings.length,
317
+ allowlistedCount: allowedFindings.length,
318
+ blocks: findings.filter(f => f.severity === 'BLOCK').length,
319
+ warns: findings.filter(f => f.severity === 'WARN').length,
320
+ verdict: proveReport?.verdict || shipReport?.meta?.verdict || 'UNKNOWN',
321
+ coverage: realityReport?.coverage || null
322
+ };
323
+
324
+ // Collect artifacts
325
+ const artifacts = {
326
+ screenshots: [],
327
+ videos: [],
328
+ traces: [],
329
+ har: []
330
+ };
331
+
332
+ // Find artifact directories
333
+ const realityBase = realityReportPath ? path.dirname(realityReportPath) : null;
334
+
335
+ if (realityBase) {
336
+ // Screenshots
337
+ if (includeScreenshots) {
338
+ const shotsDir = path.join(realityBase, "screenshots");
339
+ if (fs.existsSync(shotsDir)) {
340
+ artifacts.screenshots = fs.readdirSync(shotsDir)
341
+ .filter(f => /\.(png|jpg|jpeg)$/i.test(f))
342
+ .map(f => path.join(shotsDir, f));
343
+ }
344
+ }
345
+
346
+ // Videos
347
+ if (includeVideos) {
348
+ const videosDir = path.join(realityBase, "videos");
349
+ if (fs.existsSync(videosDir)) {
350
+ artifacts.videos = fs.readdirSync(videosDir)
351
+ .filter(f => /\.(webm|mp4)$/i.test(f))
352
+ .map(f => path.join(videosDir, f));
353
+ }
354
+ }
355
+
356
+ // Traces
357
+ if (includeTraces) {
358
+ const tracesDir = path.join(realityBase, "traces");
359
+ if (fs.existsSync(tracesDir)) {
360
+ artifacts.traces = fs.readdirSync(tracesDir)
361
+ .filter(f => /\.zip$/i.test(f))
362
+ .map(f => path.join(tracesDir, f));
363
+ }
364
+ }
365
+
366
+ // HAR
367
+ if (includeHar) {
368
+ const harDir = path.join(realityBase, "har");
369
+ if (fs.existsSync(harDir)) {
370
+ artifacts.har = fs.readdirSync(harDir)
371
+ .filter(f => /\.har$/i.test(f))
372
+ .map(f => path.join(harDir, f));
373
+ }
374
+ }
375
+ }
376
+
377
+ // Build evidence pack manifest
378
+ const packId = `EP_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
379
+ const manifest = {
380
+ id: packId,
381
+ version: EVIDENCE_PACK_VERSION,
382
+ meta: {
383
+ createdAt: new Date().toISOString(),
384
+ projectName: path.basename(projectRoot),
385
+ projectRoot: projectRoot,
386
+ proveReport: proveReportPath ? path.relative(projectRoot, proveReportPath) : null,
387
+ realityReport: realityReportPath ? path.relative(projectRoot, realityReportPath) : null,
388
+ shipReport: shipReportPath && fs.existsSync(shipReportPath) ? path.relative(projectRoot, shipReportPath) : null
389
+ },
390
+ summary,
391
+ findings: enrichedFindings,
392
+ allowlist: allowlist ? {
393
+ applied: true,
394
+ entriesCount: allowlist.entries?.length || 0,
395
+ allowedFindings: allowedFindings.map(a => ({
396
+ findingId: a.finding.id,
397
+ reason: a.reason
398
+ }))
399
+ } : { applied: false },
400
+ artifacts: {
401
+ screenshots: artifacts.screenshots.map(p => path.relative(projectRoot, p)),
402
+ videos: artifacts.videos.map(p => path.relative(projectRoot, p)),
403
+ traces: artifacts.traces.map(p => path.relative(projectRoot, p)),
404
+ har: artifacts.har.map(p => path.relative(projectRoot, p))
405
+ }
406
+ };
407
+
408
+ // Write manifest
409
+ const manifestPath = path.join(outDir, `${packId}_manifest.json`);
410
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
411
+
412
+ // Create zip bundle if requested
413
+ let zipPath = null;
414
+ if (outputPath || artifacts.videos.length > 0 || artifacts.traces.length > 0) {
415
+ zipPath = outputPath || path.join(outDir, `${packId}.zip`);
416
+ await createEvidenceZip(projectRoot, manifest, artifacts, zipPath);
417
+ }
418
+
419
+ return {
420
+ id: packId,
421
+ manifest,
422
+ manifestPath,
423
+ zipPath,
424
+ summary
425
+ };
426
+ }
427
+
428
+ async function createEvidenceZip(projectRoot, manifest, artifacts, outputPath) {
429
+ return new Promise((resolve, reject) => {
430
+ // Check if archiver is available, otherwise skip zip creation
431
+ let archiver;
432
+ try {
433
+ archiver = require("archiver");
434
+ } catch {
435
+ // archiver not installed, skip zip creation
436
+ resolve({ skipped: true, reason: "archiver not installed" });
437
+ return;
438
+ }
439
+
440
+ const output = fs.createWriteStream(outputPath);
441
+ const archive = archiver("zip", { zlib: { level: 9 } });
442
+
443
+ output.on("close", () => resolve({ path: outputPath, size: archive.pointer() }));
444
+ archive.on("error", reject);
445
+
446
+ archive.pipe(output);
447
+
448
+ // Add manifest
449
+ archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
450
+
451
+ // Add artifacts
452
+ for (const screenshot of artifacts.screenshots) {
453
+ if (fs.existsSync(screenshot)) {
454
+ archive.file(screenshot, { name: `screenshots/${path.basename(screenshot)}` });
455
+ }
456
+ }
457
+
458
+ for (const video of artifacts.videos) {
459
+ if (fs.existsSync(video)) {
460
+ archive.file(video, { name: `videos/${path.basename(video)}` });
461
+ }
462
+ }
463
+
464
+ for (const trace of artifacts.traces) {
465
+ if (fs.existsSync(trace)) {
466
+ archive.file(trace, { name: `traces/${path.basename(trace)}` });
467
+ }
468
+ }
469
+
470
+ for (const har of artifacts.har) {
471
+ if (fs.existsSync(har)) {
472
+ archive.file(har, { name: `har/${path.basename(har)}` });
473
+ }
474
+ }
475
+
476
+ archive.finalize();
477
+ });
478
+ }
479
+
480
+ function findLatestReport(projectRoot, type) {
481
+ const baseDir = path.join(projectRoot, ".vibecheck", type);
482
+
483
+ // Check for latest.json pointer
484
+ const latestPath = path.join(baseDir, "latest.json");
485
+ try {
486
+ const latest = JSON.parse(fs.readFileSync(latestPath, "utf8"));
487
+ const reportPath = path.join(projectRoot, latest.latest, `${type}_report.json`);
488
+ if (fs.existsSync(reportPath)) return reportPath;
489
+ } catch {}
490
+
491
+ // Fall back to scanning directories
492
+ try {
493
+ const dirs = fs.readdirSync(baseDir)
494
+ .filter(d => /^\d{8}_\d{6}$/.test(d))
495
+ .sort()
496
+ .reverse();
497
+
498
+ for (const dir of dirs) {
499
+ const reportPath = path.join(baseDir, dir, `${type}_report.json`);
500
+ if (fs.existsSync(reportPath)) return reportPath;
501
+ }
502
+ } catch {}
503
+
504
+ return null;
505
+ }
506
+
507
+ // ═══════════════════════════════════════════════════════════════════════════════
508
+ // MARKDOWN REPORT GENERATOR
509
+ // ═══════════════════════════════════════════════════════════════════════════════
510
+
511
+ function generateMarkdownReport(pack) {
512
+ const { manifest, summary } = pack;
513
+ const verdict = summary.verdict;
514
+ const verdictEmoji = verdict === 'SHIP' ? '✅' : verdict === 'WARN' ? '⚠️' : '🛑';
515
+
516
+ let md = `# Evidence Pack Report
517
+
518
+ ## ${verdictEmoji} Verdict: ${verdict}
519
+
520
+ **Generated:** ${manifest.meta.createdAt}
521
+ **Project:** ${manifest.meta.projectName}
522
+
523
+ ---
524
+
525
+ ## Summary
526
+
527
+ | Metric | Value |
528
+ |--------|-------|
529
+ | Total Findings | ${summary.totalFindings} |
530
+ | Filtered (after allowlist) | ${summary.filteredFindings} |
531
+ | Allowlisted | ${summary.allowlistedCount} |
532
+ | **BLOCK** | ${summary.blocks} |
533
+ | **WARN** | ${summary.warns} |
534
+
535
+ `;
536
+
537
+ if (summary.coverage) {
538
+ md += `
539
+ ### Coverage
540
+
541
+ - **Pages Hit:** ${summary.coverage.hit} / ${summary.coverage.total}
542
+ - **Coverage:** ${summary.coverage.percent}%
543
+
544
+ `;
545
+ }
546
+
547
+ if (manifest.findings.length > 0) {
548
+ md += `
549
+ ---
550
+
551
+ ## Findings
552
+
553
+ `;
554
+
555
+ for (const finding of manifest.findings.slice(0, 20)) {
556
+ const severityIcon = finding.severity === 'BLOCK' ? '🛑' : finding.severity === 'WARN' ? '⚠️' : 'ℹ️';
557
+
558
+ md += `### ${severityIcon} ${finding.what}
559
+
560
+ - **Category:** ${finding.category}
561
+ - **Confidence:** ${Math.round(finding.confidence * 100)}%
562
+ `;
563
+
564
+ if (finding.where.file) {
565
+ md += `- **File:** \`${finding.where.file}\``;
566
+ if (finding.where.line) md += `:${finding.where.line}`;
567
+ md += '\n';
568
+ }
569
+
570
+ if (finding.where.url) {
571
+ md += `- **URL:** ${finding.where.url}\n`;
572
+ }
573
+
574
+ if (finding.where.screenshot) {
575
+ md += `- **Screenshot:** ${finding.where.screenshot}\n`;
576
+ }
577
+
578
+ md += `
579
+ **Why:** ${finding.why}
580
+ `;
581
+
582
+ if (finding.fixHint) {
583
+ md += `
584
+ **Fix:** ${finding.fixHint}
585
+ `;
586
+ }
587
+
588
+ md += '\n';
589
+ }
590
+
591
+ if (manifest.findings.length > 20) {
592
+ md += `\n_...and ${manifest.findings.length - 20} more findings. See manifest.json for full details._\n`;
593
+ }
594
+ }
595
+
596
+ if (manifest.artifacts.videos.length > 0 || manifest.artifacts.traces.length > 0) {
597
+ md += `
598
+ ---
599
+
600
+ ## Visual Artifacts
601
+
602
+ `;
603
+
604
+ if (manifest.artifacts.videos.length > 0) {
605
+ md += `### Videos
606
+
607
+ `;
608
+ for (const video of manifest.artifacts.videos) {
609
+ md += `- [${path.basename(video)}](${video})\n`;
610
+ }
611
+ }
612
+
613
+ if (manifest.artifacts.traces.length > 0) {
614
+ md += `
615
+ ### Traces
616
+
617
+ View traces at [trace.playwright.dev](https://trace.playwright.dev)
618
+
619
+ `;
620
+ for (const trace of manifest.artifacts.traces) {
621
+ md += `- [${path.basename(trace)}](${trace})\n`;
622
+ }
623
+ }
624
+ }
625
+
626
+ if (manifest.allowlist.applied && manifest.allowlist.allowedFindings.length > 0) {
627
+ md += `
628
+ ---
629
+
630
+ ## Allowlisted Findings
631
+
632
+ The following findings were filtered by the allowlist:
633
+
634
+ | Finding ID | Reason |
635
+ |------------|--------|
636
+ `;
637
+
638
+ for (const allowed of manifest.allowlist.allowedFindings.slice(0, 10)) {
639
+ md += `| \`${allowed.findingId}\` | ${allowed.reason || 'No reason provided'} |\n`;
640
+ }
641
+
642
+ if (manifest.allowlist.allowedFindings.length > 10) {
643
+ md += `\n_...and ${manifest.allowlist.allowedFindings.length - 10} more._\n`;
644
+ }
645
+ }
646
+
647
+ md += `
648
+ ---
649
+
650
+ _Generated by vibecheck evidence-pack v${EVIDENCE_PACK_VERSION}_
651
+ `;
652
+
653
+ return md;
654
+ }
655
+
656
+ // ═══════════════════════════════════════════════════════════════════════════════
657
+ // EXPORTS
658
+ // ═══════════════════════════════════════════════════════════════════════════════
659
+
660
+ module.exports = {
661
+ // Core functions
662
+ buildEvidencePack,
663
+ generateMarkdownReport,
664
+
665
+ // Allowlist functions
666
+ loadAllowlist,
667
+ saveAllowlist,
668
+ addToAllowlist,
669
+ isAllowlisted,
670
+ filterByAllowlist,
671
+
672
+ // Evidence helpers
673
+ enrichEvidence,
674
+
675
+ // Constants
676
+ EVIDENCE_PACK_VERSION,
677
+ DEFAULT_ALLOWLIST_PATH
678
+ };