@nextcommerce/campaigns-os 1.52.0 → 1.53.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. package/src/tooling-setup.mjs +1 -1
@@ -12,6 +12,13 @@ export const POLISH_BROWSER_STARTUP_DEADLINE_MS = 45_000;
12
12
  export const POLISH_CAPTURE_CELL_DEADLINE_MS = 55_000;
13
13
  export const POLISH_CAPTURE_CLOSE_DEADLINE_MS = 10_000;
14
14
  export const POLISH_CAPTURE_STARTUP_DEADLINE_MS = 55_000;
15
+ // Readability bounds, beside the capture deadlines above: one cell's probe,
16
+ // every probe of a run, every crop of a run, and all readability work a run
17
+ // adds (readability-only navigation, probes and crops).
18
+ export const READABILITY_PROBE_CELL_MS = 1_500;
19
+ export const READABILITY_PROBE_RUN_MS = 120_000;
20
+ export const READABILITY_CROP_RUN_MS = 30_000;
21
+ export const READABILITY_ADDED_RUN_MS = 300_000;
15
22
 
16
23
  export function boundedPolishDeadline(value, fallback) {
17
24
  return Number.isSafeInteger(value) && value > 0 && value <= fallback ? value : fallback;
@@ -35,8 +35,22 @@ import {
35
35
  POLISH_PRODUCER_TIMEOUT_ERROR_CODE,
36
36
  polishProducerCleanupError,
37
37
  polishProducerTimeoutError,
38
+ READABILITY_ADDED_RUN_MS,
39
+ READABILITY_CROP_RUN_MS,
40
+ READABILITY_PROBE_CELL_MS,
41
+ READABILITY_PROBE_RUN_MS,
38
42
  runWithPolishProducerDeadline,
39
43
  } from "./polish-deadline.mjs";
44
+ import {
45
+ buildReadabilityCell,
46
+ buildReadabilityRecord,
47
+ readabilityLimits,
48
+ readabilityObservationOk,
49
+ readabilityRoutes,
50
+ READABILITY_PRODUCER,
51
+ READABILITY_SCHEMA,
52
+ writeReadabilityCrop,
53
+ } from "./polish-readability.mjs";
40
54
  import {
41
55
  assemblySourcePackageMaterialFingerprint,
42
56
  currentSourcePackageMaterialFingerprint,
@@ -104,6 +118,9 @@ function mappedSpecRoute(value, pageId) {
104
118
 
105
119
  const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
106
120
 
121
+ // Whether planPolishCapture refused because every mapping is template stock.
122
+ export const isNoCapturableRoutesError = (error) => error?.code === NO_CAPTURABLE_ROUTES_ERROR;
123
+
107
124
  export function planPolishCapture({ packet, baseUrl } = {}) {
108
125
  if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
109
126
  throw new Error("polish capture requires packet.source_html.pages mappings.");
@@ -293,15 +310,18 @@ function canonicalJson(value) {
293
310
  return JSON.stringify(canonicalize(value));
294
311
  }
295
312
 
296
- // Covers both package-owned capture keys: a page_load or media_weight that
297
- // changes during the browser pass refuses the attachment.
313
+ // Covers every package-owned capture key: a page_load, media_weight or
314
+ // readability record that changes during the browser pass refuses the
315
+ // attachment.
298
316
  function conflictToken(visualReview) {
299
317
  const hasPageLoad = Object.hasOwn(visualReview, "page_load");
300
318
  const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
301
- if (!hasPageLoad && !hasMediaWeight) return "absent";
319
+ const hasReadability = Object.hasOwn(visualReview, "readability");
320
+ if (!hasPageLoad && !hasMediaWeight && !hasReadability) return "absent";
302
321
  const value = {
303
322
  ...(hasPageLoad ? { value: visualReview.page_load } : {}),
304
323
  ...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
324
+ ...(hasReadability ? { readability: visualReview.readability } : {}),
305
325
  };
306
326
  return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
307
327
  }
@@ -355,6 +375,8 @@ function bindingPlanProjection(plan) {
355
375
  };
356
376
  }
357
377
 
378
+ // `plan` is null for a campaign with no capturable page_load routes (every
379
+ // mapping is template stock), where the capture measures readability only.
358
380
  export function createPolishCaptureBinding({ packet, report, plan, packetPath, targetRepo } = {}) {
359
381
  if (!isPlainObject(packet) || packet.schema_version !== "campaign-runtime-build-packet/v0") {
360
382
  throw new Error("polish capture requires a current campaign-runtime-build-packet/v0 packet.");
@@ -424,10 +446,23 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
424
446
  current_source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report),
425
447
  page_load_conflict_token: conflictToken(visualReview),
426
448
  },
427
- plan: bindingPlanProjection(plan),
449
+ plan: plan === null ? null : bindingPlanProjection(plan),
450
+ readability_routes: readabilityRouteProjection(resolvedTargetRepo, slug),
428
451
  });
429
452
  }
430
453
 
454
+ // The built routes readability captures, enumerated again for each binding,
455
+ // so a route set that changes during the browser pass refuses the attachment.
456
+ function readabilityRouteProjection(targetRepo, slug) {
457
+ const built = readabilityRoutes(targetRepo, slug);
458
+ return {
459
+ ok: built.ok === true,
460
+ routes: built.routes,
461
+ uncaptured_routes: built.uncaptured_routes,
462
+ route_enumeration_capped: built.route_enumeration_capped,
463
+ };
464
+ }
465
+
431
466
  export function assertPolishCaptureBindingUnchanged(initial, current) {
432
467
  if (canonicalJson(initial) !== canonicalJson(current)) {
433
468
  throw new Error(
@@ -462,27 +497,66 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
462
497
  };
463
498
  }
464
499
 
465
- // Attaches both records of one capture: page_load as
466
- // mergePolishPageLoadEvidence does, and its sibling media_weight under the
467
- // same producer and schema checks, bound to the same page_load subject. A
468
- // capture without media_weight (an adapter that ran no image probe) removes
469
- // any earlier media_weight, which no longer describes the attached page_load.
470
- export function mergePolishCaptureEvidence(report, { pageLoad, mediaWeight = null } = {}) {
471
- const merged = mergePolishPageLoadEvidence(report, pageLoad);
472
- const { media_weight: _previous, ...visualReview } = merged.stages.polish.evidence.visual_review;
473
- if (mediaWeight !== null && mediaWeight !== undefined) {
474
- if (!isPlainObject(mediaWeight)
475
- || mediaWeight.schema_version !== MEDIA_WEIGHT_SCHEMA
476
- || mediaWeight.performed_by !== MEDIA_WEIGHT_PRODUCER
477
- || canonicalJson(mediaWeight.subject) !== canonicalJson(pageLoad.subject)) {
478
- throw new Error("polish capture can attach only package-produced media_weight evidence for the same page_load capture.");
500
+ // Attaches the records of one capture. page_load attaches as
501
+ // mergePolishPageLoadEvidence does, stamped with `captured_at` when the
502
+ // command passes its clock `now` (the time of the merge, outside every
503
+ // capture's integrity). Its sibling media_weight attaches under the same
504
+ // producer and schema checks, bound to the same page_load subject; a capture
505
+ // without media_weight (an adapter that ran no image probe) removes any
506
+ // earlier media_weight. With no pageLoad (a campaign with no capturable
507
+ // page_load routes), page_load and media_weight stay exactly as they are.
508
+ // readability attaches on its own: only a package-produced record bound to
509
+ // the current build, whose every shared cell carries the integrity of the
510
+ // matching page_load capture; any other value, or none, removes an earlier
511
+ // one.
512
+ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, now = null } = {}) {
513
+ let merged;
514
+ if (pageLoad === null || pageLoad === undefined) {
515
+ const { visualReview } = captureReportAncestors(report);
516
+ merged = {
517
+ ...report,
518
+ stages: {
519
+ ...report.stages,
520
+ polish: {
521
+ ...report.stages.polish,
522
+ evidence: { ...report.stages.polish.evidence, visual_review: { ...visualReview } },
523
+ },
524
+ },
525
+ };
526
+ } else {
527
+ merged = mergePolishPageLoadEvidence(report, now === null || now === undefined ? pageLoad : { ...pageLoad, captured_at: new Date(now).toISOString() });
528
+ delete merged.stages.polish.evidence.visual_review.media_weight;
529
+ if (mediaWeight !== null && mediaWeight !== undefined) {
530
+ if (!isPlainObject(mediaWeight)
531
+ || mediaWeight.schema_version !== MEDIA_WEIGHT_SCHEMA
532
+ || mediaWeight.performed_by !== MEDIA_WEIGHT_PRODUCER
533
+ || canonicalJson(mediaWeight.subject) !== canonicalJson(pageLoad.subject)) {
534
+ throw new Error("polish capture can attach only package-produced media_weight evidence for the same page_load capture.");
535
+ }
536
+ merged.stages.polish.evidence.visual_review.media_weight = mediaWeight;
479
537
  }
480
- visualReview.media_weight = mediaWeight;
481
538
  }
482
- merged.stages.polish.evidence.visual_review = visualReview;
539
+ const visualReview = merged.stages.polish.evidence.visual_review;
540
+ delete visualReview.readability;
541
+ if (readabilityAttaches(readability, { buildFingerprint: currentBuildFingerprint(report), pageLoad: visualReview.page_load })) {
542
+ visualReview.readability = readability;
543
+ }
483
544
  return merged;
484
545
  }
485
546
 
547
+ function readabilityAttaches(record, { buildFingerprint, pageLoad }) {
548
+ if (!isPlainObject(record)
549
+ || record.schema_version !== READABILITY_SCHEMA
550
+ || record.performed_by !== READABILITY_PRODUCER
551
+ || !buildFingerprint
552
+ || record.subject?.build_fingerprint !== buildFingerprint
553
+ || !Array.isArray(record.cells)) return false;
554
+ const captures = Array.isArray(pageLoad?.captures) ? pageLoad.captures : [];
555
+ return record.cells.every((cell) => cell?.page_load_integrity === null || captures.some((capture) => capture?.subject?.requested_route === cell.route
556
+ && capture?.subject?.viewport === cell.viewport
557
+ && capture?.integrity?.projection_fingerprint === cell.page_load_integrity));
558
+ }
559
+
486
560
  // The spec pages the plan skips (no source mapping), for media_weight's
487
561
  // page_not_captured results: the public route of each one that has a
488
562
  // resolvable route, and the page id of each one that has none (a skipped
@@ -552,9 +626,41 @@ function captureCampaignSlug(packet, report) {
552
626
  return packetSlug;
553
627
  }
554
628
 
555
- export async function capturePolishPageLoad({
629
+ export async function capturePolishPageLoad(options = {}) {
630
+ const plan = planPolishCapture({ packet: options.packet, baseUrl: options.baseUrl });
631
+ return runPolishCapture({ ...options, plan });
632
+ }
633
+
634
+ // The readability-only capture of a campaign with no capturable page_load
635
+ // routes (every mapping is template stock): every built route is a
636
+ // readability-only cell; page_load and media_weight are not produced. A
637
+ // browser that cannot start is recorded on every cell.
638
+ export async function capturePolishReadability(options = {}) {
639
+ captureBaseUrl(options.baseUrl);
640
+ return runPolishCapture({ ...options, plan: null });
641
+ }
642
+
643
+ // Readability for one run: the built routes (readabilityRoutes) when the
644
+ // adapter has a readability probe or did not start, else null (no record is
645
+ // produced).
646
+ function readabilityScope({ adapter, startupFailed, targetRepo, slug, plan, baseUrl }) {
647
+ if (!startupFailed && typeof adapter?.probeReadabilityRoute !== "function") return null;
648
+ if (!nonemptyString(targetRepo)) return null;
649
+ const built = readabilityRoutes(resolve(targetRepo), slug);
650
+ if (!built.ok) return null;
651
+ const base = captureBaseUrl(baseUrl);
652
+ const shared = new Set((plan?.routes || []).map((route) => route.requested_route));
653
+ return {
654
+ ...built,
655
+ shared,
656
+ only: built.routes.filter((route) => !shared.has(route)).map((route) => ({ route, url: new URL(route, base).href })),
657
+ };
658
+ }
659
+
660
+ async function runPolishCapture({
556
661
  packet,
557
662
  report,
663
+ plan,
558
664
  baseUrl,
559
665
  headed = false,
560
666
  authCookie = null,
@@ -563,8 +669,9 @@ export async function capturePolishPageLoad({
563
669
  captureCellDeadlineMs,
564
670
  adapterCloseDeadlineMs,
565
671
  probeClock = null,
672
+ targetRepo = null,
673
+ now = new Date(),
566
674
  } = {}) {
567
- const plan = planPolishCapture({ packet, baseUrl });
568
675
  const slug = captureCampaignSlug(packet, report);
569
676
  const buildFingerprint = currentBuildFingerprint(report);
570
677
  if (!buildFingerprint) throw new Error("polish capture requires a current Assembly Report build fingerprint.");
@@ -582,6 +689,9 @@ export async function capturePolishPageLoad({
582
689
  );
583
690
 
584
691
  let adapter = null;
692
+ // Failure is tracked apart from its reason: a rejection may carry any value,
693
+ // including null or undefined.
694
+ let adapterStartupFailed = false;
585
695
  let adapterStartupError = null;
586
696
  let startupTimedOut = false;
587
697
  const adapterPromise = Promise.resolve().then(() => createBrowserAdapter({
@@ -604,19 +714,56 @@ export async function capturePolishPageLoad({
604
714
  throw new Error("polish capture browser adapter must provide captureRoute() and close().");
605
715
  }
606
716
  } catch (error) {
607
- adapterStartupError = error;
717
+ adapterStartupFailed = true;
718
+ adapterStartupError = error ?? new Error("polish capture browser adapter failed to start.");
608
719
  }
720
+ // A browser that did not start leaves every readability-only cell
721
+ // unmeasured: producer_timeout for a startup timeout, else navigation_failed
722
+ // (the status a shared cell reads for the same page_load failure).
723
+ const startupStatus = !adapterStartupFailed ? null
724
+ : adapterStartupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
725
+
726
+ const scope = readabilityScope({ adapter, startupFailed: adapterStartupFailed, targetRepo, slug, plan, baseUrl });
727
+ const clock = isProbeClock(probeClock) ? probeClock : { now: () => performance.now() };
728
+ // The readability budgets, charged on `clock`: probe time, crop time, and
729
+ // all readability work (shared-cell probes and crops, and readability-only
730
+ // cells end to end). Probe and crop time are subtotals of the added work, so
731
+ // a shared cell's probe and crop time is charged once to its own budget and
732
+ // once to the added budget.
733
+ const spentMs = { probe: 0, crop: 0, added: 0 };
734
+ const readabilityProbe = () => ({
735
+ ...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
736
+ cellBoundMs: READABILITY_PROBE_CELL_MS,
737
+ probeRemainingMs: READABILITY_PROBE_RUN_MS - spentMs.probe,
738
+ cropRemainingMs: READABILITY_CROP_RUN_MS - spentMs.crop,
739
+ addedRemainingMs: READABILITY_ADDED_RUN_MS - spentMs.added,
740
+ elementCap: readabilityLimits().elements_per_cell,
741
+ cropsPerCell: readabilityLimits().crops_per_cell,
742
+ });
743
+ const chargeReadability = (observation, addedMs) => {
744
+ if (Number.isFinite(observation?.probe_ms) && observation.probe_ms > 0) spentMs.probe += observation.probe_ms;
745
+ if (Number.isFinite(observation?.crop_ms) && observation.crop_ms > 0) spentMs.crop += observation.crop_ms;
746
+ if (Number.isFinite(addedMs) && addedMs > 0) spentMs.added += addedMs;
747
+ };
609
748
 
610
749
  const captures = [];
611
750
  // Per cell, beside its capture: the adapter's observation and image probe,
612
751
  // for the media_weight record.
613
752
  const cellInputs = [];
753
+ // Readability-only cells, each { route, viewport, status, observation }.
754
+ const readabilityOnly = [];
614
755
  let probeSpentMs = 0;
615
756
  let probed = false;
757
+ // As for startup, a close failure is tracked apart from its reason.
758
+ let adapterCloseFailed = false;
616
759
  let adapterCloseError = null;
617
760
  let adapterPoisonProblem = null;
618
761
  let observedProducerTimeout = false;
619
762
  let adapterClosePromise = null;
763
+ // Whether page_load's cells were all run when the adapter failed to close:
764
+ // a close failure after readability-only cells never changes page_load.
765
+ let closeFailedAfterGrid = false;
766
+ let gridDone = false;
620
767
  const closeAdapter = async () => {
621
768
  if (!adapter || typeof adapter.close !== "function") return;
622
769
  if (!adapterClosePromise) {
@@ -626,13 +773,31 @@ export async function capturePolishPageLoad({
626
773
  }
627
774
  return adapterClosePromise;
628
775
  };
776
+ // A producer timeout or cleanup failure poisons the adapter for every later
777
+ // cell, and closes it.
778
+ const poison = async (error) => {
779
+ const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
780
+ const producerCleanupFailed = error?.code === POLISH_PRODUCER_CLEANUP_ERROR_CODE;
781
+ if (producerTimedOut) observedProducerTimeout = true;
782
+ if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
783
+ adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
784
+ try {
785
+ await closeAdapter();
786
+ } catch (closeError) {
787
+ adapterCloseFailed = true;
788
+ adapterCloseError = closeError;
789
+ closeFailedAfterGrid = gridDone;
790
+ }
791
+ }
792
+ };
629
793
  try {
630
- for (const route of plan.routes) {
794
+ for (const route of plan?.routes || []) {
631
795
  for (const viewport of plan.viewports) {
632
796
  let observation;
633
797
  let cellFailed = false;
798
+ const readabilityShared = scope?.shared.has(route.requested_route) && scope.routes.includes(route.requested_route);
634
799
  try {
635
- if (adapterStartupError) throw adapterStartupError;
800
+ if (adapterStartupFailed) throw adapterStartupError;
636
801
  if (adapterPoisonProblem) {
637
802
  throw adapterPoisonProblem === "producer_timeout"
638
803
  ? polishProducerTimeoutError()
@@ -646,7 +811,13 @@ export async function capturePolishPageLoad({
646
811
  imageCap: MEDIA_PROBE_LIMITS.imageCap,
647
812
  };
648
813
  observation = await runWithPolishProducerDeadline(
649
- () => adapter.captureRoute({ url: route.url, viewport, signal: abortController.signal, imageProbe }),
814
+ () => adapter.captureRoute({
815
+ url: route.url,
816
+ viewport,
817
+ signal: abortController.signal,
818
+ imageProbe,
819
+ ...(readabilityShared ? { readabilityProbe: readabilityProbe() } : {}),
820
+ }),
650
821
  {
651
822
  timeoutMs: cellDeadlineMs,
652
823
  onTimeout() { abortController.abort(); },
@@ -656,16 +827,7 @@ export async function capturePolishPageLoad({
656
827
  } catch (error) {
657
828
  const browserUnavailable = error?.code === POLISH_BROWSER_UNAVAILABLE_ERROR_CODE;
658
829
  const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
659
- const producerCleanupFailed = error?.code === POLISH_PRODUCER_CLEANUP_ERROR_CODE;
660
- if (producerTimedOut) observedProducerTimeout = true;
661
- if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
662
- adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
663
- try {
664
- await closeAdapter();
665
- } catch (closeError) {
666
- adapterCloseError = closeError;
667
- }
668
- }
830
+ await poison(error);
669
831
  cellFailed = true;
670
832
  observation = {
671
833
  finalDocumentUrl: route.url,
@@ -679,6 +841,7 @@ export async function capturePolishPageLoad({
679
841
  const probe = !cellFailed && isPlainObject(observation.imageProbe) ? observation.imageProbe : null;
680
842
  if (probe) probed = true;
681
843
  if (Number.isFinite(probe?.spent_ms) && probe.spent_ms > 0) probeSpentMs += probe.spent_ms;
844
+ if (readabilityShared && !cellFailed) chargeReadability(observation.readability, (observation.readability?.probe_ms || 0) + (observation.readability?.crop_ms || 0));
682
845
  cellInputs.push({ route: route.requested_route, viewport: viewport.key, observation: cellFailed ? null : observation, probe });
683
846
  captures.push(buildPageLoadCapture({
684
847
  buildFingerprint,
@@ -696,24 +859,77 @@ export async function capturePolishPageLoad({
696
859
  }));
697
860
  }
698
861
  }
862
+ gridDone = true;
863
+
864
+ // Readability-only cells, after every page_load cell, each in a fresh
865
+ // context, under the producer cell deadline and the run budgets. A cell
866
+ // that starts once the added budget is spent reads run_budget_exhausted;
867
+ // after a producer timeout, every later cell reads producer_timeout and
868
+ // none is tried again.
869
+ for (const { route, url } of scope?.only || []) {
870
+ for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
871
+ const cell = { route, viewport: viewport.key, status: null, observation: null };
872
+ readabilityOnly.push(cell);
873
+ if (startupStatus) {
874
+ cell.status = startupStatus;
875
+ continue;
876
+ }
877
+ if (adapterPoisonProblem) {
878
+ cell.status = adapterPoisonProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed";
879
+ continue;
880
+ }
881
+ if (!(READABILITY_ADDED_RUN_MS - spentMs.added > 0)) {
882
+ cell.status = "run_budget_exhausted";
883
+ continue;
884
+ }
885
+ const started = clock.now();
886
+ let observation = null;
887
+ try {
888
+ const abortController = new AbortController();
889
+ observation = await runWithPolishProducerDeadline(
890
+ () => adapter.probeReadabilityRoute({ route, url }, viewport, { signal: abortController.signal, probe: readabilityProbe() }),
891
+ {
892
+ timeoutMs: cellDeadlineMs,
893
+ onTimeout() { abortController.abort(); },
894
+ },
895
+ );
896
+ } catch (error) {
897
+ await poison(error);
898
+ cell.status = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
899
+ }
900
+ const elapsedMs = Math.max(0, clock.now() - started);
901
+ chargeReadability(observation, elapsedMs);
902
+ if (cell.status) continue;
903
+ // An observation outside the record vocabulary is no measurement: its
904
+ // probe ran out its bound on the clock, or returned nothing.
905
+ if (readabilityObservationOk(observation)) {
906
+ cell.status = observation.status;
907
+ cell.observation = observation;
908
+ } else {
909
+ cell.status = elapsedMs >= READABILITY_PROBE_CELL_MS ? "probe_timeout" : "document_changed";
910
+ }
911
+ }
912
+ }
699
913
  } finally {
700
- if (adapter && !adapterCloseError) {
914
+ if (adapter && !adapterCloseFailed) {
701
915
  try {
702
916
  await closeAdapter();
703
917
  } catch (error) {
918
+ adapterCloseFailed = true;
704
919
  adapterCloseError = error;
920
+ closeFailedAfterGrid = gridDone && readabilityOnly.length > 0;
705
921
  }
706
922
  }
707
923
  }
708
924
 
709
- if (adapterCloseError) {
710
- const producerProblem = observedProducerTimeout
711
- || adapterCloseError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE
712
- ? "producer_timeout"
713
- : "producer_failed";
925
+ const closeProblem = observedProducerTimeout
926
+ || adapterCloseError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE
927
+ ? "producer_timeout"
928
+ : "producer_failed";
929
+ if (adapterCloseFailed && !closeFailedAfterGrid) {
714
930
  captures.length = 0;
715
931
  for (const input of cellInputs) Object.assign(input, { observation: null, probe: null });
716
- for (const route of plan.routes) {
932
+ for (const route of plan?.routes || []) {
717
933
  for (const viewport of plan.viewports) {
718
934
  captures.push(buildPageLoadCapture({
719
935
  buildFingerprint,
@@ -724,12 +940,29 @@ export async function capturePolishPageLoad({
724
940
  finalDocumentUrl: route.url,
725
941
  responseCollectionStatus: "failed",
726
942
  networkidle: { status: "invalid", duration_ms: null },
727
- producerProblem,
943
+ producerProblem: closeProblem,
728
944
  }));
729
945
  }
730
946
  }
731
947
  }
732
948
 
949
+ const readability = scope ? buildRunReadability({
950
+ scope,
951
+ cellInputs,
952
+ readabilityOnly,
953
+ captures,
954
+ // A browser that did not close cleanly leaves every readability cell
955
+ // unmeasured.
956
+ closeStatus: adapterCloseFailed ? (closeProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed") : null,
957
+ report,
958
+ buildFingerprint,
959
+ slug,
960
+ targetRepo,
961
+ now,
962
+ }) : null;
963
+ const withReadability = (result) => (readability ? { ...result, readability } : result);
964
+ if (plan === null) return withReadability({ plan: null });
965
+
733
966
  const routes = plan.routes.map((route) => route.requested_route);
734
967
  const viewports = plan.viewports.map((viewport) => viewport.key);
735
968
  const pageLoad = buildPolishPageLoadEvidence({
@@ -742,7 +975,7 @@ export async function capturePolishPageLoad({
742
975
  });
743
976
  // The image probe's sibling record, when the adapter ran the probe. Each
744
977
  // cell is stamped with the integrity of its page_load capture.
745
- if (!probed) return { plan, page_load: pageLoad };
978
+ if (!probed) return withReadability({ plan, page_load: pageLoad });
746
979
  const uncaptured = uncapturedPages(packet, plan);
747
980
  const mediaWeight = buildMediaWeightRecord({
748
981
  pageLoad,
@@ -754,5 +987,52 @@ export async function capturePolishPageLoad({
754
987
  uncapturedRoutes: uncaptured.routes,
755
988
  uncapturedPageIds: uncaptured.pageIds,
756
989
  });
757
- return { plan, page_load: pageLoad, media_weight: mediaWeight };
990
+ return withReadability({ plan, page_load: pageLoad, media_weight: mediaWeight });
991
+ }
992
+
993
+ // The readability record of one run: one cell per captured built route ×
994
+ // viewport. A shared cell (a route in page_load's grid) carries its probe from
995
+ // the page_load cell and that capture's integrity; it reads producer_timeout
996
+ // or navigation_failed when its page_load cell failed, and document_changed
997
+ // when the cell returned no readability read. Readability-only cells carry
998
+ // their own status.
999
+ function buildRunReadability({ scope, cellInputs, readabilityOnly, captures, closeStatus, report, buildFingerprint, slug, targetRepo, now }) {
1000
+ const writeCrop = (bytes) => writeReadabilityCrop(resolve(targetRepo), bytes);
1001
+ const cells = [];
1002
+ for (const route of scope.routes) {
1003
+ for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
1004
+ const key = viewport.key;
1005
+ if (scope.shared.has(route)) {
1006
+ const input = cellInputs.find((entry) => entry.route === route && entry.viewport === key);
1007
+ const capture = captures.find((entry) => entry.subject.requested_route === route && entry.subject.viewport === key);
1008
+ const failed = (Array.isArray(capture?.problems) ? capture.problems : []).find((problem) => ["producer_timeout", "producer_failed", "browser_unavailable"].includes(problem?.code));
1009
+ const observation = input?.observation?.readability;
1010
+ const status = closeStatus
1011
+ ?? (failed ? (failed.code === "producer_timeout" ? "producer_timeout" : "navigation_failed")
1012
+ : readabilityObservationOk(observation) ? observation.status : "document_changed");
1013
+ cells.push(buildReadabilityCell({
1014
+ route,
1015
+ viewport: key,
1016
+ pageLoadIntegrity: capture?.integrity?.projection_fingerprint ?? null,
1017
+ status,
1018
+ observation: status === "measured" ? observation : null,
1019
+ writeCrop,
1020
+ }));
1021
+ continue;
1022
+ }
1023
+ const only = readabilityOnly.find((entry) => entry.route === route && entry.viewport === key);
1024
+ const status = closeStatus ?? only?.status ?? "navigation_failed";
1025
+ cells.push(buildReadabilityCell({ route, viewport: key, status, observation: status === "measured" ? only.observation : null, writeCrop }));
1026
+ }
1027
+ }
1028
+ return buildReadabilityRecord({
1029
+ buildFingerprint,
1030
+ sourceFingerprint: currentSourcePackageMaterialFingerprint(report),
1031
+ slug,
1032
+ routes: scope.routes,
1033
+ uncapturedRoutes: scope.uncaptured_routes,
1034
+ routeEnumerationCapped: scope.route_enumeration_capped,
1035
+ cells,
1036
+ measuredAt: now,
1037
+ });
758
1038
  }