harnery 0.37.0 → 0.38.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 (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -2,8 +2,16 @@ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSyn
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, resolve } from "node:path";
4
4
  import type { Command } from "commander";
5
+ import { PNG } from "pngjs";
5
6
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
6
7
  import { resolveBinName } from "../core/config.ts";
8
+ import {
9
+ CAPTURE_FIDELITY_MISMATCH_THRESHOLD,
10
+ compareBand,
11
+ decideFidelity,
12
+ type FidelityProbe,
13
+ pngDimensions,
14
+ } from "../lib/browser/capture-fidelity.ts";
7
15
  import {
8
16
  type AssertResult,
9
17
  Browser,
@@ -11,6 +19,7 @@ import {
11
19
  browserProxyGateFromEnv,
12
20
  type ContentAnnotationBox,
13
21
  type ContentChecksResult,
22
+ type CritiqueCoverage,
14
23
  type CritiqueResult,
15
24
  type CritiqueTile,
16
25
  captureDevOverlay,
@@ -34,6 +43,16 @@ import {
34
43
  writeNetscapeCookieFile,
35
44
  wslHeadedLaunchArgs,
36
45
  } from "../lib/browser/index.ts";
46
+ import {
47
+ findPackTile,
48
+ type PageReviewCaptureFidelity,
49
+ type PageReviewContextRecord,
50
+ type PageReviewExpandedTileRecord,
51
+ readPackContext,
52
+ tileId,
53
+ writePackContext,
54
+ writePackExpandedTile,
55
+ } from "../lib/browser/page-review-pack.ts";
37
56
  import {
38
57
  buildQaManifest,
39
58
  classifySignatures,
@@ -62,7 +81,7 @@ import {
62
81
  type SaveBaselineResult,
63
82
  saveBaseline,
64
83
  } from "../lib/browser/visual-diff.ts";
65
- import { CookieJar } from "../lib/cookies/index.ts";
84
+ import { applyExtraCookies, CookieJar } from "../lib/cookies/index.ts";
66
85
 
67
86
  /**
68
87
  * `harn browse <url>`: Playwright-backed page navigation with shared
@@ -117,6 +136,7 @@ interface BrowseOpts {
117
136
  store?: string;
118
137
  profile?: string;
119
138
  viewport?: string;
139
+ deviceScaleFactor?: string;
120
140
  colorScheme?: string;
121
141
  waitUntil: string;
122
142
  timeout: string;
@@ -211,6 +231,16 @@ interface BrowseOpts {
211
231
  qaStates?: string[];
212
232
  qaReuse?: boolean;
213
233
  qaReuseThreshold?: string;
234
+ // Page review pack capture (page-review-pack.ts): tiles + DOM to disk, no
235
+ // vision call; the judge stage reviews the pack after the browser closed.
236
+ reviewPack?: string;
237
+ reviewPackContext?: string;
238
+ reviewPackScope?: string[];
239
+ reviewPackBands?: boolean;
240
+ /** Re-capture ONE existing tile of --review-pack-context at --device-scale-factor. */
241
+ reviewPackExpand?: string;
242
+ /** Gate-hit rectangles (`x,y,w,h` strings) whose bands are captured past the tile cap. */
243
+ reviewPackHitRect?: string[];
214
244
  // Next.js dev-overlay capture (auto-on; --no-dev-overlay opts out)
215
245
  devOverlay?: boolean;
216
246
  }
@@ -688,6 +718,48 @@ export function registerBrowseCommand(
688
718
  "Band-diff mismatch ratio at or below which a clean tile region is reused " +
689
719
  `(default ${DEFAULT_REUSE_MISMATCH_RATIO}; lower it toward 0 for stricter reuse).`,
690
720
  )
721
+ .option(
722
+ "--review-pack <dir>",
723
+ "Capture this render into a page review pack at <dir> (contexts/<id>/: full-page " +
724
+ "screenshot, critique tiles as PNG files, serialized DOM, QA signature) and make no " +
725
+ "vision call. A judge stage (`review-pack judge`, or qa-run) reviews the tiles from disk " +
726
+ "after every browser has closed. Tiling knobs are the --check-critique-* flags.",
727
+ )
728
+ .option(
729
+ "--review-pack-context <id>",
730
+ "Context id inside the pack (default <viewport>-<theme>-<state> from --viewport, " +
731
+ "--qa-theme, --qa-state).",
732
+ )
733
+ .option(
734
+ "--review-pack-scope <selector>",
735
+ "Also tile one screenshot per element matching <selector> into the pack (repeatable).",
736
+ (value: string, previous: string[] = []) => [...previous, value],
737
+ [] as string[],
738
+ )
739
+ .option(
740
+ "--no-review-pack-bands",
741
+ "Skip full-page bands in the pack and keep only --review-pack-scope tiles (a scoped " +
742
+ "visual pass).",
743
+ )
744
+ .option(
745
+ "--review-pack-expand <tile-id>",
746
+ "Instead of capturing a context, re-render ONE existing tile of --review-pack-context " +
747
+ "(e.g. T012) at --device-scale-factor and write it beside the original as " +
748
+ "tiles/<tile>@<dpr>x.png. Existing tiles are untouched.",
749
+ )
750
+ .option(
751
+ "--review-pack-hit-rect <x,y,w,h>",
752
+ "Document-space rectangle (integer px) a deterministic gate already flagged. Every " +
753
+ "full-page band that contains it is captured even past --check-critique-max-tiles, " +
754
+ `labelled \`hit band N\` (repeatable, at most ${REVIEW_PACK_HIT_RECT_MAX}).`,
755
+ (value: string, previous: string[] = []) => [...previous, value],
756
+ [] as string[],
757
+ )
758
+ .option(
759
+ "--device-scale-factor <n>",
760
+ "Device scale factor for the browser context (default 1). 2 renders every screenshot " +
761
+ "at twice the pixels; pair with --review-pack-expand for a sharper look at one tile.",
762
+ )
691
763
  .option(
692
764
  "--no-dev-overlay",
693
765
  "Skip auto-capture of Next.js dev-overlay issues. Default: capture every queued error (kind/code/message/stack) when a <nextjs-portal> shadow root is present. Necessary because Next.js 16 + React 19 route hydration errors + most React warnings through onCaughtError → next-devtools' errorQueue, NOT through console.error, so Playwright's standard listener doesn't see them. Surfaces them in the JSON envelope under `devOverlay`.",
@@ -739,8 +811,19 @@ async function runBrowse(
739
811
  opts.cookies === false
740
812
  ? null
741
813
  : new CookieJar({ path: opts.store ?? DEFAULT_STORE, source: "harn-browse" });
814
+ if (jar) applyExtraCookies(url, jar, context?.extraCookies);
742
815
  const headed = opts.login || opts.headed;
743
816
  const viewport = parseViewport(opts.viewport ?? "desktop");
817
+ const deviceScaleFactor = parseDeviceScaleFactor(opts.deviceScaleFactor);
818
+ if (opts.reviewPackExpand !== undefined) {
819
+ if (opts.reviewPack === undefined || opts.reviewPackContext === undefined) {
820
+ throw new Error(
821
+ "--review-pack-expand requires --review-pack <dir> and --review-pack-context <id>.",
822
+ );
823
+ }
824
+ }
825
+ // Malformed hit rects fail here, before a browser launches.
826
+ parseReviewPackHitRects(opts.reviewPackHitRect);
744
827
  let colorScheme: "light" | "dark" | undefined;
745
828
  if (opts.colorScheme !== undefined) {
746
829
  if (opts.colorScheme !== "light" && opts.colorScheme !== "dark") {
@@ -768,6 +851,7 @@ async function runBrowse(
768
851
  headed,
769
852
  jar,
770
853
  viewport,
854
+ ...(deviceScaleFactor !== undefined ? { deviceScaleFactor } : {}),
771
855
  ...(colorScheme ? { colorScheme } : {}),
772
856
  navigationTimeout: Number.parseInt(opts.timeout, 10),
773
857
  waitUntil: opts.waitUntil as BrowseOpts["waitUntil"] as never,
@@ -994,12 +1078,25 @@ async function runBrowse(
994
1078
  "info",
995
1079
  );
996
1080
  }
997
- critique = await runCritique({
998
- url: navResult.url,
999
- rubric,
1000
- tiles: tilesToReview,
1001
- provider: critiqueProvider,
1002
- });
1081
+ critique = {
1082
+ ...(await runCritique({
1083
+ url: navResult.url,
1084
+ rubric,
1085
+ tiles: tilesToReview,
1086
+ provider: critiqueProvider,
1087
+ })),
1088
+ // Coverage rides on every envelope, skipped or not, so a reader can
1089
+ // tell a pass over the whole page from a pass over its first N bands.
1090
+ coverage: captured.coverage,
1091
+ };
1092
+ }
1093
+ // Page review pack capture: tiles, DOM, and signature to disk, no vision
1094
+ // call. Also before annotations, for the same reason as the critique.
1095
+ let reviewPack: ReviewPackReport | undefined;
1096
+ if (opts.reviewPack !== undefined && opts.reviewPackExpand !== undefined) {
1097
+ reviewPack = await expandReviewPackTile(browser, opts, deviceScaleFactor ?? 1);
1098
+ } else if (opts.reviewPack !== undefined) {
1099
+ reviewPack = await captureReviewPackContext(browser, url, navResult, opts, qaCapture);
1003
1100
  }
1004
1101
  // Persist the QA baseline AFTER critique so a passing run's verdicts ride
1005
1102
  // along with the snapshot (still before annotations mutate the page).
@@ -1012,6 +1109,7 @@ async function runBrowse(
1012
1109
  critique?.provider &&
1013
1110
  critique.outcome !== "skipped" &&
1014
1111
  critiqueTiles !== undefined &&
1112
+ !critique.coverage?.capped &&
1015
1113
  (!qaReuse || qaReuse.tiles_reused === 0);
1016
1114
  const persistedCritique: PersistedCritique | undefined =
1017
1115
  fullCoverage && critique && critiqueTiles
@@ -1175,6 +1273,7 @@ async function runBrowse(
1175
1273
  batchResult,
1176
1274
  qaPlan,
1177
1275
  qaReuse ? summarizeReuse(qaReuse) : undefined,
1276
+ reviewPack,
1178
1277
  );
1179
1278
  } else {
1180
1279
  await runTrioMode(
@@ -1195,6 +1294,7 @@ async function runBrowse(
1195
1294
  batchResult,
1196
1295
  qaPlan,
1197
1296
  qaReuse ? summarizeReuse(qaReuse) : undefined,
1297
+ reviewPack,
1198
1298
  );
1199
1299
  }
1200
1300
 
@@ -1440,6 +1540,7 @@ async function runPrintMode(
1440
1540
  batchResult: BatchResult | undefined,
1441
1541
  qaPlan: QaPlanReport | undefined,
1442
1542
  qaReuse: Record<string, unknown> | undefined,
1543
+ reviewPack: ReviewPackReport | undefined,
1443
1544
  ): Promise<void> {
1444
1545
  let body: string | null = null;
1445
1546
  if (opts.html) {
@@ -1476,6 +1577,7 @@ async function runPrintMode(
1476
1577
  if (devOverlay) result.devOverlay = devOverlay;
1477
1578
  if (qaPlan) result.qaPlan = qaPlan;
1478
1579
  if (qaReuse) result.qaReuse = qaReuse;
1580
+ if (reviewPack) result.reviewPack = reviewPack;
1479
1581
  if (batchResult && batchResult.clipboardReads.length > 0) {
1480
1582
  result.batchClipboardReads = batchResult.clipboardReads;
1481
1583
  }
@@ -1514,6 +1616,7 @@ async function runTrioMode(
1514
1616
  batchResult: BatchResult | undefined,
1515
1617
  qaPlan: QaPlanReport | undefined,
1516
1618
  qaReuse: Record<string, unknown> | undefined,
1619
+ reviewPack: ReviewPackReport | undefined,
1517
1620
  ): Promise<void> {
1518
1621
  const prefix = resolveOutPrefix(opts.out);
1519
1622
  mkdirSync(dirname(prefix), { recursive: true });
@@ -1610,6 +1713,7 @@ async function runTrioMode(
1610
1713
  if (devOverlay) envelope.devOverlay = devOverlay;
1611
1714
  if (qaPlan) envelope.qaPlan = qaPlan;
1612
1715
  if (qaReuse) envelope.qaReuse = qaReuse;
1716
+ if (reviewPack) envelope.reviewPack = reviewPack;
1613
1717
  if (batchResult && batchResult.clipboardReads.length > 0) {
1614
1718
  envelope.batchClipboardReads = batchResult.clipboardReads;
1615
1719
  }
@@ -2103,12 +2207,14 @@ interface QaCaptureState {
2103
2207
  * supplies the live-page inputs (signature, page height, explicit scope match
2104
2208
  * counts). Classification and manifest math live in lib/browser/qa-plan.ts.
2105
2209
  */
2106
- async function runQaPlanning(
2210
+ /** Capture the live page's QA signature and serialized DOM for the store key
2211
+ * `--qa-target` (default: the url argument) and the labelled context. */
2212
+ async function captureQaState(
2107
2213
  browser: Browser,
2108
2214
  targetArg: string,
2109
2215
  renderedUrl: string,
2110
2216
  opts: BrowseOpts,
2111
- ): Promise<{ report: QaPlanReport; capture: QaCaptureState }> {
2217
+ ): Promise<QaCaptureState> {
2112
2218
  const context: QaContext = {
2113
2219
  viewport: opts.viewport ?? "desktop",
2114
2220
  theme: opts.qaTheme === "dark" ? "dark" : "light",
@@ -2123,11 +2229,22 @@ async function runQaPlanning(
2123
2229
  stylesheets: captured.stylesheets,
2124
2230
  ...(captured.truncated ? { truncated: true } : {}),
2125
2231
  };
2232
+ return { target, context, signature, domHtml: captured.domHtml };
2233
+ }
2234
+
2235
+ async function runQaPlanning(
2236
+ browser: Browser,
2237
+ targetArg: string,
2238
+ renderedUrl: string,
2239
+ opts: BrowseOpts,
2240
+ ): Promise<{ report: QaPlanReport; capture: QaCaptureState }> {
2241
+ const capture = await captureQaState(browser, targetArg, renderedUrl, opts);
2242
+ const { target, context, signature } = capture;
2126
2243
  const report: QaPlanReport = {
2127
2244
  signature: {
2128
- nodes: captured.nodes.length,
2129
- stylesheets: captured.stylesheets.length,
2130
- truncated: captured.truncated,
2245
+ nodes: signature.nodes.length,
2246
+ stylesheets: signature.stylesheets.length,
2247
+ truncated: Boolean(signature.truncated),
2131
2248
  },
2132
2249
  };
2133
2250
 
@@ -2165,20 +2282,324 @@ async function runQaPlanning(
2165
2282
  });
2166
2283
  }
2167
2284
 
2168
- return { report, capture: { target, context, signature, domHtml: captured.domHtml } };
2285
+ return { report, capture };
2169
2286
  }
2170
2287
 
2171
- async function captureCritiqueTiles(
2172
- browser: Browser,
2288
+ /** Tiling knobs shared by the critique and the review-pack capture. The
2289
+ * routed provider's vision long-edge budget caps band height so tiles are
2290
+ * never silently downscaled by the model. */
2291
+ function critiqueTilingKnobs(
2173
2292
  opts: BrowseOpts,
2174
2293
  tileBudgetPx?: number,
2175
- ): Promise<{ tiles: CritiqueTile[]; fullPage: Buffer }> {
2294
+ ): { maxTiles: number; band: number; overlap: number } {
2176
2295
  const maxTiles = Math.max(1, Number.parseInt(opts.checkCritiqueMaxTiles ?? "24", 10));
2177
- // The routed provider's vision long-edge budget caps band height so tiles
2178
- // are never silently downscaled by the model.
2179
2296
  const bandFlag = Math.max(200, Number.parseInt(opts.checkCritiqueBand ?? "1400", 10));
2180
2297
  const band = tileBudgetPx ? Math.max(200, Math.min(bandFlag, tileBudgetPx)) : bandFlag;
2181
2298
  const overlap = Math.max(0, Number.parseInt(opts.checkCritiqueOverlap ?? "120", 10));
2299
+ return { maxTiles, band, overlap };
2300
+ }
2301
+
2302
+ /** What `--review-pack` puts in the JSON envelope. */
2303
+ interface ReviewPackReport {
2304
+ dir: string;
2305
+ context_id: string;
2306
+ tiles: number;
2307
+ scopes: Array<{ selector: string; tiles: number }>;
2308
+ coverage: CritiqueCoverage;
2309
+ files: PageReviewContextRecord["files"];
2310
+ /** Bands captured past the tile cap for --review-pack-hit-rect rectangles. */
2311
+ hit_bands?: number;
2312
+ /** Where the tiles came from and what the fidelity probe saw. */
2313
+ capture_fidelity?: PageReviewCaptureFidelity;
2314
+ /** Capture warnings worth surfacing beside coverage (fidelity fallback, failed probes). */
2315
+ warnings?: string[];
2316
+ /** Set by --review-pack-expand: the one region that was re-captured. */
2317
+ expanded?: PageReviewExpandedTileRecord;
2318
+ }
2319
+
2320
+ /**
2321
+ * `--review-pack-expand`: re-render one existing tile of a pack context at
2322
+ * the context's device scale factor. The tile's CSS-pixel rect is captured by
2323
+ * scrolling the viewport to it and clipping (`captureRegionByScroll`), which
2324
+ * renders the region the way a reader sees it and yields tile size × DPR
2325
+ * pixels. A second full-page screenshot is not taken: it would reproduce the
2326
+ * very raster artifact the expand is meant to check. No other pack file
2327
+ * changes.
2328
+ */
2329
+ async function expandReviewPackTile(
2330
+ browser: Browser,
2331
+ opts: BrowseOpts,
2332
+ dpr: number,
2333
+ ): Promise<ReviewPackReport> {
2334
+ const packDir = opts.reviewPack as string;
2335
+ const contextId = opts.reviewPackContext as string;
2336
+ const tileId = opts.reviewPackExpand as string;
2337
+ const { tile } = findPackTile([readPackContext(packDir, contextId)], tileId, contextId);
2338
+ const region = await browser.captureRegionByScroll({
2339
+ x: tile.x,
2340
+ y: tile.scrollY,
2341
+ width: tile.width,
2342
+ height: tile.height,
2343
+ });
2344
+ const { record, expanded } = writePackExpandedTile(packDir, contextId, {
2345
+ tileId,
2346
+ dpr,
2347
+ region,
2348
+ });
2349
+ emit.log(
2350
+ `review-pack: ${record.id}/${tileId} expanded at ${dpr}× → ${expanded.width}×${expanded.height} px, ${expanded.file}`,
2351
+ "info",
2352
+ );
2353
+ return {
2354
+ dir: packDir,
2355
+ context_id: record.id,
2356
+ tiles: record.tiles.length,
2357
+ scopes: record.scopes,
2358
+ coverage: record.coverage,
2359
+ files: record.files,
2360
+ expanded,
2361
+ };
2362
+ }
2363
+
2364
+ /**
2365
+ * Capture-fidelity probe (capture-fidelity.ts). Every band is re-shot by
2366
+ * scrolling the viewport (`Browser.captureRegionByScroll`) and pixel compared
2367
+ * with the crop the tiler took from the full-page screenshot. When they all
2368
+ * agree the tiles stand and the record says `full-page`; when any disagrees,
2369
+ * every band is re-cut from its scrolled capture (same rects, same ids, only
2370
+ * the pixels change) and the record says `scrolled-bands`. A probe that
2371
+ * cannot run, or a re-capture that comes back the wrong size (a page wider
2372
+ * than the viewport), keeps the full-page crop for that band and says so in a
2373
+ * warning. Runs while the capture browser is open; the judge never opens one.
2374
+ */
2375
+ async function reconcileCaptureFidelity(
2376
+ browser: Browser,
2377
+ tiles: CritiqueTile[],
2378
+ ): Promise<{ tiles: CritiqueTile[]; fidelity: PageReviewCaptureFidelity; warnings: string[] }> {
2379
+ const threshold = CAPTURE_FIDELITY_MISMATCH_THRESHOLD;
2380
+ const warnings: string[] = [];
2381
+ const dpr = await browser.currentPage.evaluate(() => window.devicePixelRatio || 1);
2382
+ // Tile rects live in the full-page PNG's pixel space; the scroll capture
2383
+ // takes CSS px and renders at the page's DPR, so the two line up again.
2384
+ const cssRect = (tile: CritiqueTile) => ({
2385
+ x: (tile.x ?? 0) / dpr,
2386
+ y: tile.scrollY / dpr,
2387
+ width: tile.width / dpr,
2388
+ height: tile.height / dpr,
2389
+ });
2390
+ const shots = new Map<number, Buffer | undefined>();
2391
+ const shoot = async (position: number): Promise<Buffer | undefined> => {
2392
+ if (shots.has(position)) return shots.get(position);
2393
+ const tile = tiles[position] as CritiqueTile;
2394
+ let shot: Buffer | undefined;
2395
+ try {
2396
+ const captured = await browser.captureRegionByScroll(cssRect(tile));
2397
+ const size = pngDimensions(captured);
2398
+ if (Math.abs(size.width - tile.width) > 1 || Math.abs(size.height - tile.height) > 1) {
2399
+ warnings.push(
2400
+ `review-pack: scrolled capture of ${tileId(position)} came back ` +
2401
+ `${size.width}×${size.height} px for a ${tile.width}×${tile.height} px band; ` +
2402
+ "its full-page crop was kept",
2403
+ );
2404
+ } else {
2405
+ shot = captured;
2406
+ }
2407
+ } catch (err: unknown) {
2408
+ warnings.push(
2409
+ `review-pack: scrolled capture of ${tileId(position)} failed ` +
2410
+ `(${err instanceof Error ? err.message : String(err)}); its full-page crop was kept`,
2411
+ );
2412
+ }
2413
+ shots.set(position, shot);
2414
+ return shot;
2415
+ };
2416
+ // Every band is probed. A two-band sample (top and middle) missed a raster
2417
+ // artifact that sat elsewhere on the page, and once every band has been
2418
+ // shot by scrolling, the fallback below costs nothing extra: the same shots
2419
+ // become the tiles.
2420
+ const probes: FidelityProbe[] = [];
2421
+ for (let position = 0; position < tiles.length; position++) {
2422
+ const tile = tiles[position] as CritiqueTile;
2423
+ const shot = await shoot(position);
2424
+ if (!shot) continue;
2425
+ const cmp = compareBand(
2426
+ PNG.sync.read(Buffer.from(tile.pngBase64, "base64")),
2427
+ PNG.sync.read(shot),
2428
+ threshold,
2429
+ );
2430
+ probes.push({
2431
+ tile_id: tileId(position),
2432
+ scrollY: tile.scrollY,
2433
+ height: tile.height,
2434
+ mismatch_ratio: cmp.mismatch_ratio,
2435
+ });
2436
+ }
2437
+ const fidelity = decideFidelity(probes, threshold);
2438
+ if (fidelity.source === "full-page") return { tiles, fidelity, warnings };
2439
+ const recut: CritiqueTile[] = [];
2440
+ for (let position = 0; position < tiles.length; position++) {
2441
+ const tile = tiles[position] as CritiqueTile;
2442
+ const shot = await shoot(position);
2443
+ recut.push(shot ? { ...tile, pngBase64: shot.toString("base64") } : tile);
2444
+ }
2445
+ warnings.unshift(
2446
+ `review-pack: the full-page screenshot disagreed with the viewport render on ` +
2447
+ `${fidelity.mismatched.join(", ")} (mismatch ratio above ${threshold}); every band was ` +
2448
+ "re-captured by scrolling the viewport",
2449
+ );
2450
+ return { tiles: recut, fidelity, warnings };
2451
+ }
2452
+
2453
+ /**
2454
+ * Capture this render into a page review pack: one full-page screenshot,
2455
+ * banded tiles (unless --no-review-pack-bands), one tile set per
2456
+ * --review-pack-scope selector, the serialized DOM, and the QA signature —
2457
+ * all written to disk, with no vision call. The judge stage reads them back
2458
+ * once the browser is gone.
2459
+ */
2460
+ async function captureReviewPackContext(
2461
+ browser: Browser,
2462
+ targetArg: string,
2463
+ navResult: { url: string; title: string },
2464
+ opts: BrowseOpts,
2465
+ qaCapture: QaCaptureState | undefined,
2466
+ ): Promise<ReviewPackReport> {
2467
+ const packDir = opts.reviewPack as string;
2468
+ const { maxTiles, band, overlap } = critiqueTilingKnobs(opts);
2469
+ const buffer = await browser.fullPageScreenshotBuffer();
2470
+ let atoms: Awaited<ReturnType<Browser["visualAtoms"]>> | undefined;
2471
+ try {
2472
+ atoms = await browser.visualAtoms();
2473
+ } catch {
2474
+ atoms = undefined;
2475
+ }
2476
+ const tiling = { bandHeight: band, overlap, maxTiles, atoms };
2477
+ const withBands = opts.reviewPackBands !== false;
2478
+ // Gate-hit rectangles (qa-run lifts them from the gate stage, which runs
2479
+ // first) pull the bands they land in past the cap; those tiles follow the
2480
+ // kept bands as `hit band N`. Coverage still describes the kept prefix.
2481
+ const hitRects = parseReviewPackHitRects(opts.reviewPackHitRect);
2482
+ const banded = withBands
2483
+ ? tilesFromFullPage(buffer, { ...tiling, hitRects, maxHitBands: REVIEW_PACK_HIT_RECT_MAX })
2484
+ : undefined;
2485
+ if (banded?.coverage.capped) {
2486
+ emit.log(
2487
+ `review-pack: page banded to the ${maxTiles}-tile cap (raise --check-critique-max-tiles for full coverage)`,
2488
+ "warn",
2489
+ );
2490
+ }
2491
+ if (banded && banded.hitBands > 0) {
2492
+ emit.log(
2493
+ `review-pack: ${banded.hitBands} band(s) below the tile cap captured for ${hitRects.length} gate-hit rect(s)`,
2494
+ "info",
2495
+ );
2496
+ }
2497
+ // Fidelity: prove the full-page screenshot against scrolled viewport
2498
+ // renders while the browser is still open; on disagreement the bands are
2499
+ // re-cut from scrolled captures (see reconcileCaptureFidelity).
2500
+ let bandTiles = banded?.tiles ?? [];
2501
+ let captureFidelity: PageReviewCaptureFidelity | undefined;
2502
+ const fidelityWarnings: string[] = [];
2503
+ if (bandTiles.length > 0) {
2504
+ const reconciled = await reconcileCaptureFidelity(browser, bandTiles);
2505
+ bandTiles = reconciled.tiles;
2506
+ captureFidelity = reconciled.fidelity;
2507
+ fidelityWarnings.push(...reconciled.warnings);
2508
+ for (const warning of reconciled.warnings) emit.log(warning, "warn");
2509
+ emit.log(
2510
+ `review-pack: capture fidelity ${captureFidelity.source} ` +
2511
+ `(${captureFidelity.probed.length} band(s) probed, ${captureFidelity.mismatched.length} mismatched)`,
2512
+ "info",
2513
+ );
2514
+ }
2515
+ const scopeTiles: Array<{ selector: string; tiles: CritiqueTile[]; coverage: CritiqueCoverage }> =
2516
+ [];
2517
+ for (const selector of opts.reviewPackScope ?? []) {
2518
+ const elementRects = await browser.elementTiles(selector);
2519
+ const scoped = tilesFromFullPage(buffer, { ...tiling, elementRects });
2520
+ if (elementRects.length > maxTiles) {
2521
+ emit.log(
2522
+ `review-pack: ${elementRects.length} elements matched ${selector}, capped to ${maxTiles} tiles`,
2523
+ "warn",
2524
+ );
2525
+ }
2526
+ scopeTiles.push({ selector, tiles: scoped.tiles, coverage: scoped.coverage });
2527
+ }
2528
+ // Coverage: the bands when present, else the scopes folded together
2529
+ // (heights worst-case, band counts add, capped sticky).
2530
+ let coverage: CritiqueCoverage | undefined = banded?.coverage;
2531
+ if (!coverage) {
2532
+ for (const scope of scopeTiles) {
2533
+ coverage = coverage
2534
+ ? {
2535
+ page_height_px: Math.max(coverage.page_height_px, scope.coverage.page_height_px),
2536
+ reviewed_height_px: Math.min(
2537
+ coverage.reviewed_height_px,
2538
+ scope.coverage.reviewed_height_px,
2539
+ ),
2540
+ bands_total: coverage.bands_total + scope.coverage.bands_total,
2541
+ bands_reviewed: coverage.bands_reviewed + scope.coverage.bands_reviewed,
2542
+ capped: coverage.capped || scope.coverage.capped,
2543
+ }
2544
+ : scope.coverage;
2545
+ }
2546
+ }
2547
+ const page = await browser.currentPage.evaluate(() => ({
2548
+ width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
2549
+ height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
2550
+ }));
2551
+ const capture = qaCapture ?? (await captureQaState(browser, targetArg, navResult.url, opts));
2552
+ const record = writePackContext(packDir, {
2553
+ context: {
2554
+ ...capture.context,
2555
+ ...(opts.reviewPackContext ? { id: opts.reviewPackContext } : {}),
2556
+ },
2557
+ url: navResult.url,
2558
+ title: navResult.title,
2559
+ fullPage: buffer,
2560
+ pageWidth: page.width,
2561
+ pageHeight: page.height,
2562
+ tiles: bandTiles,
2563
+ coverage: coverage ?? {
2564
+ page_height_px: page.height,
2565
+ reviewed_height_px: 0,
2566
+ bands_total: 0,
2567
+ bands_reviewed: 0,
2568
+ capped: false,
2569
+ },
2570
+ scopeTiles: scopeTiles.map(({ selector, tiles }) => ({ selector, tiles })),
2571
+ signature: capture.signature,
2572
+ domHtml: capture.domHtml,
2573
+ ...(captureFidelity ? { captureFidelity } : {}),
2574
+ hitBands: banded?.hitBands ?? 0,
2575
+ });
2576
+ emit.log(
2577
+ `review-pack: ${record.id} → ${record.tiles.length} tile(s)` +
2578
+ (record.scopes.length > 0
2579
+ ? ` (${record.scopes.map((s) => `${s.selector}: ${s.tiles}`).join(", ")})`
2580
+ : "") +
2581
+ ` written to ${packDir}`,
2582
+ "info",
2583
+ );
2584
+ return {
2585
+ dir: packDir,
2586
+ context_id: record.id,
2587
+ tiles: record.tiles.length,
2588
+ scopes: record.scopes,
2589
+ coverage: record.coverage,
2590
+ files: record.files,
2591
+ ...(record.hit_bands !== undefined ? { hit_bands: record.hit_bands } : {}),
2592
+ ...(record.capture_fidelity ? { capture_fidelity: record.capture_fidelity } : {}),
2593
+ ...(fidelityWarnings.length > 0 ? { warnings: fidelityWarnings } : {}),
2594
+ };
2595
+ }
2596
+
2597
+ async function captureCritiqueTiles(
2598
+ browser: Browser,
2599
+ opts: BrowseOpts,
2600
+ tileBudgetPx?: number,
2601
+ ): Promise<{ tiles: CritiqueTile[]; fullPage: Buffer; coverage: CritiqueCoverage }> {
2602
+ const { maxTiles, band, overlap } = critiqueTilingKnobs(opts, tileBudgetPx);
2182
2603
 
2183
2604
  // Capture the whole page ONCE, then crop tiles from the pixels. Playwright's
2184
2605
  // clip is viewport-relative, so a below-fold band clip throws ("clipped area
@@ -2198,7 +2619,7 @@ async function captureCritiqueTiles(
2198
2619
  } catch {
2199
2620
  atoms = undefined;
2200
2621
  }
2201
- const tiles = tilesFromFullPage(buffer, {
2622
+ const { tiles, coverage } = tilesFromFullPage(buffer, {
2202
2623
  elementRects,
2203
2624
  bandHeight: band,
2204
2625
  overlap,
@@ -2217,7 +2638,7 @@ async function captureCritiqueTiles(
2217
2638
  "warn",
2218
2639
  );
2219
2640
  }
2220
- return { tiles, fullPage: buffer };
2641
+ return { tiles, fullPage: buffer, coverage };
2221
2642
  }
2222
2643
  function logCritiqueSummary(critique: CritiqueResult): void {
2223
2644
  if (critique.outcome === "skipped") {
@@ -2314,6 +2735,46 @@ async function waitForTerminalEnter(signal: AbortSignal): Promise<void> {
2314
2735
  });
2315
2736
  }
2316
2737
 
2738
+ /** Upper bound on --review-pack-hit-rect values (and on hit bands cut past the cap). */
2739
+ const REVIEW_PACK_HIT_RECT_MAX = 50;
2740
+
2741
+ /** `x,y,w,h` integer rectangles from repeated --review-pack-hit-rect flags. */
2742
+ function parseReviewPackHitRects(
2743
+ specs: string[] | undefined,
2744
+ ): Array<{ x: number; y: number; width: number; height: number }> {
2745
+ const list = specs ?? [];
2746
+ if (list.length > REVIEW_PACK_HIT_RECT_MAX) {
2747
+ throw new Error(
2748
+ `--review-pack-hit-rect accepts at most ${REVIEW_PACK_HIT_RECT_MAX} rectangles (got ${list.length}).`,
2749
+ );
2750
+ }
2751
+ return list.map((spec) => {
2752
+ const parts = spec.split(",").map((part) => part.trim());
2753
+ const nums = parts.map((part) => (/^-?\d+$/.test(part) ? Number.parseInt(part, 10) : NaN));
2754
+ if (parts.length !== 4 || nums.some((n) => !Number.isFinite(n))) {
2755
+ throw new Error(
2756
+ `--review-pack-hit-rect must be four integers x,y,w,h in document px (got: ${spec}).`,
2757
+ );
2758
+ }
2759
+ const [x, y, width, height] = nums as [number, number, number, number];
2760
+ if (width < 0 || height < 0) {
2761
+ throw new Error(
2762
+ `--review-pack-hit-rect width and height must not be negative (got: ${spec}).`,
2763
+ );
2764
+ }
2765
+ return { x, y, width, height };
2766
+ });
2767
+ }
2768
+
2769
+ function parseDeviceScaleFactor(spec: string | undefined): number | undefined {
2770
+ if (spec === undefined) return undefined;
2771
+ const value = Number.parseFloat(spec);
2772
+ if (!Number.isFinite(value) || value < 1 || value > 4) {
2773
+ throw new Error(`--device-scale-factor must be a number between 1 and 4 (got: ${spec})`);
2774
+ }
2775
+ return value;
2776
+ }
2777
+
2317
2778
  function parseViewport(spec: string): { width: number; height: number } {
2318
2779
  const preset = VIEWPORT_PRESETS[spec.toLowerCase()];
2319
2780
  if (preset) return preset;
@@ -96,6 +96,7 @@ async function runFetch(
96
96
  redirect: opts.redirect as RequestRedirect,
97
97
  signal: ac.signal,
98
98
  extraHeaders: context?.extraHeaders,
99
+ extraCookies: context?.extraCookies,
99
100
  responseType: "bytes",
100
101
  });
101
102
  } finally {