harnery 0.38.0 → 0.40.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 (378) hide show
  1. package/dist/commands/agents.d.ts.map +1 -1
  2. package/dist/commands/agents.js +32 -8
  3. package/dist/commands/artifacts.d.ts.map +1 -1
  4. package/dist/commands/artifacts.js +97 -6
  5. package/dist/commands/backup.d.ts +16 -0
  6. package/dist/commands/backup.d.ts.map +1 -1
  7. package/dist/commands/backup.js +239 -33
  8. package/dist/commands/browse.d.ts +8 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +466 -231
  11. package/dist/commands/diagnostics.d.ts.map +1 -1
  12. package/dist/commands/diagnostics.js +6 -3
  13. package/dist/commands/ledger-v3.d.ts.map +1 -1
  14. package/dist/commands/ledger-v3.js +20 -4
  15. package/dist/commands/qa-run.d.ts.map +1 -1
  16. package/dist/commands/qa-run.js +26 -3
  17. package/dist/commands/qa-status.d.ts.map +1 -1
  18. package/dist/commands/qa-status.js +7 -2
  19. package/dist/commands/resources.d.ts.map +1 -1
  20. package/dist/commands/resources.js +23 -3
  21. package/dist/commands/review-pack.d.ts.map +1 -1
  22. package/dist/commands/review-pack.js +98 -23
  23. package/dist/commands/tunnel.d.ts.map +1 -1
  24. package/dist/commands/tunnel.js +2 -2
  25. package/dist/core/adapters/profiles.d.ts +1 -1
  26. package/dist/core/agents/canonical-emit.d.ts +3 -2
  27. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  28. package/dist/core/agents/canonical-emit.js +2 -0
  29. package/dist/core/agents/cli.js +13 -6
  30. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  31. package/dist/core/agents/live-authority-v3.js +5 -3
  32. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -2
  33. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  34. package/dist/core/agents/render/prompt-context.d.ts +7 -5
  35. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  36. package/dist/core/agents/render/prompt-context.js +63 -8
  37. package/dist/core/agents/session-finalizer-v3.d.ts +2 -2
  38. package/dist/core/agents/session-finalizer-v3.d.ts.map +1 -1
  39. package/dist/core/agents/state/live-coordination-view.d.ts +3 -3
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +2 -1
  42. package/dist/core/agents/state/names.d.ts +35 -2
  43. package/dist/core/agents/state/names.d.ts.map +1 -1
  44. package/dist/core/agents/state/names.js +80 -5
  45. package/dist/core/agents/state/stale-sweep.d.ts +2 -1
  46. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  47. package/dist/core/agents/state/stale-sweep.js +23 -6
  48. package/dist/core/agents/turn-elapsed.d.ts +51 -0
  49. package/dist/core/agents/turn-elapsed.d.ts.map +1 -0
  50. package/dist/core/agents/turn-elapsed.js +91 -0
  51. package/dist/core/artifacts/activity.d.ts +14 -0
  52. package/dist/core/artifacts/activity.d.ts.map +1 -0
  53. package/dist/core/artifacts/activity.js +52 -0
  54. package/dist/core/artifacts/delivery-card.d.ts +39 -0
  55. package/dist/core/artifacts/delivery-card.d.ts.map +1 -0
  56. package/dist/core/artifacts/delivery-card.js +282 -0
  57. package/dist/core/artifacts/index.d.ts +42 -7
  58. package/dist/core/artifacts/index.d.ts.map +1 -1
  59. package/dist/core/artifacts/index.js +288 -76
  60. package/dist/core/artifacts/mutation-lock.d.ts +2 -0
  61. package/dist/core/artifacts/mutation-lock.d.ts.map +1 -0
  62. package/dist/core/artifacts/mutation-lock.js +91 -0
  63. package/dist/core/backup/host-snapshot.d.ts +18 -0
  64. package/dist/core/backup/host-snapshot.d.ts.map +1 -0
  65. package/dist/core/backup/host-snapshot.js +64 -0
  66. package/dist/core/config.d.ts +20 -5
  67. package/dist/core/config.d.ts.map +1 -1
  68. package/dist/core/config.js +44 -10
  69. package/dist/core/diagnostics/advice.d.ts +38 -3
  70. package/dist/core/diagnostics/advice.d.ts.map +1 -1
  71. package/dist/core/diagnostics/advice.js +103 -99
  72. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  73. package/dist/core/diagnostics/bundle.js +111 -47
  74. package/dist/core/diagnostics/comparison.d.ts.map +1 -1
  75. package/dist/core/diagnostics/comparison.js +3 -2
  76. package/dist/core/diagnostics/contract.d.ts +15 -31
  77. package/dist/core/diagnostics/contract.d.ts.map +1 -1
  78. package/dist/core/diagnostics/contract.js +2 -5
  79. package/dist/core/diagnostics/index.d.ts +2 -0
  80. package/dist/core/diagnostics/index.d.ts.map +1 -1
  81. package/dist/core/diagnostics/index.js +2 -0
  82. package/dist/core/diagnostics/pressure-contract.d.ts +260 -0
  83. package/dist/core/diagnostics/pressure-contract.d.ts.map +1 -0
  84. package/dist/core/diagnostics/pressure-contract.js +70 -0
  85. package/dist/core/diagnostics/pressure.d.ts +25 -0
  86. package/dist/core/diagnostics/pressure.d.ts.map +1 -0
  87. package/dist/core/diagnostics/pressure.js +1138 -0
  88. package/dist/core/diagnostics/replay.d.ts.map +1 -1
  89. package/dist/core/diagnostics/replay.js +90 -10
  90. package/dist/core/events/v3/adapter-id.d.ts +6 -0
  91. package/dist/core/events/v3/adapter-id.d.ts.map +1 -0
  92. package/dist/core/events/v3/adapter-id.js +7 -0
  93. package/dist/core/events/v3/archive-retention.d.ts.map +1 -1
  94. package/dist/core/events/v3/archive-retention.js +3 -2
  95. package/dist/core/events/v3/bootstrap.d.ts +41 -4
  96. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  97. package/dist/core/events/v3/bootstrap.js +120 -17
  98. package/dist/core/events/v3/capabilities.d.ts +16 -9
  99. package/dist/core/events/v3/capabilities.d.ts.map +1 -1
  100. package/dist/core/events/v3/capabilities.js +37 -2
  101. package/dist/core/events/v3/capability-drift.d.ts +2 -2
  102. package/dist/core/events/v3/capability-drift.d.ts.map +1 -1
  103. package/dist/core/events/v3/control-witness.d.ts +48 -5
  104. package/dist/core/events/v3/control-witness.d.ts.map +1 -1
  105. package/dist/core/events/v3/control-witness.js +91 -36
  106. package/dist/core/events/v3/control.d.ts.map +1 -1
  107. package/dist/core/events/v3/control.js +22 -2
  108. package/dist/core/events/v3/index.d.ts +2 -1
  109. package/dist/core/events/v3/index.d.ts.map +1 -1
  110. package/dist/core/events/v3/index.js +2 -1
  111. package/dist/core/events/v3/live-feed.d.ts +7 -1
  112. package/dist/core/events/v3/live-feed.d.ts.map +1 -1
  113. package/dist/core/events/v3/live-feed.js +21 -3
  114. package/dist/core/events/v3/live-routing.d.ts.map +1 -1
  115. package/dist/core/events/v3/live-routing.js +10 -2
  116. package/dist/core/events/v3/producers/command-recorder.d.ts +2 -2
  117. package/dist/core/events/v3/producers/command-recorder.d.ts.map +1 -1
  118. package/dist/core/events/v3/producers/command-recorder.js +2 -1
  119. package/dist/core/events/v3/producers/coordination-recorder.d.ts +2 -2
  120. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  121. package/dist/core/events/v3/producers/coordination-recorder.js +2 -1
  122. package/dist/core/events/v3/producers/hook-base.d.ts +2 -2
  123. package/dist/core/events/v3/producers/hook-base.d.ts.map +1 -1
  124. package/dist/core/events/v3/producers/hook-base.js +15 -5
  125. package/dist/core/events/v3/producers/hook.d.ts.map +1 -1
  126. package/dist/core/events/v3/producers/hook.js +17 -4
  127. package/dist/core/events/v3/producers/intake.d.ts +4 -4
  128. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  129. package/dist/core/events/v3/producers/intake.js +3 -2
  130. package/dist/core/events/v3/producers/producer-state-observer.d.ts.map +1 -1
  131. package/dist/core/events/v3/producers/producer-state-observer.js +2 -1
  132. package/dist/core/events/v3/producers/recorder.d.ts +16 -5
  133. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  134. package/dist/core/events/v3/producers/recorder.js +31 -9
  135. package/dist/core/events/v3/reader.d.ts.map +1 -1
  136. package/dist/core/events/v3/reader.js +10 -13
  137. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  138. package/dist/core/events/v3/runtime-build.js +3 -2
  139. package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts +2 -2
  140. package/dist/core/events/v3/runtime-telemetry-capabilities.d.ts.map +1 -1
  141. package/dist/core/events/v3/turn-telemetry.d.ts +2 -2
  142. package/dist/core/events/v3/turn-telemetry.d.ts.map +1 -1
  143. package/dist/core/hooks/adapter/output.d.ts +16 -14
  144. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  145. package/dist/core/hooks/adapter/output.js +36 -38
  146. package/dist/core/hooks/backup-schedule.d.ts +34 -0
  147. package/dist/core/hooks/backup-schedule.d.ts.map +1 -0
  148. package/dist/core/hooks/backup-schedule.js +114 -0
  149. package/dist/core/hooks/cli.js +61 -18
  150. package/dist/core/hooks/codex-wsl-bridge.d.ts +2 -0
  151. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -1
  152. package/dist/core/hooks/codex-wsl-bridge.js +18 -4
  153. package/dist/core/hooks/index.d.ts +1 -0
  154. package/dist/core/hooks/index.d.ts.map +1 -1
  155. package/dist/core/hooks/index.js +1 -0
  156. package/dist/core/hooks/prompt-context/contract.d.ts +61 -0
  157. package/dist/core/hooks/prompt-context/contract.d.ts.map +1 -0
  158. package/dist/core/hooks/prompt-context/contract.js +102 -0
  159. package/dist/core/hooks/prompt-context/index.d.ts +5 -0
  160. package/dist/core/hooks/prompt-context/index.d.ts.map +1 -0
  161. package/dist/core/hooks/prompt-context/index.js +2 -0
  162. package/dist/core/hooks/prompt-context/runner.d.ts +24 -0
  163. package/dist/core/hooks/prompt-context/runner.d.ts.map +1 -0
  164. package/dist/core/hooks/prompt-context/runner.js +249 -0
  165. package/dist/core/qa-artifacts.d.ts +3 -0
  166. package/dist/core/qa-artifacts.d.ts.map +1 -1
  167. package/dist/core/qa-artifacts.js +25 -3
  168. package/dist/core/resources/contract.d.ts +69 -3
  169. package/dist/core/resources/contract.d.ts.map +1 -1
  170. package/dist/core/resources/contract.js +1 -1
  171. package/dist/core/resources/index.d.ts +1 -0
  172. package/dist/core/resources/index.d.ts.map +1 -1
  173. package/dist/core/resources/index.js +1 -0
  174. package/dist/core/resources/native.d.ts +36 -0
  175. package/dist/core/resources/native.d.ts.map +1 -0
  176. package/dist/core/resources/native.js +418 -0
  177. package/dist/core/resources/sampler.d.ts +2 -0
  178. package/dist/core/resources/sampler.d.ts.map +1 -1
  179. package/dist/core/resources/sampler.js +31 -1
  180. package/dist/core/resources/status.d.ts +61 -0
  181. package/dist/core/resources/status.d.ts.map +1 -0
  182. package/dist/core/resources/status.js +536 -0
  183. package/dist/core/resources/system.d.ts +27 -0
  184. package/dist/core/resources/system.d.ts.map +1 -0
  185. package/dist/core/resources/system.js +439 -0
  186. package/dist/core/semantic/contract.d.ts +13 -13
  187. package/dist/core/semantic/service.d.ts +2 -0
  188. package/dist/core/semantic/service.d.ts.map +1 -1
  189. package/dist/core/semantic/service.js +27 -4
  190. package/dist/core/storage/builtins.js +41 -0
  191. package/dist/core/supervisor/contract.d.ts +41 -3
  192. package/dist/core/supervisor/contract.d.ts.map +1 -1
  193. package/dist/core/supervisor/contract.js +3 -2
  194. package/dist/core/supervisor/findings.d.ts +3 -0
  195. package/dist/core/supervisor/findings.d.ts.map +1 -1
  196. package/dist/core/supervisor/findings.js +88 -0
  197. package/dist/core/supervisor/history.d.ts.map +1 -1
  198. package/dist/core/supervisor/history.js +14 -0
  199. package/dist/core/supervisor/ledger-state.d.ts +55 -0
  200. package/dist/core/supervisor/ledger-state.d.ts.map +1 -0
  201. package/dist/core/supervisor/ledger-state.js +0 -0
  202. package/dist/core/supervisor/service.d.ts.map +1 -1
  203. package/dist/core/supervisor/service.js +120 -7
  204. package/dist/core/supervisor/storage.d.ts +3 -1
  205. package/dist/core/supervisor/storage.d.ts.map +1 -1
  206. package/dist/core/supervisor/storage.js +4 -0
  207. package/dist/core/workflow/admission.d.ts +3 -0
  208. package/dist/core/workflow/admission.d.ts.map +1 -1
  209. package/dist/core/workflow/admission.js +28 -16
  210. package/dist/core/workflow/engine.js +2 -2
  211. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  212. package/dist/core/workflow/live-session-v3.js +6 -1
  213. package/dist/core/workflow/proof.d.ts.map +1 -1
  214. package/dist/core/workflow/proof.js +4 -3
  215. package/dist/lib/browser/capture-fidelity.d.ts +7 -0
  216. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -1
  217. package/dist/lib/browser/capture-fidelity.js +17 -0
  218. package/dist/lib/browser/client.d.ts +9 -0
  219. package/dist/lib/browser/client.d.ts.map +1 -1
  220. package/dist/lib/browser/client.js +85 -37
  221. package/dist/lib/browser/index.d.ts +3 -1
  222. package/dist/lib/browser/index.d.ts.map +1 -1
  223. package/dist/lib/browser/index.js +2 -1
  224. package/dist/lib/browser/page-review-budget.d.ts +13 -0
  225. package/dist/lib/browser/page-review-budget.d.ts.map +1 -0
  226. package/dist/lib/browser/page-review-budget.js +251 -0
  227. package/dist/lib/browser/page-review-contracts.d.ts +127 -0
  228. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -0
  229. package/dist/lib/browser/page-review-contracts.js +5 -0
  230. package/dist/lib/browser/page-review-evidence.d.ts +48 -0
  231. package/dist/lib/browser/page-review-evidence.d.ts.map +1 -0
  232. package/dist/lib/browser/page-review-evidence.js +340 -0
  233. package/dist/lib/browser/page-review-judge.d.ts +1 -1
  234. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  235. package/dist/lib/browser/page-review-judge.js +46 -38
  236. package/dist/lib/browser/page-review-pack.d.ts +44 -22
  237. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  238. package/dist/lib/browser/page-review-pack.js +116 -36
  239. package/dist/lib/browser/page-review-source.d.ts +12 -0
  240. package/dist/lib/browser/page-review-source.d.ts.map +1 -0
  241. package/dist/lib/browser/page-review-source.js +22 -0
  242. package/dist/lib/browser/qa-run-contracts.d.ts +35 -9
  243. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  244. package/dist/lib/browser/qa-run-contracts.js +48 -14
  245. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  246. package/dist/lib/browser/qa-run.js +215 -68
  247. package/dist/lib/browser/qa-snapshot.d.ts +6 -0
  248. package/dist/lib/browser/qa-snapshot.d.ts.map +1 -1
  249. package/dist/lib/browser/qa-snapshot.js +259 -48
  250. package/dist/lib/browser/review-capture-transaction.d.ts +13 -0
  251. package/dist/lib/browser/review-capture-transaction.d.ts.map +1 -0
  252. package/dist/lib/browser/review-capture-transaction.js +79 -0
  253. package/dist/lib/browser/standalone-html.d.ts.map +1 -1
  254. package/dist/lib/browser/standalone-html.js +4 -2
  255. package/dist/lib/browser/thumbnail-association.d.ts +34 -0
  256. package/dist/lib/browser/thumbnail-association.d.ts.map +1 -0
  257. package/dist/lib/browser/thumbnail-association.js +158 -0
  258. package/dist/lib/browser/tiling.d.ts +30 -0
  259. package/dist/lib/browser/tiling.d.ts.map +1 -1
  260. package/dist/lib/browser/tiling.js +38 -0
  261. package/dist/lib/docs-metadata-managed.d.ts.map +1 -1
  262. package/dist/lib/docs-metadata-managed.js +4 -1
  263. package/dist/lib/identities/assume.d.ts.map +1 -1
  264. package/dist/lib/identities/assume.js +7 -1
  265. package/dist/lib/instructions/templates.d.ts.map +1 -1
  266. package/dist/lib/instructions/templates.js +1 -0
  267. package/dist/lib/local-file-url.d.ts +12 -0
  268. package/dist/lib/local-file-url.d.ts.map +1 -1
  269. package/dist/lib/local-file-url.js +16 -2
  270. package/dist/lib/tunnel/state.d.ts +19 -4
  271. package/dist/lib/tunnel/state.d.ts.map +1 -1
  272. package/dist/lib/tunnel/state.js +53 -13
  273. package/package.json +7 -4
  274. package/schemas/config.schema.json +69 -2
  275. package/src/commands/agents.ts +42 -9
  276. package/src/commands/artifacts.ts +130 -6
  277. package/src/commands/backup.ts +287 -34
  278. package/src/commands/browse.ts +564 -267
  279. package/src/commands/diagnostics.ts +6 -3
  280. package/src/commands/ledger-v3.ts +20 -3
  281. package/src/commands/qa-run.ts +37 -3
  282. package/src/commands/qa-status.ts +11 -2
  283. package/src/commands/resources.ts +23 -3
  284. package/src/commands/review-pack.ts +165 -62
  285. package/src/commands/tunnel.ts +2 -1
  286. package/src/core/agents/canonical-emit.ts +4 -2
  287. package/src/core/agents/cli.ts +14 -6
  288. package/src/core/agents/live-authority-v3.ts +7 -3
  289. package/src/core/agents/live-lifecycle-v3.ts +2 -2
  290. package/src/core/agents/render/prompt-context.ts +68 -14
  291. package/src/core/agents/session-finalizer-v3.ts +2 -2
  292. package/src/core/agents/state/live-coordination-view.ts +4 -4
  293. package/src/core/agents/state/names.ts +92 -7
  294. package/src/core/agents/state/stale-sweep.ts +22 -8
  295. package/src/core/agents/turn-elapsed.ts +135 -0
  296. package/src/core/artifacts/activity.ts +71 -0
  297. package/src/core/artifacts/delivery-card.ts +375 -0
  298. package/src/core/artifacts/index.ts +385 -91
  299. package/src/core/artifacts/mutation-lock.ts +106 -0
  300. package/src/core/backup/host-snapshot.ts +76 -0
  301. package/src/core/config.ts +66 -13
  302. package/src/core/diagnostics/advice.ts +148 -120
  303. package/src/core/diagnostics/bundle.ts +138 -52
  304. package/src/core/diagnostics/comparison.ts +6 -2
  305. package/src/core/diagnostics/contract.ts +39 -42
  306. package/src/core/diagnostics/index.ts +2 -0
  307. package/src/core/diagnostics/pressure-contract.ts +294 -0
  308. package/src/core/diagnostics/pressure.ts +1361 -0
  309. package/src/core/diagnostics/replay.ts +116 -11
  310. package/src/core/events/v3/adapter-id.ts +12 -0
  311. package/src/core/events/v3/archive-retention.ts +3 -6
  312. package/src/core/events/v3/bootstrap.ts +163 -16
  313. package/src/core/events/v3/capabilities.ts +75 -25
  314. package/src/core/events/v3/capability-drift.ts +2 -2
  315. package/src/core/events/v3/control-witness.ts +172 -41
  316. package/src/core/events/v3/control.ts +34 -2
  317. package/src/core/events/v3/index.ts +3 -0
  318. package/src/core/events/v3/live-feed.ts +23 -5
  319. package/src/core/events/v3/live-routing.ts +14 -2
  320. package/src/core/events/v3/producers/command-recorder.ts +4 -4
  321. package/src/core/events/v3/producers/coordination-recorder.ts +4 -4
  322. package/src/core/events/v3/producers/hook-base.ts +21 -16
  323. package/src/core/events/v3/producers/hook.ts +21 -4
  324. package/src/core/events/v3/producers/intake.ts +6 -6
  325. package/src/core/events/v3/producers/producer-state-observer.ts +2 -1
  326. package/src/core/events/v3/producers/recorder.ts +50 -21
  327. package/src/core/events/v3/reader.ts +14 -13
  328. package/src/core/events/v3/runtime-build.ts +3 -2
  329. package/src/core/events/v3/runtime-telemetry-capabilities.ts +3 -3
  330. package/src/core/events/v3/turn-telemetry.ts +4 -4
  331. package/src/core/hooks/adapter/output.ts +50 -36
  332. package/src/core/hooks/backup-schedule.ts +147 -0
  333. package/src/core/hooks/cli.ts +65 -14
  334. package/src/core/hooks/codex-wsl-bridge.ts +21 -5
  335. package/src/core/hooks/index.ts +1 -0
  336. package/src/core/hooks/prompt-context/contract.ts +177 -0
  337. package/src/core/hooks/prompt-context/index.ts +22 -0
  338. package/src/core/hooks/prompt-context/runner.ts +303 -0
  339. package/src/core/qa-artifacts.ts +29 -3
  340. package/src/core/resources/contract.ts +76 -3
  341. package/src/core/resources/index.ts +1 -0
  342. package/src/core/resources/native.ts +481 -0
  343. package/src/core/resources/sampler.ts +46 -1
  344. package/src/core/resources/status.ts +717 -0
  345. package/src/core/resources/system.ts +530 -0
  346. package/src/core/semantic/service.ts +37 -3
  347. package/src/core/storage/builtins.ts +41 -0
  348. package/src/core/supervisor/contract.ts +45 -3
  349. package/src/core/supervisor/findings.ts +159 -0
  350. package/src/core/supervisor/history.ts +15 -0
  351. package/src/core/supervisor/ledger-state.ts +0 -0
  352. package/src/core/supervisor/service.ts +133 -5
  353. package/src/core/supervisor/storage.ts +7 -0
  354. package/src/core/workflow/admission.ts +33 -17
  355. package/src/core/workflow/engine.ts +2 -2
  356. package/src/core/workflow/live-session-v3.ts +6 -8
  357. package/src/core/workflow/proof.ts +4 -3
  358. package/src/lib/browser/capture-fidelity.ts +23 -0
  359. package/src/lib/browser/client.ts +96 -44
  360. package/src/lib/browser/index.ts +15 -1
  361. package/src/lib/browser/page-review-budget.ts +309 -0
  362. package/src/lib/browser/page-review-contracts.ts +129 -0
  363. package/src/lib/browser/page-review-evidence.ts +465 -0
  364. package/src/lib/browser/page-review-judge.ts +56 -40
  365. package/src/lib/browser/page-review-pack.ts +181 -43
  366. package/src/lib/browser/page-review-source.ts +30 -0
  367. package/src/lib/browser/qa-run-contracts.ts +62 -18
  368. package/src/lib/browser/qa-run.ts +259 -71
  369. package/src/lib/browser/qa-snapshot.ts +289 -48
  370. package/src/lib/browser/review-capture-transaction.ts +106 -0
  371. package/src/lib/browser/standalone-html.ts +4 -2
  372. package/src/lib/browser/thumbnail-association.ts +190 -0
  373. package/src/lib/browser/tiling.ts +63 -0
  374. package/src/lib/docs-metadata-managed.ts +4 -1
  375. package/src/lib/identities/assume.ts +12 -4
  376. package/src/lib/instructions/templates.ts +1 -0
  377. package/src/lib/local-file-url.ts +17 -2
  378. package/src/lib/tunnel/state.ts +70 -13
@@ -3,13 +3,19 @@ import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, resolve } from "node:path";
4
4
  import { PNG } from "pngjs";
5
5
  import { resolveBinName } from "../core/config.js";
6
- import { CAPTURE_FIDELITY_MISMATCH_THRESHOLD, compareBand, decideFidelity, pngDimensions, } from "../lib/browser/capture-fidelity.js";
6
+ import { CAPTURE_FIDELITY_MISMATCH_THRESHOLD, compareBand, cropNativePng, decideFidelity, pngDimensions, } from "../lib/browser/capture-fidelity.js";
7
7
  import { Browser, browserProxyFromEnv, browserProxyGateFromEnv, captureDevOverlay, DEFAULT_CRITIQUE_RUBRIC, extractObservedIp, parseAssertSpec, runCritique, tilesFromFullPage, WEBRTC_PROXY_ONLY_ARG, writeNetscapeCookieFile, wslHeadedLaunchArgs, } from "../lib/browser/index.js";
8
- import { findPackTile, readPackContext, tileId, writePackContext, writePackExpandedTile, } from "../lib/browser/page-review-pack.js";
8
+ import { allocateTileBudget, validatePageReviewAllocation, } from "../lib/browser/page-review-budget.js";
9
+ import { PAGE_REVIEW_CAPTURE_PLAN_SCHEMA, } from "../lib/browser/page-review-contracts.js";
10
+ import { findPackTile, gateHitsFromEnvelope, readPackContext, tileId, writePackContext, writePackExpandedTile, } from "../lib/browser/page-review-pack.js";
11
+ import { retainPageReviewSourceIdentity } from "../lib/browser/page-review-source.js";
9
12
  import { buildQaManifest, classifySignatures, } from "../lib/browser/qa-plan.js";
10
13
  import { DEFAULT_REUSE_MISMATCH_RATIO, planCritiqueReuse, QA_CRITIQUE_CONTRACT_VERSION, rubricDigest, } from "../lib/browser/qa-reuse.js";
11
14
  import { loadQaSnapshot, resolveQaBaseline, saveQaSnapshot } from "../lib/browser/qa-snapshot.js";
15
+ import { allocateCaptureReservation, assertGatePlanStable, CaptureSourceChanged, runCaptureTransaction, writeCaptureTransaction, } from "../lib/browser/review-capture-transaction.js";
12
16
  import { startBrowserSessionServer, } from "../lib/browser/session-control.js";
17
+ import { captureThumbnailFingerprint, registerCaptureThumbnail, } from "../lib/browser/thumbnail-association.js";
18
+ import { reviewCandidateRects } from "../lib/browser/tiling.js";
13
19
  import { diffAgainstBaseline, saveBaseline, } from "../lib/browser/visual-diff.js";
14
20
  import { applyExtraCookies, CookieJar } from "../lib/cookies/index.js";
15
21
  /**
@@ -244,17 +250,19 @@ export function registerBrowseCommand(program, emitParam, context, binName = res
244
250
  "screenshot, critique tiles as PNG files, serialized DOM, QA signature) and make no " +
245
251
  "vision call. A judge stage (`review-pack judge`, or qa-run) reviews the tiles from disk " +
246
252
  "after every browser has closed. Tiling knobs are the --check-critique-* flags.")
253
+ .option("--review-pack-plan", "Emit all native capture candidates and gate associations without capturing tiles. " +
254
+ "With --out, retain private source identity inputs beside the output for diagnostics.")
255
+ .option("--review-pack-allocation <json-file>", "Capture exactly the selected IDs in a verified context allocation.")
256
+ .option("--review-pack-reservation <json-file>", "Reserve the preliminary allocation's tile count, then rerun supplied gates and allocate against the same page before capture. Full source checks and native coverage remain required.")
247
257
  .option("--review-pack-context <id>", "Context id inside the pack (default <viewport>-<theme>-<state> from --viewport, " +
248
258
  "--qa-theme, --qa-state).")
249
259
  .option("--review-pack-scope <selector>", "Also tile one screenshot per element matching <selector> into the pack (repeatable).", (value, previous = []) => [...previous, value], [])
250
- .option("--no-review-pack-bands", "Skip full-page bands in the pack and keep only --review-pack-scope tiles (a scoped " +
251
- "visual pass).")
252
260
  .option("--review-pack-expand <tile-id>", "Instead of capturing a context, re-render ONE existing tile of --review-pack-context " +
253
261
  "(e.g. T012) at --device-scale-factor and write it beside the original as " +
254
262
  "tiles/<tile>@<dpr>x.png. Existing tiles are untouched.")
255
- .option("--review-pack-hit-rect <x,y,w,h>", "Document-space rectangle (integer px) a deterministic gate already flagged. Every " +
256
- "full-page band that contains it is captured even past --check-critique-max-tiles, " +
257
- `labelled \`hit band N\` (repeatable, at most ${REVIEW_PACK_HIT_RECT_MAX}).`, (value, previous = []) => [...previous, value], [])
263
+ .option("--review-pack-hit-rect <x,y,w,h>", "Document-space rectangle (integer px) a deterministic gate already flagged. " +
264
+ "Intersecting bands are prioritized within the hard tile cap " +
265
+ `(repeatable, at most ${REVIEW_PACK_HIT_RECT_MAX}).`, (value, previous = []) => [...previous, value], [])
258
266
  .option("--device-scale-factor <n>", "Device scale factor for the browser context (default 1). 2 renders every screenshot " +
259
267
  "at twice the pixels; pair with --review-pack-expand for a sharper look at one tile.")
260
268
  .option("--no-dev-overlay", "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`.")
@@ -291,6 +299,13 @@ async function runBrowse(url, opts, context) {
291
299
  const headed = opts.login || opts.headed;
292
300
  const viewport = parseViewport(opts.viewport ?? "desktop");
293
301
  const deviceScaleFactor = parseDeviceScaleFactor(opts.deviceScaleFactor);
302
+ if (opts.reviewPackAllocation && !opts.reviewPack) {
303
+ throw new Error("--review-pack-allocation requires --review-pack <dir>.");
304
+ }
305
+ if (opts.reviewPackReservation &&
306
+ (!opts.reviewPack || opts.reviewPackAllocation || opts.reviewPackExpand || opts.checkCritique)) {
307
+ throw new Error("--review-pack-reservation requires an ordinary --review-pack capture without allocation, expansion or inline critique.");
308
+ }
294
309
  if (opts.reviewPackExpand !== undefined) {
295
310
  if (opts.reviewPack === undefined || opts.reviewPackContext === undefined) {
296
311
  throw new Error("--review-pack-expand requires --review-pack <dir> and --review-pack-context <id>.");
@@ -371,110 +386,182 @@ async function runBrowse(url, opts, context) {
371
386
  if (opts.evaluate) {
372
387
  evalResult = await browser.evaluate(opts.evaluate);
373
388
  }
389
+ if (opts.reviewPackPlan || opts.reviewPack)
390
+ await browser.waitForReviewReady();
374
391
  // Run visibility checks AFTER any --batch interactions but BEFORE the
375
392
  // screenshot. Annotation injection happens between sampling and capture
376
393
  // so the boxes show on the saved PNG; they're cleared post-screenshot
377
394
  // so the live profile state isn't polluted.
378
- let visibility;
379
- if (opts.checkVisible && opts.checkVisible.length > 0) {
380
- visibility = await browser.checkVisibility(opts.checkVisible, {
381
- sampleGrid: Number.parseInt(opts.checkVisibleSampleGrid ?? "3", 10),
382
- });
383
- if (opts.checkVisibleAnnotate !== false) {
384
- await browser.annotateVisibility(visibility);
395
+ const collectGates = async (attempt = 0) => {
396
+ const before = opts.reviewPackReservation
397
+ ? await buildReviewCapturePlan(browser, opts)
398
+ : undefined;
399
+ let visibility;
400
+ if (opts.checkVisible && opts.checkVisible.length > 0) {
401
+ visibility = await browser.checkVisibility(opts.checkVisible, {
402
+ sampleGrid: Number.parseInt(opts.checkVisibleSampleGrid ?? "3", 10),
403
+ });
404
+ if (opts.checkVisibleAnnotate !== false && !opts.reviewPackPlan && !opts.reviewPack) {
405
+ await browser.annotateVisibility(visibility);
406
+ }
385
407
  }
386
- }
387
- let widths;
388
- if (opts.checkWidth && opts.checkWidth.length > 0) {
389
- widths = await browser.checkWidth(opts.checkWidth);
390
- }
391
- let overflow;
392
- if (opts.checkOverflow) {
393
- overflow = await browser.checkOverflow();
394
- }
395
- let runts;
396
- if (opts.checkRunts) {
397
- runts = await browser.checkRunts({
398
- scope: typeof opts.checkRunts === "string" ? opts.checkRunts : null,
399
- minChars: Number.parseInt(opts.checkRuntsMinChars ?? "40", 10),
400
- });
401
- }
402
- const hasLayoutLint = (opts.checkAlign?.length ?? 0) > 0 ||
403
- (opts.checkGap?.length ?? 0) > 0 ||
404
- (opts.checkClip?.length ?? 0) > 0 ||
405
- (opts.checkOverlap?.length ?? 0) > 0 ||
406
- (opts.checkCrowd?.length ?? 0) > 0;
407
- let layoutLint;
408
- if (hasLayoutLint) {
409
- layoutLint = await browser.checkLayoutLint({
410
- align: (opts.checkAlign ?? []).map((selector) => ({
411
- selector,
412
- axis: alignAxis,
413
- tolerancePx: alignThreshold,
414
- })),
415
- gap: (opts.checkGap ?? []).map((selector) => ({
416
- selector,
417
- axis: gapAxis,
418
- tolerancePx: gapThreshold,
419
- expectedGapPx: gapExpected,
420
- })),
421
- clip: (opts.checkClip ?? []).map((selector) => ({
422
- selector,
423
- tolerancePx: clipThreshold,
424
- })),
425
- overlap: (opts.checkOverlap ?? []).map((selector) => ({
426
- selector,
427
- tolerancePx: overlapThreshold,
428
- })),
429
- crowd: (opts.checkCrowd ?? []).map((selector) => ({
430
- selector,
431
- minGapPx: crowdMin,
432
- })),
433
- });
434
- }
435
- let hit;
436
- if (opts.checkHit && opts.checkHit.length > 0) {
437
- hit = await browser.checkTargetSize(opts.checkHit, hitProfile);
438
- }
439
- const hasContentChecks = opts.checkPlaceholder !== undefined ||
440
- opts.checkImages !== undefined ||
441
- opts.checkTruncation !== undefined ||
442
- opts.checkContrast !== undefined;
443
- let content;
444
- if (hasContentChecks) {
445
- content = await browser.checkContent({
446
- placeholder: opts.checkPlaceholder !== undefined
447
- ? { scope: contentScope(opts.checkPlaceholder) }
448
- : null,
449
- image: opts.checkImages !== undefined
450
- ? {
451
- scope: contentScope(opts.checkImages),
452
- tolerance: parseNonNegativeNumber(opts.checkImagesTolerance ?? "0.1", "--check-images-tolerance"),
453
- }
454
- : null,
455
- truncation: opts.checkTruncation !== undefined
456
- ? {
457
- scope: contentScope(opts.checkTruncation),
458
- tolerance: parseNonNegativeNumber(opts.checkTruncationTolerance ?? "2", "--check-truncation-tolerance"),
459
- }
460
- : null,
461
- contrast: opts.checkContrast !== undefined ? { scope: contentScope(opts.checkContrast) } : null,
462
- });
463
- }
464
- // Diff-aware QA planning. Signature capture must also happen BEFORE any
465
- // annotation overlays are injected — an annotation box is a DOM change.
466
- let qaPlan;
467
- let qaCapture;
468
- if (opts.qaPlan || opts.qaSnapshot) {
469
- const qa = await runQaPlanning(browser, url, navResult.url, opts);
470
- qaPlan = qa.report;
471
- qaCapture = qa.capture;
472
- if (qaPlan?.manifest) {
473
- const m = qaPlan.manifest;
474
- emit.log(`qa-plan: class=${m.change_class} scopes=${m.scopes.map((s) => s.selector).join(",") || "-"} ` +
475
- `contexts=${m.contexts.length} model-calls<=${m.predicted.model_calls_ceiling} baseline=${m.baseline_source}`, "info");
408
+ let widths;
409
+ if (opts.checkWidth && opts.checkWidth.length > 0) {
410
+ widths = await browser.checkWidth(opts.checkWidth);
476
411
  }
477
- }
412
+ let overflow;
413
+ if (opts.checkOverflow) {
414
+ overflow = await browser.checkOverflow();
415
+ }
416
+ let runts;
417
+ if (opts.checkRunts) {
418
+ runts = await browser.checkRunts({
419
+ scope: typeof opts.checkRunts === "string" ? opts.checkRunts : null,
420
+ minChars: Number.parseInt(opts.checkRuntsMinChars ?? "40", 10),
421
+ });
422
+ }
423
+ const hasLayoutLint = (opts.checkAlign?.length ?? 0) > 0 ||
424
+ (opts.checkGap?.length ?? 0) > 0 ||
425
+ (opts.checkClip?.length ?? 0) > 0 ||
426
+ (opts.checkOverlap?.length ?? 0) > 0 ||
427
+ (opts.checkCrowd?.length ?? 0) > 0;
428
+ let layoutLint;
429
+ if (hasLayoutLint) {
430
+ layoutLint = await browser.checkLayoutLint({
431
+ align: (opts.checkAlign ?? []).map((selector) => ({
432
+ selector,
433
+ axis: alignAxis,
434
+ tolerancePx: alignThreshold,
435
+ })),
436
+ gap: (opts.checkGap ?? []).map((selector) => ({
437
+ selector,
438
+ axis: gapAxis,
439
+ tolerancePx: gapThreshold,
440
+ expectedGapPx: gapExpected,
441
+ })),
442
+ clip: (opts.checkClip ?? []).map((selector) => ({
443
+ selector,
444
+ tolerancePx: clipThreshold,
445
+ })),
446
+ overlap: (opts.checkOverlap ?? []).map((selector) => ({
447
+ selector,
448
+ tolerancePx: overlapThreshold,
449
+ })),
450
+ crowd: (opts.checkCrowd ?? []).map((selector) => ({
451
+ selector,
452
+ minGapPx: crowdMin,
453
+ })),
454
+ });
455
+ }
456
+ let hit;
457
+ if (opts.checkHit && opts.checkHit.length > 0) {
458
+ hit = await browser.checkTargetSize(opts.checkHit, hitProfile);
459
+ }
460
+ const hasContentChecks = opts.checkPlaceholder !== undefined ||
461
+ opts.checkImages !== undefined ||
462
+ opts.checkTruncation !== undefined ||
463
+ opts.checkContrast !== undefined;
464
+ let content;
465
+ if (hasContentChecks) {
466
+ content = await browser.checkContent({
467
+ placeholder: opts.checkPlaceholder !== undefined
468
+ ? { scope: contentScope(opts.checkPlaceholder) }
469
+ : null,
470
+ image: opts.checkImages !== undefined
471
+ ? {
472
+ scope: contentScope(opts.checkImages),
473
+ tolerance: parseNonNegativeNumber(opts.checkImagesTolerance ?? "0.1", "--check-images-tolerance"),
474
+ }
475
+ : null,
476
+ truncation: opts.checkTruncation !== undefined
477
+ ? {
478
+ scope: contentScope(opts.checkTruncation),
479
+ tolerance: parseNonNegativeNumber(opts.checkTruncationTolerance ?? "2", "--check-truncation-tolerance"),
480
+ }
481
+ : null,
482
+ contrast: opts.checkContrast !== undefined ? { scope: contentScope(opts.checkContrast) } : null,
483
+ });
484
+ }
485
+ // Diff-aware QA planning. Signature capture must also happen BEFORE any
486
+ // annotation overlays are injected — an annotation box is a DOM change.
487
+ let qaPlan;
488
+ let qaCapture;
489
+ if (opts.qaPlan || opts.qaSnapshot) {
490
+ const qa = await runQaPlanning(browser, url, navResult.url, opts);
491
+ qaPlan = qa.report;
492
+ qaCapture = qa.capture;
493
+ if (qaPlan?.manifest) {
494
+ const m = qaPlan.manifest;
495
+ emit.log(`qa-plan: class=${m.change_class} scopes=${m.scopes.map((s) => s.selector).join(",") || "-"} ` +
496
+ `contexts=${m.contexts.length} model-calls<=${m.predicted.model_calls_ceiling} baseline=${m.baseline_source}`, "info");
497
+ }
498
+ }
499
+ let asserts;
500
+ if (opts.assert && opts.assert.length > 0) {
501
+ const specs = opts.assert.map(parseAssertSpec);
502
+ asserts = await browser.checkAsserts(specs);
503
+ }
504
+ if (opts.reviewPackPlan || opts.reviewPackReservation) {
505
+ const gateEnvelope = { runts, overflow, hit, ...layoutLint };
506
+ if (content)
507
+ assignContent(gateEnvelope, content);
508
+ opts.capturePlanResult = await buildReviewCapturePlan(browser, opts);
509
+ const hits = gateHitsFromEnvelope(gateEnvelope, Number.POSITIVE_INFINITY);
510
+ for (const candidate of opts.capturePlanResult.candidates) {
511
+ const r = candidate.rect;
512
+ candidate.gate_hits = hits.flatMap((hit, index) => {
513
+ const dpr = opts.capturePlanResult.dpr;
514
+ const h = {
515
+ x: hit.rect.x * dpr,
516
+ y: hit.rect.y * dpr,
517
+ width: Math.max(1, hit.rect.width * dpr),
518
+ height: Math.max(1, hit.rect.height * dpr),
519
+ };
520
+ return r.x < h.x + h.width &&
521
+ r.x + r.width > h.x &&
522
+ r.y < h.y + h.height &&
523
+ r.y + r.height > h.y
524
+ ? [{ check_id: `${hit.rule}:${index + 1}`, severity: "high" }]
525
+ : [];
526
+ });
527
+ }
528
+ }
529
+ if (opts.reviewPackReservation) {
530
+ const prefix = opts.out ?? resolve(opts.reviewPack, opts.reviewPackContext ?? "capture");
531
+ mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 });
532
+ writeFileSync(`${prefix}.attempt-${attempt}.gates.json`, JSON.stringify({
533
+ attempt,
534
+ source_before: before?.source_digest,
535
+ source_after: opts.capturePlanResult?.source_digest,
536
+ source_evidence: opts.captureSourceEvidence,
537
+ visibility,
538
+ widths,
539
+ overflow,
540
+ runts,
541
+ layoutLint,
542
+ hit,
543
+ content,
544
+ asserts,
545
+ }, null, 2), { mode: 0o600 });
546
+ }
547
+ if (before)
548
+ assertGatePlanStable(before, opts.capturePlanResult);
549
+ return {
550
+ visibility,
551
+ widths,
552
+ overflow,
553
+ runts,
554
+ layoutLint,
555
+ hit,
556
+ content,
557
+ qaPlan,
558
+ qaCapture,
559
+ asserts,
560
+ };
561
+ };
562
+ let { visibility, widths, overflow, runts, layoutLint, hit, content, qaPlan, qaCapture, asserts, } = opts.reviewPackReservation
563
+ ? {}
564
+ : await collectGates();
478
565
  // Vision critique. Capture tiles BEFORE any annotation overlays are injected
479
566
  // so the model sees the real page, not our boxes.
480
567
  let critique;
@@ -542,6 +629,52 @@ async function runBrowse(url, opts, context) {
542
629
  if (opts.reviewPack !== undefined && opts.reviewPackExpand !== undefined) {
543
630
  reviewPack = await expandReviewPackTile(browser, opts, deviceScaleFactor ?? 1);
544
631
  }
632
+ else if (opts.reviewPackReservation) {
633
+ const reservation = JSON.parse(readFileSync(resolve(opts.reviewPackReservation), "utf8"));
634
+ opts.captureTransaction = { attempts: [] };
635
+ const persistTransaction = () => {
636
+ const prefix = opts.out ?? resolve(opts.reviewPack, opts.reviewPackContext ?? "capture");
637
+ mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 });
638
+ writeFileSync(`${prefix}.capture-transaction.json`, JSON.stringify(opts.captureTransaction, null, 2), { mode: 0o600 });
639
+ };
640
+ reviewPack = await runCaptureTransaction(async (attempt) => {
641
+ await browser.waitForReviewReady();
642
+ ({
643
+ visibility,
644
+ widths,
645
+ overflow,
646
+ runts,
647
+ layoutLint,
648
+ hit,
649
+ content,
650
+ qaPlan,
651
+ qaCapture,
652
+ asserts,
653
+ } = await collectGates(attempt));
654
+ opts.captureTransactionAllocation = allocateCaptureReservation(reservation, opts.capturePlanResult);
655
+ opts.captureTransaction.attempts.push({
656
+ attempt,
657
+ status: "gated",
658
+ source_digest: opts.capturePlanResult.source_digest,
659
+ });
660
+ persistTransaction();
661
+ }, async () => {
662
+ const captured = await captureReviewPackContext(browser, url, navResult, opts, qaCapture);
663
+ opts.captureTransaction.source_digest =
664
+ opts.captureTransactionAllocation.plan.source_digest;
665
+ opts.captureTransaction.attempts.at(-1).status = "captured";
666
+ persistTransaction();
667
+ return captured;
668
+ }, (attempt, error) => {
669
+ opts.captureTransaction.attempts.push({
670
+ attempt,
671
+ status: "failed",
672
+ error: error instanceof Error ? error.message : String(error),
673
+ source_digest: opts.capturePlanResult?.source_digest,
674
+ });
675
+ persistTransaction();
676
+ });
677
+ }
545
678
  else if (opts.reviewPack !== undefined) {
546
679
  reviewPack = await captureReviewPackContext(browser, url, navResult, opts, qaCapture);
547
680
  }
@@ -590,11 +723,6 @@ async function runBrowse(url, opts, context) {
590
723
  `[${qaCapture.context.viewport}/${qaCapture.context.theme}/${qaCapture.context.state}]` +
591
724
  `${persistedCritique ? " (with critique results)" : ""}`, "info");
592
725
  }
593
- let asserts;
594
- if (opts.assert && opts.assert.length > 0) {
595
- const specs = opts.assert.map(parseAssertSpec);
596
- asserts = await browser.checkAsserts(specs);
597
- }
598
726
  const widthThreshold = Number.parseFloat(opts.checkWidthThreshold ?? "0.9");
599
727
  const annotateWidth = widths && opts.checkWidthAnnotate !== false;
600
728
  const annotateOverflow = overflow && opts.checkOverflowAnnotate !== false;
@@ -930,6 +1058,12 @@ async function runPrintMode(browser, navResult, opts, evalResult, visibility, wi
930
1058
  result.qaReuse = qaReuse;
931
1059
  if (reviewPack)
932
1060
  result.reviewPack = reviewPack;
1061
+ if (opts.capturePlanResult)
1062
+ result.review_pack_capture_plan = opts.capturePlanResult;
1063
+ if (opts.captureSourceEvidence)
1064
+ result.review_pack_source_evidence = opts.captureSourceEvidence;
1065
+ if (opts.captureTransaction)
1066
+ result.review_pack_transaction = opts.captureTransaction;
933
1067
  if (batchResult && batchResult.clipboardReads.length > 0) {
934
1068
  result.batchClipboardReads = batchResult.clipboardReads;
935
1069
  }
@@ -958,6 +1092,7 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
958
1092
  const skipScreenshot = opts.screenshot === false;
959
1093
  let pngPath;
960
1094
  let pngBytes;
1095
+ let pngFingerprint = null;
961
1096
  let captureEvalResult;
962
1097
  let captureViewport;
963
1098
  let captureEvidence;
@@ -975,6 +1110,8 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
975
1110
  else {
976
1111
  pngBytes = await browser.screenshot(pngPath, { fullPage: opts.fullPage !== false });
977
1112
  }
1113
+ if (!opts.selector && opts.fullPage !== false)
1114
+ pngFingerprint = captureThumbnailFingerprint(pngPath);
978
1115
  written.push(pngPath);
979
1116
  }
980
1117
  else if (opts.captureEvaluate) {
@@ -999,6 +1136,17 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
999
1136
  }
1000
1137
  }
1001
1138
  written.push(htmlPath);
1139
+ const thumbnailAssociation = snapshot && pngPath && pngFingerprint
1140
+ ? registerCaptureThumbnail({
1141
+ htmlPath,
1142
+ pngPath,
1143
+ html: snapshot.html,
1144
+ preview: pngFingerprint,
1145
+ fullDocument: !opts.selector && opts.fullPage !== false,
1146
+ stylesheetsLinked: snapshot.stylesheetsLinked,
1147
+ resourcesLinked: snapshot.resourcesLinked,
1148
+ })
1149
+ : null;
1002
1150
  const diag = browser.diagnostics();
1003
1151
  const jsonPath = `${prefix}.json`;
1004
1152
  const envelope = {
@@ -1014,6 +1162,8 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
1014
1162
  };
1015
1163
  if (pngBytes !== undefined)
1016
1164
  envelope.screenshotBytes = pngBytes;
1165
+ if (thumbnailAssociation)
1166
+ envelope.thumbnailAssociation = thumbnailAssociation;
1017
1167
  if (snapshot) {
1018
1168
  envelope.htmlSnapshot = {
1019
1169
  stylesheetsInlined: snapshot.stylesheetsInlined,
@@ -1063,6 +1213,12 @@ async function runTrioMode(browser, navResult, opts, evalResult, visibility, wid
1063
1213
  envelope.qaReuse = qaReuse;
1064
1214
  if (reviewPack)
1065
1215
  envelope.reviewPack = reviewPack;
1216
+ if (opts.capturePlanResult)
1217
+ envelope.review_pack_capture_plan = opts.capturePlanResult;
1218
+ if (opts.captureSourceEvidence)
1219
+ envelope.review_pack_source_evidence = opts.captureSourceEvidence;
1220
+ if (opts.captureTransaction)
1221
+ envelope.review_pack_transaction = opts.captureTransaction;
1066
1222
  if (batchResult && batchResult.clipboardReads.length > 0) {
1067
1223
  envelope.batchClipboardReads = batchResult.clipboardReads;
1068
1224
  }
@@ -1517,12 +1673,14 @@ async function expandReviewPackTile(browser, opts, dpr) {
1517
1673
  const packDir = opts.reviewPack;
1518
1674
  const contextId = opts.reviewPackContext;
1519
1675
  const tileId = opts.reviewPackExpand;
1520
- const { tile } = findPackTile([readPackContext(packDir, contextId)], tileId, contextId);
1676
+ const originalContext = readPackContext(packDir, contextId);
1677
+ const { tile } = findPackTile([originalContext], tileId, contextId);
1678
+ const originalDpr = originalContext.dpr;
1521
1679
  const region = await browser.captureRegionByScroll({
1522
- x: tile.x,
1523
- y: tile.scrollY,
1524
- width: tile.width,
1525
- height: tile.height,
1680
+ x: tile.x / originalDpr,
1681
+ y: tile.scrollY / originalDpr,
1682
+ width: tile.width / originalDpr,
1683
+ height: tile.height / originalDpr,
1526
1684
  });
1527
1685
  const { record, expanded } = writePackExpandedTile(packDir, contextId, {
1528
1686
  tileId,
@@ -1547,9 +1705,9 @@ async function expandReviewPackTile(browser, opts, dpr) {
1547
1705
  * agree the tiles stand and the record says `full-page`; when any disagrees,
1548
1706
  * every band is re-cut from its scrolled capture (same rects, same ids, only
1549
1707
  * the pixels change) and the record says `scrolled-bands`. A probe that
1550
- * cannot run, or a re-capture that comes back the wrong size (a page wider
1551
- * than the viewport), keeps the full-page crop for that band and says so in a
1552
- * warning. Runs while the capture browser is open; the judge never opens one.
1708
+ * cannot run, or a re-capture with the wrong size, leaves a missing probe;
1709
+ * the caller refuses to save that context. Runs while the capture browser
1710
+ * is open; the judge never opens one.
1553
1711
  */
1554
1712
  async function reconcileCaptureFidelity(browser, tiles) {
1555
1713
  const threshold = CAPTURE_FIDELITY_MISMATCH_THRESHOLD;
@@ -1564,30 +1722,22 @@ async function reconcileCaptureFidelity(browser, tiles) {
1564
1722
  height: tile.height / dpr,
1565
1723
  });
1566
1724
  const shots = new Map();
1567
- const shoot = async (position) => {
1568
- if (shots.has(position))
1569
- return shots.get(position);
1570
- const tile = tiles[position];
1571
- let shot;
1572
- try {
1573
- const captured = await browser.captureRegionByScroll(cssRect(tile));
1574
- const size = pngDimensions(captured);
1725
+ try {
1726
+ const captured = await browser.captureRegionsByScroll(tiles.map(cssRect));
1727
+ for (const [position, shot] of captured.entries()) {
1728
+ const tile = tiles[position];
1729
+ const size = pngDimensions(shot);
1575
1730
  if (Math.abs(size.width - tile.width) > 1 || Math.abs(size.height - tile.height) > 1) {
1576
- warnings.push(`review-pack: scrolled capture of ${tileId(position)} came back ` +
1577
- `${size.width}×${size.height} px for a ${tile.width}×${tile.height} px band; ` +
1578
- "its full-page crop was kept");
1579
- }
1580
- else {
1581
- shot = captured;
1731
+ warnings.push(`Native scrolled capture of ${tileId(position)} has different dimensions; probe incomplete.`);
1582
1732
  }
1733
+ else
1734
+ shots.set(position, shot);
1583
1735
  }
1584
- catch (err) {
1585
- warnings.push(`review-pack: scrolled capture of ${tileId(position)} failed ` +
1586
- `(${err instanceof Error ? err.message : String(err)}); its full-page crop was kept`);
1587
- }
1588
- shots.set(position, shot);
1589
- return shot;
1590
- };
1736
+ }
1737
+ catch (error) {
1738
+ warnings.push(`Native scrolled capture failed: ${error instanceof Error ? error.message : String(error)}; probe incomplete.`);
1739
+ }
1740
+ const shoot = async (position) => shots.get(position);
1591
1741
  // Every band is probed. A two-band sample (top and middle) missed a raster
1592
1742
  // artifact that sat elsewhere on the page, and once every band has been
1593
1743
  // shot by scrolling, the fallback below costs nothing extra: the same shots
@@ -1622,112 +1772,198 @@ async function reconcileCaptureFidelity(browser, tiles) {
1622
1772
  }
1623
1773
  /**
1624
1774
  * Capture this render into a page review pack: one full-page screenshot,
1625
- * banded tiles (unless --no-review-pack-bands), one tile set per
1775
+ * budgeted native tiles, one candidate set per
1626
1776
  * --review-pack-scope selector, the serialized DOM, and the QA signature —
1627
1777
  * all written to disk, with no vision call. The judge stage reads them back
1628
1778
  * once the browser is gone.
1629
1779
  */
1780
+ export const REVIEW_CAPTURE_RECIPE = "native-full-page-scroll-probe/v2";
1781
+ async function buildReviewCapturePlan(browser, opts) {
1782
+ await browser.currentPage.evaluate(() => document.fonts.ready);
1783
+ const geometry = await browser.currentPage.evaluate(() => ({
1784
+ dpr: window.devicePixelRatio || 1,
1785
+ width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
1786
+ height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
1787
+ viewportWidth: window.innerWidth,
1788
+ viewportHeight: window.innerHeight,
1789
+ }));
1790
+ const { dpr } = geometry;
1791
+ const signature = await browser.qaSignature();
1792
+ // Attribute insertion order is serialization noise, not a source change.
1793
+ // Parse an inert document so canonicalization cannot mutate the rendered page.
1794
+ const canonicalDom = await browser.currentPage.evaluate((html) => {
1795
+ const document = new DOMParser().parseFromString(html, "text/html");
1796
+ for (const el of Array.from(document.querySelectorAll("*"))) {
1797
+ const attrs = Array.from(el.attributes)
1798
+ .map((a) => ({ name: a.name, value: a.value, ns: a.namespaceURI }))
1799
+ .sort((a, b) => a.name.localeCompare(b.name));
1800
+ for (const a of Array.from(el.attributes))
1801
+ el.removeAttributeNode(a);
1802
+ for (const a of attrs)
1803
+ el.setAttributeNS(a.ns, a.name, a.value);
1804
+ }
1805
+ return document.documentElement.outerHTML;
1806
+ }, signature.domHtml);
1807
+ const source = retainPageReviewSourceIdentity({ nodes: signature.nodes, stylesheets: signature.stylesheets, dom: canonicalDom }, opts.out ?? (opts.reviewPack ? resolve(opts.reviewPack, "source") : undefined));
1808
+ opts.captureSourceEvidence = source.evidence;
1809
+ const atoms = (await browser.visualAtoms()).map((a) => ({
1810
+ ...a,
1811
+ top: Math.round(a.top * dpr),
1812
+ bottom: Math.round(a.bottom * dpr),
1813
+ }));
1814
+ const { band, overlap } = critiqueTilingKnobs(opts);
1815
+ const scopes = [];
1816
+ for (const selector of opts.reviewPackScope ?? []) {
1817
+ const rects = (await browser.elementTiles(selector)).map((r) => ({
1818
+ ...r,
1819
+ x: Math.round(r.x * dpr),
1820
+ y: Math.round(r.y * dpr),
1821
+ width: Math.round(r.width * dpr),
1822
+ height: Math.round(r.height * dpr),
1823
+ }));
1824
+ scopes.push({ selector, rects });
1825
+ }
1826
+ const theme = opts.qaTheme === "dark" ? "dark" : "light";
1827
+ const viewport = opts.viewport ?? "desktop", state = opts.qaState ?? "default";
1828
+ return {
1829
+ schema: PAGE_REVIEW_CAPTURE_PLAN_SCHEMA,
1830
+ context_id: opts.reviewPackContext ?? `${viewport}--${theme}--${state}`,
1831
+ viewport,
1832
+ viewport_width: geometry.viewportWidth,
1833
+ viewport_height: geometry.viewportHeight,
1834
+ theme,
1835
+ state,
1836
+ dpr,
1837
+ page_width: Math.round(geometry.width * dpr),
1838
+ page_height: Math.round(geometry.height * dpr),
1839
+ source_digest: source.digest,
1840
+ recipe_version: REVIEW_CAPTURE_RECIPE,
1841
+ required_scopes: opts.reviewPackScope ?? [],
1842
+ candidates: reviewCandidateRects({
1843
+ width: Math.round(geometry.width * dpr),
1844
+ height: Math.round(geometry.height * dpr),
1845
+ bandHeight: band,
1846
+ overlap,
1847
+ atoms,
1848
+ scopes,
1849
+ }),
1850
+ };
1851
+ }
1630
1852
  async function captureReviewPackContext(browser, targetArg, navResult, opts, qaCapture) {
1631
1853
  const packDir = opts.reviewPack;
1632
- const { maxTiles, band, overlap } = critiqueTilingKnobs(opts);
1633
- const buffer = await browser.fullPageScreenshotBuffer();
1634
- let atoms;
1635
- try {
1636
- atoms = await browser.visualAtoms();
1637
- }
1638
- catch {
1639
- atoms = undefined;
1854
+ const plan = await buildReviewCapturePlan(browser, opts);
1855
+ let allocation;
1856
+ if (opts.captureTransactionAllocation) {
1857
+ try {
1858
+ allocation = validatePageReviewAllocation(opts.captureTransactionAllocation, plan);
1859
+ }
1860
+ catch (error) {
1861
+ throw new CaptureSourceChanged(String(error));
1862
+ }
1640
1863
  }
1641
- const tiling = { bandHeight: band, overlap, maxTiles, atoms };
1642
- const withBands = opts.reviewPackBands !== false;
1643
- // Gate-hit rectangles (qa-run lifts them from the gate stage, which runs
1644
- // first) pull the bands they land in past the cap; those tiles follow the
1645
- // kept bands as `hit band N`. Coverage still describes the kept prefix.
1646
- const hitRects = parseReviewPackHitRects(opts.reviewPackHitRect);
1647
- const banded = withBands
1648
- ? tilesFromFullPage(buffer, { ...tiling, hitRects, maxHitBands: REVIEW_PACK_HIT_RECT_MAX })
1649
- : undefined;
1650
- if (banded?.coverage.capped) {
1651
- emit.log(`review-pack: page banded to the ${maxTiles}-tile cap (raise --check-critique-max-tiles for full coverage)`, "warn");
1652
- }
1653
- if (banded && banded.hitBands > 0) {
1654
- emit.log(`review-pack: ${banded.hitBands} band(s) below the tile cap captured for ${hitRects.length} gate-hit rect(s)`, "info");
1655
- }
1656
- // Fidelity: prove the full-page screenshot against scrolled viewport
1657
- // renders while the browser is still open; on disagreement the bands are
1658
- // re-cut from scrolled captures (see reconcileCaptureFidelity).
1659
- let bandTiles = banded?.tiles ?? [];
1660
- let captureFidelity;
1661
- const fidelityWarnings = [];
1662
- if (bandTiles.length > 0) {
1663
- const reconciled = await reconcileCaptureFidelity(browser, bandTiles);
1664
- bandTiles = reconciled.tiles;
1665
- captureFidelity = reconciled.fidelity;
1666
- fidelityWarnings.push(...reconciled.warnings);
1667
- for (const warning of reconciled.warnings)
1668
- emit.log(warning, "warn");
1669
- emit.log(`review-pack: capture fidelity ${captureFidelity.source} ` +
1670
- `(${captureFidelity.probed.length} band(s) probed, ${captureFidelity.mismatched.length} mismatched)`, "info");
1671
- }
1672
- const scopeTiles = [];
1673
- for (const selector of opts.reviewPackScope ?? []) {
1674
- const elementRects = await browser.elementTiles(selector);
1675
- const scoped = tilesFromFullPage(buffer, { ...tiling, elementRects });
1676
- if (elementRects.length > maxTiles) {
1677
- emit.log(`review-pack: ${elementRects.length} elements matched ${selector}, capped to ${maxTiles} tiles`, "warn");
1678
- }
1679
- scopeTiles.push({ selector, tiles: scoped.tiles, coverage: scoped.coverage });
1680
- }
1681
- // Coverage: the bands when present, else the scopes folded together
1682
- // (heights worst-case, band counts add, capped sticky).
1683
- let coverage = banded?.coverage;
1684
- if (!coverage) {
1685
- for (const scope of scopeTiles) {
1686
- coverage = coverage
1687
- ? {
1688
- page_height_px: Math.max(coverage.page_height_px, scope.coverage.page_height_px),
1689
- reviewed_height_px: Math.min(coverage.reviewed_height_px, scope.coverage.reviewed_height_px),
1690
- bands_total: coverage.bands_total + scope.coverage.bands_total,
1691
- bands_reviewed: coverage.bands_reviewed + scope.coverage.bands_reviewed,
1692
- capped: coverage.capped || scope.coverage.capped,
1693
- }
1694
- : scope.coverage;
1864
+ else if (opts.reviewPackAllocation) {
1865
+ try {
1866
+ allocation = validatePageReviewAllocation(JSON.parse(readFileSync(resolve(opts.reviewPackAllocation), "utf8")), plan);
1867
+ }
1868
+ catch (error) {
1869
+ if (error instanceof Error && opts.captureSourceEvidence)
1870
+ error.message += ` Current source identity: ${opts.captureSourceEvidence.path} (sha256 ${plan.source_digest}).`;
1871
+ throw error;
1695
1872
  }
1696
1873
  }
1697
- const page = await browser.currentPage.evaluate(() => ({
1698
- width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
1699
- height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
1874
+ else {
1875
+ // Standalone capture retains its 24-tile default; it still obeys one hard total.
1876
+ const maxTiles = critiqueTilingKnobs(opts).maxTiles;
1877
+ const hits = parseReviewPackHitRects(opts.reviewPackHitRect);
1878
+ for (const c of plan.candidates) {
1879
+ c.gate_hits = hits.flatMap((h, i) => c.rect.x < (h.x + h.width) * plan.dpr &&
1880
+ c.rect.x + c.rect.width > h.x * plan.dpr &&
1881
+ c.rect.y < (h.y + h.height) * plan.dpr &&
1882
+ c.rect.y + c.rect.height > h.y * plan.dpr
1883
+ ? [{ check_id: `hit:${i + 1}`, severity: "high" }]
1884
+ : []);
1885
+ }
1886
+ allocation = allocateTileBudget([plan], maxTiles).contexts[0];
1887
+ }
1888
+ const start = performance.now();
1889
+ const buffer = await browser.fullPageScreenshotBuffer();
1890
+ const size = pngDimensions(buffer);
1891
+ if (size.width !== plan.page_width || size.height !== plan.page_height)
1892
+ throw opts.captureTransactionAllocation
1893
+ ? new CaptureSourceChanged("Page review image geometry changed after planning.")
1894
+ : new Error("Page review image geometry changed after planning; rerun the review.");
1895
+ const decoded = PNG.sync.read(buffer);
1896
+ const fullPageMs = performance.now() - start;
1897
+ const byId = new Map(plan.candidates.map((c) => [c.id, c]));
1898
+ const selected = allocation.selected_ids.map((id) => byId.get(id));
1899
+ const cropStart = performance.now();
1900
+ const tiles = selected.map((c, index) => ({
1901
+ index,
1902
+ label: c.label,
1903
+ x: c.rect.x,
1904
+ scrollY: c.rect.y,
1905
+ width: c.rect.width,
1906
+ height: c.rect.height,
1907
+ pngBase64: cropNativePng(decoded, c.rect).toString("base64"),
1700
1908
  }));
1909
+ const cropMs = performance.now() - cropStart;
1910
+ const probeStart = performance.now();
1911
+ const reconciled = await reconcileCaptureFidelity(browser, tiles);
1912
+ const probeMs = performance.now() - probeStart;
1913
+ // A lazy image can change the document bottom while a native probe is running.
1914
+ // Revalidate even failed probes so a changed plan gets the same bounded retry.
1915
+ if (opts.captureTransactionAllocation) {
1916
+ const after = await buildReviewCapturePlan(browser, opts);
1917
+ try {
1918
+ validatePageReviewAllocation(allocation, after);
1919
+ }
1920
+ catch (error) {
1921
+ throw new CaptureSourceChanged(String(error));
1922
+ }
1923
+ }
1924
+ if (reconciled.fidelity.probed.length !== tiles.length)
1925
+ throw new Error("Page review native fidelity probe incomplete; no context was saved. " +
1926
+ reconciled.warnings.join("; "));
1927
+ const bandTiles = [];
1928
+ const scopes = new Map();
1929
+ for (const [index, c] of selected.entries()) {
1930
+ const tile = reconciled.tiles[index];
1931
+ if (!c.scope)
1932
+ bandTiles.push(tile);
1933
+ else {
1934
+ const group = scopes.get(c.scope) ?? [];
1935
+ group.push(tile);
1936
+ scopes.set(c.scope, group);
1937
+ }
1938
+ }
1701
1939
  const capture = qaCapture ?? (await captureQaState(browser, targetArg, navResult.url, opts));
1702
- const record = writePackContext(packDir, {
1703
- context: {
1704
- ...capture.context,
1705
- ...(opts.reviewPackContext ? { id: opts.reviewPackContext } : {}),
1706
- },
1940
+ const writeContext = (destination) => writePackContext(destination, {
1941
+ context: { ...capture.context, id: plan.context_id },
1707
1942
  url: navResult.url,
1708
1943
  title: navResult.title,
1709
1944
  fullPage: buffer,
1710
- pageWidth: page.width,
1711
- pageHeight: page.height,
1945
+ pageWidth: plan.page_width / plan.dpr,
1946
+ pageHeight: plan.page_height / plan.dpr,
1712
1947
  tiles: bandTiles,
1713
- coverage: coverage ?? {
1714
- page_height_px: page.height,
1715
- reviewed_height_px: 0,
1716
- bands_total: 0,
1717
- bands_reviewed: 0,
1718
- capped: false,
1719
- },
1720
- scopeTiles: scopeTiles.map(({ selector, tiles }) => ({ selector, tiles })),
1948
+ coverage: allocation.coverage,
1949
+ scopeTiles: [...scopes].map(([selector, tiles]) => ({ selector, tiles })),
1721
1950
  signature: capture.signature,
1722
1951
  domHtml: capture.domHtml,
1723
- ...(captureFidelity ? { captureFidelity } : {}),
1724
- hitBands: banded?.hitBands ?? 0,
1952
+ captureFidelity: reconciled.fidelity,
1953
+ viewportSize: { width: plan.viewport_width, height: plan.viewport_height },
1954
+ dpr: plan.dpr,
1955
+ recipeVersion: plan.recipe_version,
1956
+ allocationCoverage: allocation.coverage,
1957
+ capturePlan: allocation.plan,
1958
+ hitBands: 0,
1725
1959
  });
1726
- emit.log(`review-pack: ${record.id} → ${record.tiles.length} tile(s)` +
1727
- (record.scopes.length > 0
1728
- ? ` (${record.scopes.map((s) => `${s.selector}: ${s.tiles}`).join(", ")})`
1729
- : "") +
1730
- ` written to ${packDir}`, "info");
1960
+ const record = opts.captureTransactionAllocation
1961
+ ? writeCaptureTransaction(packDir, plan.context_id, writeContext)
1962
+ : writeContext(packDir);
1963
+ const warnings = [...reconciled.warnings];
1964
+ if (allocation.coverage.capped)
1965
+ warnings.push(`Review coverage is incomplete: ${allocation.coverage.uncovered_intervals.length} uncovered intervals and ${allocation.coverage.omitted_scopes.length} omitted scopes.`);
1966
+ emit.log(`review-pack: ${record.id} captured ${record.tiles.length} tiles; full-page ${Math.round(fullPageMs)} ms, crop ${Math.round(cropMs)} ms, native probe ${Math.round(probeMs)} ms`, "info");
1731
1967
  return {
1732
1968
  dir: packDir,
1733
1969
  context_id: record.id,
@@ -1735,9 +1971,8 @@ async function captureReviewPackContext(browser, targetArg, navResult, opts, qaC
1735
1971
  scopes: record.scopes,
1736
1972
  coverage: record.coverage,
1737
1973
  files: record.files,
1738
- ...(record.hit_bands !== undefined ? { hit_bands: record.hit_bands } : {}),
1739
- ...(record.capture_fidelity ? { capture_fidelity: record.capture_fidelity } : {}),
1740
- ...(fidelityWarnings.length > 0 ? { warnings: fidelityWarnings } : {}),
1974
+ capture_fidelity: reconciled.fidelity,
1975
+ ...(warnings.length ? { warnings } : {}),
1741
1976
  };
1742
1977
  }
1743
1978
  async function captureCritiqueTiles(browser, opts, tileBudgetPx) {
@@ -1858,7 +2093,7 @@ async function waitForTerminalEnter(signal) {
1858
2093
  signal.addEventListener("abort", onAbort, { once: true });
1859
2094
  });
1860
2095
  }
1861
- /** Upper bound on --review-pack-hit-rect values (and on hit bands cut past the cap). */
2096
+ /** Upper bound on manually supplied --review-pack-hit-rect values. */
1862
2097
  const REVIEW_PACK_HIT_RECT_MAX = 50;
1863
2098
  /** `x,y,w,h` integer rectangles from repeated --review-pack-hit-rect flags. */
1864
2099
  function parseReviewPackHitRects(specs) {