scenescout 3.15.0 → 3.17.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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -1,11 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { SHARED_CHROME_ROUTE, isEmbedKey, isWorthALook, judgedMergesOf } from "./memory.js";
3
+ import { baseRoute, reachedRoutes, routeIdentity, isEmbedKey, isWorthALook, judgedMergesOf, seenOnOf, } from "./memory.js";
4
4
  import { sayVerification } from "./verify.js";
5
+ import { describePicture, readFindingPicture } from "./capture.js";
5
6
  import { feedForSession } from "./live.js";
6
7
  import { buildReplayHtml, evidenceFor } from "./replay.js";
7
8
  import { calibrate, formatCalibration } from "./calibration.js";
8
9
  import { formatPace, measurePace } from "./pace.js";
10
+ import { formatNeverSubmittedEmpty } from "./forms.js";
11
+ import { DEFAULT_REPORT_AUDIENCE, formatPlainSection, pictureOf } from "./plain.js";
12
+ import { formatTicketsPlain, formatTicketsTechnical, ticketSummaryLine } from "./tickets.js";
13
+ import { COLLECTOR_CAP } from "./collector.js";
9
14
  function playwrightSkeleton(f) {
10
15
  const routeClass = f.state.split("#")[0].split("?")[0];
11
16
  let gotoPath = routeClass;
@@ -126,11 +131,22 @@ export function formatWorthALook(items, sessionStart) {
126
131
  lines.push(`- **A defect only if** your project uses ${f.convention ?? "a convention the finding does not name"}`);
127
132
  if (f.evidence)
128
133
  lines.push(`- **Seen:** \`${f.evidence}\``);
129
- lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
134
+ lines.push(`- **Where:** \`${f.state}\` (${f.url})${alsoSeenOn(f)}`);
135
+ lines.push(...pictureLine(f));
130
136
  lines.push(``, f.detail, ``);
131
137
  }
132
138
  return lines;
133
139
  }
140
+ /** A finding's picture as a bullet, its path relative to report.md, or nothing when it has none. */
141
+ export function pictureLine(f) {
142
+ const p = readFindingPicture(f);
143
+ return p ? [`- **Picture:** \`${p.file}\` (${describePicture(p)})`] : [];
144
+ }
145
+ /** The other routes a merged finding was filed on, as the end of its Where line, or "". */
146
+ function alsoSeenOn(f) {
147
+ const routes = seenOnOf(f);
148
+ return routes.length > 0 ? `; also seen on ${routes.map((r) => `\`${r}\``).join(", ")}` : "";
149
+ }
134
150
  /**
135
151
  * How long ago a finding was last seen, for the historical index. A finding
136
152
  * nobody has re-confirmed in four months is a different thing from one seen
@@ -173,9 +189,22 @@ export function findingEvidence(memory, sessions) {
173
189
  // running at once, the run's whole log interleaves them, and the steps
174
190
  // before a finding would come from whichever lane acted last.
175
191
  const own = f.session ? sessions.find((s) => s.session === f.session) : undefined;
176
- return { id: f.id, frames: evidenceFor(own ? own.steps : all, f.foundAt) };
192
+ const picture = readFindingPicture(f);
193
+ return {
194
+ id: f.id,
195
+ frames: evidenceFor(own ? own.steps : all, f.foundAt),
196
+ ...(picture
197
+ ? {
198
+ picture: {
199
+ file: picture.file,
200
+ ...(picture.width && picture.height ? { width: picture.width, height: picture.height } : {}),
201
+ caption: describePicture(picture),
202
+ },
203
+ }
204
+ : {}),
205
+ };
177
206
  })
178
- .filter((e) => e.frames.length > 0);
207
+ .filter((e) => e.frames.length > 0 || !!e.picture);
179
208
  }
180
209
  /**
181
210
  * The whole run as one page, for the live view to serve at its own address.
@@ -278,13 +307,10 @@ function offersSubmit(elements) {
278
307
  return toks.some((t) => SUBMIT_TOKENS.has(t)) && !toks.some((t) => FILTER_TOKENS.has(t));
279
308
  });
280
309
  }
281
- /**
282
- * The collector stops at 150 elements, so on a dense page the submit control
283
- * may simply not be in the element list. "No submit found" then means "we did
284
- * not look far enough", not "there is nothing to submit" — never suppress on
285
- * that basis.
286
- */
287
- const COLLECTOR_CAP = 150;
310
+ // The collector stops at COLLECTOR_CAP elements, so on a dense page the
311
+ // submit control may simply not be in the element list. "No submit found"
312
+ // then means "we did not look far enough", not "there is nothing to submit" —
313
+ // never suppress on that basis.
288
314
  /**
289
315
  * Make app-controlled text safe inside a Markdown table cell. The backslash
290
316
  * must be escaped FIRST: escaping only the pipe turns an input of `\|` into
@@ -395,6 +421,134 @@ export function formatUnchosenOptions(dropdowns) {
395
421
  ...(dropdowns.length > 15 ? [` … +${dropdowns.length - 15} more`] : []),
396
422
  ];
397
423
  }
424
+ /**
425
+ * What scout_coverage prints, in one of two scopes. "session" is one lane's
426
+ * own remaining work: the routes it reached this run and the forms it saw.
427
+ * In a parallel run the project view lists every lane's gaps, including
428
+ * routes this lane never opened and forms its role cannot reach, and a lane
429
+ * reading those as its own either wastes turns on them or reports itself
430
+ * incomplete. "project" is everything the memory holds, each route and form
431
+ * tagged with the sessions that reached it this run. In the session scope
432
+ * each route lists only the controls on states this session recorded. `routeLine`
433
+ * is the route contract's line (formatRouteCoverage), labelled as the
434
+ * project's in the session scope.
435
+ */
436
+ export function coverageView(memory, session, scope, routeLine) {
437
+ const routes = scope === "session" ? memory.routesVisitedBy(session) : null;
438
+ // A session's own controls are the ones on the states it recorded: another
439
+ // role's table on the same route is not this session's work.
440
+ const cov = memory.coverage(routes ? { routes, states: memory.statesVisitedBy(session) } : undefined);
441
+ const inScope = (route) => routes === null || routes.has(route);
442
+ const tag = (route) => {
443
+ if (routes)
444
+ return "";
445
+ const by = memory.sessionsOnRoute(route);
446
+ return by.length > 0 ? ` (this run: ${by.join(", ")})` : "";
447
+ };
448
+ const head = routes === null
449
+ ? []
450
+ : routes.size === 0
451
+ ? [`Scope: session ${session}, which has reached no route this run yet. scope:"project" lists every session's coverage.`]
452
+ : [`Scope: session ${session} — the ${routes.size} route(s) it reached this run and the forms it saw. scope:"project" lists every session's.`];
453
+ return [
454
+ ...head,
455
+ `States known: ${cov.states} · Elements exercised: ${cov.elementsExercised}/${cov.elementsTotal}${cov.embeds.total > 0 ? ` (plus ${cov.embeds.exercised}/${cov.embeds.total} inside other sites' frames, not counted)` : ""}`,
456
+ // The route contract is the project's, every session's and every run's; a session's own figure is the head's.
457
+ routes ? `Project route contract (every session, every run): ${routeLine}` : routeLine,
458
+ `Unexercised elements by route:`,
459
+ ...cov.unexercised
460
+ .slice(0, 25)
461
+ .map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}${tag(u.state)}`),
462
+ ...formatUnchosenOptions(memory.unchosenOptions().filter((d) => inScope(d.route))),
463
+ ...formatNeverSubmittedEmpty(routes ? memory.formsNeverSubmittedEmpty(session).map(({ route, key }) => ({ route, key })) : memory.formsNeverSubmittedEmpty()),
464
+ ];
465
+ }
466
+ /**
467
+ * The gap-ledger line for pages whose scripts sent a POST that observe
468
+ * refused, or null. Some apps read through POST (a search, a report query); a
469
+ * page loading its data that way cannot be tested in observe until the user
470
+ * names the endpoint in `readPosts`. The engine cannot tell a read from a
471
+ * write, so it names the endpoints and leaves the choice to the user.
472
+ */
473
+ export function observeRefusedPostsGap(pages) {
474
+ const list = (pages ?? []).filter((p) => p.endpoints.length > 0);
475
+ if (list.length === 0)
476
+ return null;
477
+ const shown = list
478
+ .slice(0, 8)
479
+ .map((p) => `${p.route} (${p.endpoints.join(", ")})`)
480
+ .join("; ");
481
+ return (`${list.length} page(s) sent a POST that observe refused, so whatever it loads is untested: ${shown}${list.length > 8 ? " …" : ""}. ` +
482
+ `If one of these only reads (a search or query sent as POST), the user can name it in readPosts ("POST /path") and the page can be tested in observe; anything that writes stays refused.`);
483
+ }
484
+ /**
485
+ * The route lines of the gap ledger: never visited, visited but nothing
486
+ * exercised, never design-audited. All three count over ONE set, the route
487
+ * contract (`knownRoutes`), and say so, so their figures can be compared with
488
+ * each other and with the route line of scout_coverage.
489
+ *
490
+ * A state route folds into its contract route, and a UI-state query variant
491
+ * (`/things/:id?section=history`) into its base path: a tab clicked on the
492
+ * base route is recorded on the base route's state, and a variant reached by
493
+ * URL is the same page. A visited route the contract does not hold (a path
494
+ * typed by hand that does not exist) is not a route of the app.
495
+ *
496
+ * "Nothing exercised" and "never audited" judge the routes this run reached
497
+ * (`ledgerScope`), since a route an earlier run looked at and left says
498
+ * nothing about this run. Whether a control was exercised, or a route
499
+ * audited, still counts from any run.
500
+ */
501
+ function routeLedger(memory, extras) {
502
+ const lines = [];
503
+ const runStates = memory.runStates;
504
+ const scope = extras?.ledgerScope ?? (runStates.size > 0 ? "run" : "project");
505
+ const states = scope === "run" ? runStates : undefined;
506
+ const when = scope === "run" ? "this run" : "in any run";
507
+ const visited = new Set();
508
+ for (const [fp, st] of Object.entries(memory.states))
509
+ if (!states || states.has(fp))
510
+ visited.add(routeIdentity(st.route));
511
+ const hasContract = (extras?.knownRoutes?.length ?? 0) > 0;
512
+ const contract = hasContract ? [...new Set(extras.knownRoutes)] : [...visited];
513
+ const of = `of ${contract.length} ${hasContract ? "known" : "visited"} route(s)`;
514
+ const unvisited = extras?.unvisitedRoutes ?? [];
515
+ if (unvisited.length > 0) {
516
+ lines.push(`${unvisited.length} ${hasContract ? `${of} ` : "route(s) "}never visited in any run: ${unvisited.slice(0, 10).join(", ")}${unvisited.length > 10 ? " …" : ""}`);
517
+ }
518
+ // `total` and `exercised` come from coverage() so they are the SAME deduped,
519
+ // chrome-stripped count the unexercised list is drawn from. Recomputing them
520
+ // from raw state elements (as this once did) counts every state's copy of a
521
+ // shared element, and a genuinely untouched route vanished from the ledger.
522
+ // The shared-chrome pseudo-route is no state's route, so it is never visited.
523
+ const cov = memory.coverage(states ? { states } : undefined);
524
+ const groups = new Map();
525
+ for (const [route, c] of cov.perRoute) {
526
+ if (!visited.has(route))
527
+ continue;
528
+ const g = groups.get(baseRoute(route)) ?? { total: 0, exercised: 0 };
529
+ g.total += c.total;
530
+ g.exercised += c.exercised;
531
+ groups.set(baseRoute(route), g);
532
+ }
533
+ const auditedBases = new Set(Object.entries(memory.routeFacts)
534
+ .filter(([, f]) => f.audited)
535
+ .map(([r]) => baseRoute(routeIdentity(r))));
536
+ // A contract route counts as reached when it was itself, or (being a base
537
+ // path) when one of its tabs or sections was.
538
+ const reached = contract.filter(reachedRoutes(visited));
539
+ const untouched = reached.filter((r) => {
540
+ const g = groups.get(baseRoute(routeIdentity(r)));
541
+ return g !== undefined && g.total > 0 && g.exercised === 0;
542
+ });
543
+ if (untouched.length > 0) {
544
+ lines.push(`${untouched.length} ${of} visited ${when} but NOTHING exercised (looked at, never touched): ${untouched.slice(0, 8).join(", ")}${untouched.length > 8 ? " …" : ""}`);
545
+ }
546
+ const unaudited = reached.filter((r) => !auditedBases.has(baseRoute(routeIdentity(r))));
547
+ if (unaudited.length > 0) {
548
+ lines.push(`${unaudited.length} ${of} visited ${when} and never design-audited: ${unaudited.slice(0, 8).join(", ")}${unaudited.length > 8 ? " …" : ""}`);
549
+ }
550
+ return lines;
551
+ }
398
552
  /**
399
553
  * The GAP LEDGER — an explicit enumeration of what was NOT tested. This is
400
554
  * what turns "extensive" from a vibe into a verifiable claim: a run is only
@@ -403,33 +557,8 @@ export function formatUnchosenOptions(dropdowns) {
403
557
  */
404
558
  export function computeGaps(memory, extras) {
405
559
  const gaps = [];
406
- const cov = memory.coverage();
407
560
  const facts = memory.routeFacts;
408
- const visitedRoutes = [...new Set(Object.values(memory.states).map((st) => st.route))];
409
- if (extras?.unvisitedRoutes?.length) {
410
- gaps.push(`${extras.unvisitedRoutes.length} route(s) never visited: ${extras.unvisitedRoutes.slice(0, 10).join(", ")}${extras.unvisitedRoutes.length > 10 ? " …" : ""}`);
411
- }
412
- // `total` comes from coverage() so it is the SAME deduped, chrome-stripped
413
- // denominator that `keys` is a subset of. Recomputing it from raw state
414
- // elements (as this once did) counts every state's copy of a shared element,
415
- // so any route with two states had total > keys.length, the equality never
416
- // held, and a genuinely untouched route vanished from the ledger.
417
- // SHARED_CHROME_ROUTE is a pseudo-route: no state carries it, so it cannot be
418
- // "visited" and there is nothing to navigate to in order to clear it. Before
419
- // coverage() reported a total for it, it fell out of this filter by accident
420
- // (total === 0); excluding it explicitly keeps the ledger to entries a tester
421
- // can actually act on.
422
- const untouched = cov.unexercised.filter((u) => u.state !== SHARED_CHROME_ROUTE && u.total > 0 && u.keys.length === u.total);
423
- if (untouched.length > 0) {
424
- gaps.push(`${untouched.length} route(s) visited but NOTHING exercised (looked at, never touched): ${untouched
425
- .slice(0, 8)
426
- .map((u) => u.state)
427
- .join(", ")}${untouched.length > 8 ? " …" : ""}`);
428
- }
429
- const unaudited = visitedRoutes.filter((r) => !facts[r]?.audited);
430
- if (unaudited.length > 0) {
431
- gaps.push(`${unaudited.length}/${visitedRoutes.length} visited route(s) never design-audited: ${unaudited.slice(0, 8).join(", ")}${unaudited.length > 8 ? " …" : ""}`);
432
- }
561
+ gaps.push(...routeLedger(memory, extras));
433
562
  // Filled in, never committed. `mutated` records that a state-changing request
434
563
  // actually left the page; a route where someone typed, picked an option or
435
564
  // attached a file but nothing was ever submitted is a form that was looked
@@ -456,6 +585,10 @@ export function computeGaps(memory, extras) {
456
585
  ? ` — expected in observe mode, which blocks every form submission by design; what the server does with these forms is untested. Cover them in read-only mode against an environment where creating records is acceptable.`
457
586
  : ""));
458
587
  }
588
+ // From project memory, not a session: lanes close theirs before the report is written.
589
+ const refusedPosts = observeRefusedPostsGap(memory.observeRefusedPosts);
590
+ if (refusedPosts)
591
+ gaps.push(refusedPosts);
459
592
  const journeyTotal = Object.values(facts).reduce((a, f) => a + (f.journeysCompleted ?? 0), 0);
460
593
  if (journeyTotal === 0) {
461
594
  gaps.push(`no COMPLETED scout_journey measurements — task EASE is untested. An abandoned journey is a finding, not coverage: it proves a task is blocked, not that it was measured.`);
@@ -465,6 +598,40 @@ export function computeGaps(memory, extras) {
465
598
  gaps.push(`single-role run (${roles.join(", ") || "no role recorded"}) — permission boundaries and role capability gaps are untested`);
466
599
  return gaps;
467
600
  }
601
+ /** The last frame on screen before each finding was filed, by id: its picture on a recorded run. */
602
+ function lastFrames(memory) {
603
+ // A finding can carry evidence with no recorded frames (its own picture
604
+ // only), so it has no last frame to offer.
605
+ return new Map(findingEvidence(memory, replaySessions(memory))
606
+ .filter((e) => e.frames.length > 0)
607
+ .map((e) => [e.id, e.frames[e.frames.length - 1].frame]));
608
+ }
609
+ /**
610
+ * The document for an audience. `technical` is the report's lines, its title
611
+ * and date before the first section. The plain section is built only when it
612
+ * is printed: "both" puts it between the date and the technical report, which
613
+ * gets a heading of its own, and "qa" prints it alone.
614
+ */
615
+ export function withAudience(technical, audience, plain) {
616
+ if (audience === "dev")
617
+ return technical.join("\n");
618
+ // The title and date are everything before the first section.
619
+ const first = technical.findIndex((l) => l.startsWith("## "));
620
+ const split = first < 0 ? technical.length : first;
621
+ const head = technical.slice(0, split);
622
+ const body = technical.slice(split);
623
+ if (audience === "qa")
624
+ return [...head, ...plain()].join("\n");
625
+ return [
626
+ ...head,
627
+ ...plain(),
628
+ `## Technical detail`,
629
+ ``,
630
+ `Everything below is for developers: requests, oracles, routes and ids, the gap ledger and a test skeleton for each finding.`,
631
+ ``,
632
+ ...body,
633
+ ].join("\n");
634
+ }
468
635
  /**
469
636
  * The report as the run stands now. `write` is what scout_report does at the
470
637
  * end; the live view renders the same document on request without touching
@@ -508,6 +675,10 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
508
675
  lines.push(`| Finding dedup | ${judged.replace(/\|/g, "/")} |`);
509
676
  lines.push(`| Elements exercised (informational — denominator grows with every state) | ${cov.elementsExercised}/${cov.elementsTotal} |`);
510
677
  lines.push(``);
678
+ // ---- The tickets the run was given, criterion by criterion. ----
679
+ const ticketRun = memory.ticketsThisRun();
680
+ const ticketInput = { tickets: ticketRun.tickets, verdicts: ticketRun.verdicts, findings: memory.findings };
681
+ lines.push(...formatTicketsTechnical(ticketInput));
511
682
  // ---- Page quality scores, worst first — the cross-page comparator. ----
512
683
  // Scores persist across runs, so a table sorted purely by number ranks
513
684
  // yesterday's un-fixed measurement above today's re-audit and reports a page
@@ -602,7 +773,8 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
602
773
  lines.push(`- **Id:** \`${f.id}\` · **Category:** ${f.category}`);
603
774
  if (f.evidence)
604
775
  lines.push(`- **Evidence:** \`${f.evidence}\``);
605
- lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
776
+ lines.push(`- **Where:** \`${f.state}\` (${f.url})${alsoSeenOn(f)}`);
777
+ lines.push(...pictureLine(f));
606
778
  lines.push(`- **Seen in runs:** ${f.runs}`);
607
779
  // A merge the model made is shown with what was filed, so a wrong one can be seen, and refiled as its own defect (ADR 4).
608
780
  for (const m of judgedMergesOf(f)) {
@@ -703,6 +875,17 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
703
875
  lines.push(`- \`${o}\``);
704
876
  lines.push(``);
705
877
  }
878
+ if (extras?.readPosts && extras.readPosts.length > 0) {
879
+ lines.push(`## Read POSTs`);
880
+ lines.push(``);
881
+ lines.push(extras.mode === "observe"
882
+ ? `Named by the user as endpoints that only read, so observe let them out unless the path or body looked destructive or the body was a GraphQL mutation:`
883
+ : `Named as reads, but not applied: they only count in observe mode, and this run was ${extras.mode ?? "read-only"}:`);
884
+ lines.push(``);
885
+ for (const e of extras.readPosts)
886
+ lines.push(`- \`${e}\``);
887
+ lines.push(``);
888
+ }
706
889
  lines.push(...violationRollup(oracleLog.filter((v) => !v.embed)));
707
890
  lines.push(...embedSection(oracleLog.filter((v) => v.embed), cov.embeds));
708
891
  if (cov.unexercised.length > 0) {
@@ -725,7 +908,24 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
725
908
  // Only on a run that used lanes, and only once enough of them have been
726
909
  // judged for the number to mean anything; formatCalibration decides both.
727
910
  lines.push(...formatCalibration(calibrate(memory.laneDecisions, memory.findings)));
728
- const markdown = lines.join("\n");
911
+ const markdown = withAudience(lines, extras?.report ?? DEFAULT_REPORT_AUDIENCE, () => {
912
+ const frames = lastFrames(memory);
913
+ return formatPlainSection({
914
+ current,
915
+ historical: historical.length,
916
+ worthALook: worthALook.length,
917
+ violations: oracleLog.filter((v) => !v.embed),
918
+ routes: extras && extras.routesTotal > 0 ? { visited: extras.routesVisited, total: extras.routesTotal } : undefined,
919
+ gaps,
920
+ lastFrames: frames,
921
+ audience: extras?.report ?? DEFAULT_REPORT_AUDIENCE,
922
+ tickets: formatTicketsPlain({
923
+ ...ticketInput,
924
+ problemNumber: new Map(current.map((f, i) => [f.id, i + 1])),
925
+ pictureOf: (f) => pictureOf(f, frames.get(f.id)),
926
+ }),
927
+ });
928
+ });
729
929
  const outPath = path.join(memory.dir, "report.md");
730
930
  const htmlPath = path.join(memory.dir, "report.html");
731
931
  let htmlWritten = false;
@@ -774,6 +974,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
774
974
  : []),
775
975
  ``,
776
976
  `OPEN FINDINGS: ${open.length} (${open.filter((f) => f.severity === "high").length} high) — ${current.length} this session, ${historical.length} historical${resolved.length ? `, ${resolved.length} resolved` : ""}`,
977
+ ...[ticketSummaryLine(ticketRun.tickets, ticketRun.verdicts)].filter((l) => l !== null),
777
978
  ...(extras && extras.routesTotal > 0
778
979
  ? [
779
980
  `COVERAGE: routes ${extras.routesVisited}/${extras.routesTotal} · ${cov.states} states · ${cov.elementsExercised}/${cov.elementsTotal} elements exercised`,
@@ -796,5 +997,5 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
796
997
  `Gap ledger${gaps.length === 0 ? ": EMPTY — nothing known left untested" : ` (${gaps.length}):`}`,
797
998
  ...gaps.map((g) => ` ⚠ ${g}`),
798
999
  ];
799
- return { markdown, path: outPath, summary: summaryLines.join("\n") };
1000
+ return { markdown, path: outPath, summary: summaryLines.join("\n"), ...(htmlWritten ? { html: htmlPath } : {}) };
800
1001
  }