harnery 0.45.2 → 0.46.1

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 (227) hide show
  1. package/dist/commander.d.ts +13 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commands/admission.d.ts.map +1 -1
  4. package/dist/commands/admission.js +214 -27
  5. package/dist/commands/agents.d.ts +4 -0
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +102 -25
  8. package/dist/commands/browse.d.ts +3 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +172 -8
  11. package/dist/commands/fetch.js +1 -0
  12. package/dist/commands/ledger-v3.d.ts.map +1 -1
  13. package/dist/commands/ledger-v3.js +8 -2
  14. package/dist/commands/presence.d.ts.map +1 -1
  15. package/dist/commands/presence.js +40 -0
  16. package/dist/commands/qa-run.d.ts.map +1 -1
  17. package/dist/commands/qa-run.js +4 -3
  18. package/dist/commands/review-pack.d.ts.map +1 -1
  19. package/dist/commands/review-pack.js +33 -6
  20. package/dist/core/agents/index.d.ts +1 -0
  21. package/dist/core/agents/index.d.ts.map +1 -1
  22. package/dist/core/agents/index.js +1 -0
  23. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  24. package/dist/core/agents/live-authority-v3.js +11 -4
  25. package/dist/core/agents/render/session-context.d.ts +0 -6
  26. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  27. package/dist/core/agents/render/session-context.js +1 -6
  28. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  29. package/dist/core/agents/rules/claim-conflict.js +36 -16
  30. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  31. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  32. package/dist/core/agents/rules/stop-hook.js +20 -1
  33. package/dist/core/agents/session-events.d.ts.map +1 -1
  34. package/dist/core/agents/session-events.js +12 -4
  35. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  36. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  37. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  38. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  39. package/dist/core/agents/state/live-coordination-view.js +6 -2
  40. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  41. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  42. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  43. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  44. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  45. package/dist/core/agents/state/parent-reference.js +119 -0
  46. package/dist/core/config.d.ts +39 -0
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +63 -0
  49. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  50. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  51. package/dist/core/diagnostics/path-identity.js +51 -6
  52. package/dist/core/events/v3/canonical.js +43 -14
  53. package/dist/core/events/v3/index.d.ts +1 -1
  54. package/dist/core/events/v3/index.d.ts.map +1 -1
  55. package/dist/core/events/v3/index.js +1 -1
  56. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  57. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  58. package/dist/core/events/v3/producers/recorder.js +117 -13
  59. package/dist/core/events/v3/reader.d.ts.map +1 -1
  60. package/dist/core/events/v3/reader.js +53 -13
  61. package/dist/core/events/v3/validate.d.ts.map +1 -1
  62. package/dist/core/events/v3/validate.js +36 -1
  63. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  64. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  65. package/dist/core/hooks/adapter/native-parent.js +91 -0
  66. package/dist/core/hooks/cli.js +103 -5
  67. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  68. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  69. package/dist/core/hooks/resolve/owner.js +0 -23
  70. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  71. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  72. package/dist/core/hooks/resolve/transcript.js +54 -0
  73. package/dist/core/hooks/shell-admission.d.ts +84 -0
  74. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  75. package/dist/core/hooks/shell-admission.js +622 -0
  76. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  77. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  78. package/dist/core/hooks/shell-commit-tree.js +104 -0
  79. package/dist/core/inbox/contract.d.ts +12 -0
  80. package/dist/core/inbox/contract.d.ts.map +1 -1
  81. package/dist/core/inbox/contract.js +16 -0
  82. package/dist/core/inbox/peer-messages.d.ts +41 -16
  83. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  84. package/dist/core/inbox/peer-messages.js +372 -15
  85. package/dist/core/inbox/service.d.ts +8 -1
  86. package/dist/core/inbox/service.d.ts.map +1 -1
  87. package/dist/core/inbox/service.js +83 -3
  88. package/dist/core/inbox/targets.d.ts +17 -3
  89. package/dist/core/inbox/targets.d.ts.map +1 -1
  90. package/dist/core/inbox/targets.js +77 -2
  91. package/dist/core/presence/git.d.ts +18 -12
  92. package/dist/core/presence/git.d.ts.map +1 -1
  93. package/dist/core/presence/git.js +32 -38
  94. package/dist/core/presence/index.d.ts +2 -2
  95. package/dist/core/presence/index.d.ts.map +1 -1
  96. package/dist/core/presence/index.js +8 -6
  97. package/dist/core/presence/mail.d.ts +133 -0
  98. package/dist/core/presence/mail.d.ts.map +1 -0
  99. package/dist/core/presence/mail.js +464 -0
  100. package/dist/core/semantic/contract.d.ts +5 -5
  101. package/dist/core/storage/modes.d.ts +4 -0
  102. package/dist/core/storage/modes.d.ts.map +1 -1
  103. package/dist/core/storage/modes.js +4 -0
  104. package/dist/core/storage/segments.d.ts.map +1 -1
  105. package/dist/core/storage/segments.js +5 -4
  106. package/dist/lib/admission-history.d.ts +96 -0
  107. package/dist/lib/admission-history.d.ts.map +1 -0
  108. package/dist/lib/admission-history.js +196 -0
  109. package/dist/lib/admission.d.ts +56 -7
  110. package/dist/lib/admission.d.ts.map +1 -1
  111. package/dist/lib/admission.js +161 -19
  112. package/dist/lib/browser/client.d.ts +18 -0
  113. package/dist/lib/browser/client.d.ts.map +1 -1
  114. package/dist/lib/browser/client.js +100 -12
  115. package/dist/lib/browser/content-checks.d.ts +3 -1
  116. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  117. package/dist/lib/browser/content-checks.js +10 -2
  118. package/dist/lib/browser/critique.d.ts +32 -0
  119. package/dist/lib/browser/critique.d.ts.map +1 -1
  120. package/dist/lib/browser/critique.js +72 -3
  121. package/dist/lib/browser/geometry.d.ts +2 -0
  122. package/dist/lib/browser/geometry.d.ts.map +1 -1
  123. package/dist/lib/browser/geometry.js +61 -15
  124. package/dist/lib/browser/index.d.ts +1 -1
  125. package/dist/lib/browser/index.d.ts.map +1 -1
  126. package/dist/lib/browser/index.js +1 -1
  127. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  128. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  129. package/dist/lib/browser/page-review-contracts.js +6 -1
  130. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  131. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  132. package/dist/lib/browser/page-review-judge.js +41 -11
  133. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  134. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  135. package/dist/lib/browser/page-review-pack.js +5 -2
  136. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  137. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  138. package/dist/lib/browser/qa-run-contracts.js +11 -4
  139. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  140. package/dist/lib/browser/qa-run.js +73 -16
  141. package/dist/lib/browser/runts.d.ts +4 -2
  142. package/dist/lib/browser/runts.d.ts.map +1 -1
  143. package/dist/lib/browser/runts.js +84 -33
  144. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  145. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  146. package/dist/lib/browser/windows-chrome.js +42 -0
  147. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  148. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  149. package/dist/lib/browser/windows-login-sync.js +171 -0
  150. package/dist/lib/cookies/client.d.ts +30 -5
  151. package/dist/lib/cookies/client.d.ts.map +1 -1
  152. package/dist/lib/cookies/client.js +55 -10
  153. package/dist/lib/cookies/index.d.ts +1 -1
  154. package/dist/lib/cookies/index.d.ts.map +1 -1
  155. package/dist/lib/cookies/index.js +1 -1
  156. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  157. package/dist/lib/docs-metadata-sync.js +41 -2
  158. package/dist/lib/durable-job.d.ts +8 -0
  159. package/dist/lib/durable-job.d.ts.map +1 -1
  160. package/dist/lib/durable-job.js +10 -0
  161. package/dist/lib/http/client.d.ts +9 -8
  162. package/dist/lib/http/client.d.ts.map +1 -1
  163. package/dist/lib/http/client.js +44 -2
  164. package/dist/lib/instructions/templates.js +4 -4
  165. package/package.json +2 -2
  166. package/src/commander.ts +13 -0
  167. package/src/commands/admission.ts +272 -29
  168. package/src/commands/agents.ts +132 -27
  169. package/src/commands/browse.ts +208 -6
  170. package/src/commands/fetch.ts +1 -0
  171. package/src/commands/ledger-v3.ts +8 -2
  172. package/src/commands/presence.ts +49 -0
  173. package/src/commands/qa-run.ts +4 -3
  174. package/src/commands/review-pack.ts +41 -5
  175. package/src/core/agents/index.ts +6 -0
  176. package/src/core/agents/live-authority-v3.ts +11 -4
  177. package/src/core/agents/render/session-context.ts +1 -7
  178. package/src/core/agents/rules/claim-conflict.ts +50 -18
  179. package/src/core/agents/rules/stop-hook.ts +28 -1
  180. package/src/core/agents/session-events.ts +18 -3
  181. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  182. package/src/core/agents/state/live-coordination-view.ts +13 -6
  183. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  184. package/src/core/agents/state/parent-reference.ts +179 -0
  185. package/src/core/config.ts +85 -0
  186. package/src/core/diagnostics/path-identity.ts +52 -6
  187. package/src/core/events/v3/canonical.ts +46 -13
  188. package/src/core/events/v3/index.ts +3 -0
  189. package/src/core/events/v3/producers/recorder.ts +151 -13
  190. package/src/core/events/v3/reader.ts +55 -11
  191. package/src/core/events/v3/validate.ts +42 -1
  192. package/src/core/hooks/adapter/native-parent.ts +109 -0
  193. package/src/core/hooks/cli.ts +110 -4
  194. package/src/core/hooks/resolve/owner.ts +0 -23
  195. package/src/core/hooks/resolve/transcript.ts +61 -0
  196. package/src/core/hooks/shell-admission.ts +634 -0
  197. package/src/core/hooks/shell-commit-tree.ts +102 -0
  198. package/src/core/inbox/contract.ts +18 -0
  199. package/src/core/inbox/peer-messages.ts +501 -15
  200. package/src/core/inbox/service.ts +108 -3
  201. package/src/core/inbox/targets.ts +94 -3
  202. package/src/core/presence/git.ts +45 -37
  203. package/src/core/presence/index.ts +16 -8
  204. package/src/core/presence/mail.ts +620 -0
  205. package/src/core/storage/modes.ts +6 -0
  206. package/src/core/storage/segments.ts +5 -4
  207. package/src/lib/admission-history.ts +267 -0
  208. package/src/lib/admission.ts +217 -19
  209. package/src/lib/browser/client.ts +147 -35
  210. package/src/lib/browser/content-checks.ts +14 -3
  211. package/src/lib/browser/critique.ts +113 -3
  212. package/src/lib/browser/geometry.ts +63 -14
  213. package/src/lib/browser/index.ts +5 -0
  214. package/src/lib/browser/page-review-contracts.ts +6 -1
  215. package/src/lib/browser/page-review-judge.ts +58 -13
  216. package/src/lib/browser/page-review-pack.ts +13 -2
  217. package/src/lib/browser/qa-run-contracts.ts +30 -4
  218. package/src/lib/browser/qa-run.ts +78 -17
  219. package/src/lib/browser/runts.ts +85 -37
  220. package/src/lib/browser/windows-chrome.ts +50 -0
  221. package/src/lib/browser/windows-login-sync.ts +275 -0
  222. package/src/lib/cookies/client.ts +62 -10
  223. package/src/lib/cookies/index.ts +3 -0
  224. package/src/lib/docs-metadata-sync.ts +44 -2
  225. package/src/lib/durable-job.ts +15 -0
  226. package/src/lib/http/client.ts +63 -1
  227. package/src/lib/instructions/templates.ts +4 -4
@@ -151,6 +151,8 @@ export interface CrowdResult {
151
151
  }
152
152
 
153
153
  export interface LayoutLintRequest {
154
+ /** Explicit traversal budget; omission retains each rule's existing limit. */
155
+ scanNodeBudget?: number;
154
156
  align: Array<{ selector: string; axis: LayoutAxis; tolerancePx: number }>;
155
157
  gap: Array<{
156
158
  selector: string;
@@ -204,9 +206,26 @@ export function intersectRects(a: LayoutRect, b: LayoutRect): LayoutRect | null
204
206
  */
205
207
  export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLintResult {
206
208
  return (request) => {
207
- const CHILD_LIMIT = 1_000;
208
- const TEXT_LIMIT = 1_000;
209
+ if (
210
+ request.scanNodeBudget !== undefined &&
211
+ (!Number.isSafeInteger(request.scanNodeBudget) ||
212
+ request.scanNodeBudget < 1 ||
213
+ request.scanNodeBudget > 100_000)
214
+ ) {
215
+ throw new Error("scanNodeBudget must be an integer from 1 to 100000.");
216
+ }
217
+ const CHILD_LIMIT = request.scanNodeBudget ?? 1_000;
218
+ const TEXT_LIMIT = request.scanNodeBudget ?? 1_000;
209
219
  const ISSUE_LIMIT = 100;
220
+ const translationOnly = (transform: string): boolean => {
221
+ if (!transform || transform === "none") return true;
222
+ try {
223
+ const matrix = new DOMMatrixReadOnly(transform);
224
+ return matrix.is2D && matrix.a === 1 && matrix.b === 0 && matrix.c === 0 && matrix.d === 1;
225
+ } catch {
226
+ return false;
227
+ }
228
+ };
210
229
  // Chromium geometry is fractional even when authored CSS is integral.
211
230
  // Differences below half a CSS pixel are rasterization noise, not clipping.
212
231
  const SUBPIXEL_EPSILON = 0.5;
@@ -673,17 +692,22 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
673
692
  container: Element,
674
693
  seed: LayoutRect,
675
694
  seedLabel: string,
676
- ): { allowed: LayoutRect; clippedBy: { x: string; y: string } } => {
695
+ ): { allowed: LayoutRect; clippedBy: { x: string; y: string }; supported: boolean } => {
677
696
  let allowed = { ...seed };
697
+ let supported = true;
678
698
  const clippedBy = { x: seedLabel, y: seedLabel };
679
699
  const free = { x: false, y: false };
680
700
  let current: Element | null = start;
681
701
  while (current && container.contains(current)) {
682
702
  const style = getComputedStyle(current);
683
- if (style.clipPath && style.clipPath !== "none")
703
+ if (style.clipPath && style.clipPath !== "none") {
684
704
  unsupported.add(`${labelOf(current)}:clip-path`);
685
- if (style.transform && style.transform !== "none")
705
+ supported = false;
706
+ }
707
+ if (!translationOnly(style.transform)) {
686
708
  unsupported.add(`${labelOf(current)}:transform`);
709
+ supported = false;
710
+ }
687
711
  // The container bounds by default even when it does not CSS-clip,
688
712
  // since it is the scope the caller asked about -- but a scroller
689
713
  // hands its axis back whether it is the scope or an ancestor.
@@ -727,7 +751,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
727
751
  if (scope) break;
728
752
  current = current.parentElement;
729
753
  }
730
- return { allowed, clippedBy };
754
+ return { allowed, clippedBy, supported };
731
755
  };
732
756
 
733
757
  const nearestBlockOwner = (node: Node, scope: Element): Element | null => {
@@ -755,6 +779,15 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
755
779
  excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "visually-hidden" });
756
780
  return;
757
781
  }
782
+ const ownStyle = getComputedStyle(element);
783
+ if (!translationOnly(ownStyle.transform)) {
784
+ unsupported.add(`${labelOf(element)}:transform`);
785
+ return;
786
+ }
787
+ if (ownStyle.clipPath && ownStyle.clipPath !== "none") {
788
+ unsupported.add(`${labelOf(element)}:clip-path`);
789
+ return;
790
+ }
758
791
  const rect = element.getBoundingClientRect();
759
792
  if (rect.width <= 0 || rect.height <= 0) {
760
793
  excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "zero-area" });
@@ -783,7 +816,8 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
783
816
  seedLabel = labelOf(owner);
784
817
  }
785
818
  const chain = clipChain(element.parentElement, container, seed, seedLabel);
786
- recordIssue(measure(element, index, false), chain.allowed, chain.clippedBy);
819
+ if (chain.supported)
820
+ recordIssue(measure(element, index, false), chain.allowed, chain.clippedBy);
787
821
  });
788
822
 
789
823
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
@@ -825,7 +859,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
825
859
  let current: Element | null = owner;
826
860
  while (current && container.contains(current)) {
827
861
  const transform = getComputedStyle(current).transform;
828
- if (transform && transform !== "none") {
862
+ if (!translationOnly(transform)) {
829
863
  unsupported.add(`${labelOf(current)}:transform`);
830
864
  transformed = true;
831
865
  break;
@@ -851,6 +885,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
851
885
  seed.width = Math.max(0, seed.right - seed.left);
852
886
  }
853
887
  const chain = clipChain(owner, container, seed, labelOf(owner));
888
+ if (!chain.supported) continue;
854
889
  const allowed = chain.allowed;
855
890
  const clippedBy = chain.clippedBy;
856
891
  const range = document.createRange();
@@ -877,7 +912,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
877
912
  // Border radius keeps the rectangular padding-box contract; only shapes
878
913
  // the rectangle cannot certify contribute to unsupported geometry.
879
914
  const outcome: LayoutOutcome =
880
- issues.length > 0 ? "fail" : unsupported.size > 0 ? "unknown" : "pass";
915
+ issues.length > 0 ? "fail" : unsupported.size > 0 || truncated ? "unknown" : "pass";
881
916
  return {
882
917
  rule: "clip" as const,
883
918
  selector,
@@ -966,7 +1001,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
966
1001
  // and badges (radius ≥ half their smaller side) are inline controls, not
967
1002
  // layout cards, and are excluded too. Plain blocks with no panel chrome
968
1003
  // and no panel descendants stay out of the peer list.
969
- const PARENT_LIMIT = 2000;
1004
+ const PARENT_LIMIT = request.scanNodeBudget ?? 2000;
970
1005
  const PANEL_DESCENDANT_LIMIT = 100;
971
1006
  const OVERLAP_EPS = 1;
972
1007
  const STRUCTURAL_TAGS = new Set([
@@ -1036,13 +1071,18 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
1036
1071
  const collectPanelFaces = (
1037
1072
  element: Element,
1038
1073
  selfIsPanel: boolean,
1074
+ onTruncated: () => void,
1039
1075
  ): LayoutElementMeasurement[] => {
1040
1076
  if (selfIsPanel) return [measure(element, 0, false)];
1041
1077
  const faces: LayoutElementMeasurement[] = [];
1042
1078
  const descendants = element.querySelectorAll("*");
1043
- for (let i = 0; i < descendants.length && faces.length < PANEL_DESCENDANT_LIMIT; i++) {
1079
+ for (let i = 0; i < descendants.length; i++) {
1044
1080
  const child = descendants[i];
1045
1081
  if (!child || !isInFlowBox(child) || !isPanel(child)) continue;
1082
+ if (faces.length >= PANEL_DESCENDANT_LIMIT) {
1083
+ onTruncated();
1084
+ break;
1085
+ }
1046
1086
  faces.push(measure(child, faces.length, false));
1047
1087
  }
1048
1088
  return faces;
@@ -1054,6 +1094,7 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
1054
1094
  // panels separated only by chrome still get a face-to-face check.
1055
1095
  const collectCrowdPeers = (
1056
1096
  parent: Element,
1097
+ onTruncated: () => void,
1057
1098
  ): Array<{
1058
1099
  kind: "panel" | "composite";
1059
1100
  faces: LayoutElementMeasurement[];
@@ -1063,11 +1104,12 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
1063
1104
  faces: LayoutElementMeasurement[];
1064
1105
  }> = [];
1065
1106
  const children = [...parent.children];
1107
+ if (children.length > CHILD_LIMIT) onTruncated();
1066
1108
  for (let index = 0; index < children.length && index < CHILD_LIMIT; index++) {
1067
1109
  const element = children[index];
1068
1110
  if (!element || !isInFlowBox(element)) continue;
1069
1111
  const selfIsPanel = isPanel(element);
1070
- const faces = collectPanelFaces(element, selfIsPanel);
1112
+ const faces = collectPanelFaces(element, selfIsPanel, onTruncated);
1071
1113
  if (faces.length === 0) continue;
1072
1114
  out.push({
1073
1115
  kind: selfIsPanel ? "panel" : "composite",
@@ -1149,7 +1191,9 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
1149
1191
  const limit = Math.min(parents.length, PARENT_LIMIT);
1150
1192
  for (let p = 0; p < limit; p++) {
1151
1193
  const parent = parents[p]!;
1152
- const peers = collectCrowdPeers(parent);
1194
+ const peers = collectCrowdPeers(parent, () => {
1195
+ truncated = true;
1196
+ });
1153
1197
  for (let i = 1; i < peers.length; i++) {
1154
1198
  const beforePeer = peers[i - 1]!;
1155
1199
  const afterPeer = peers[i]!;
@@ -1178,7 +1222,12 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
1178
1222
  rule: "crowd" as const,
1179
1223
  selector,
1180
1224
  found: true,
1181
- outcome: issues.length > 0 ? ("fail" as const) : ("pass" as const),
1225
+ outcome:
1226
+ issues.length > 0
1227
+ ? ("fail" as const)
1228
+ : truncated
1229
+ ? ("unknown" as const)
1230
+ : ("pass" as const),
1182
1231
  minGapPx,
1183
1232
  issues,
1184
1233
  truncated,
@@ -38,15 +38,20 @@ export type {
38
38
  TruncationResult,
39
39
  } from "./content-checks.js";
40
40
  export {
41
+ addCritiqueUsage,
41
42
  bandRects,
42
43
  type CritiqueCoverage,
43
44
  type CritiqueFinding,
44
45
  type CritiqueProvider,
45
46
  type CritiqueResult,
46
47
  type CritiqueTile,
48
+ type CritiqueUsage,
49
+ type CritiqueUsageTotals,
47
50
  DEFAULT_CRITIQUE_RUBRIC,
51
+ formatCritiqueUsage,
48
52
  normalizeFindings,
49
53
  runCritique,
54
+ sumCritiqueUsage,
50
55
  tilesFromFullPage,
51
56
  } from "./critique.js";
52
57
  export {
@@ -6,7 +6,12 @@ export const PAGE_REVIEW_CAPTURE_PLAN_SCHEMA = "harnery-page-review-capture-plan
6
6
  export const PAGE_REVIEW_NATIVE_EVIDENCE_SCHEMA = "harnery-page-review-native-evidence/v2";
7
7
  export const PAGE_REVIEW_DECISIONS_SCHEMA = "harnery-page-review-decisions/v1";
8
8
  export const PAGE_REVIEW_DEFAULT_TILE_BUDGET = 96;
9
- export const PAGE_REVIEW_MAX_TILE_BUDGET = 400;
9
+ /** Hard ceiling on vision calls in one critique invocation (a qa-run pack,
10
+ * a review-pack judge, or a browse --check-critique). Each call is a separate
11
+ * model session, so the ceiling bounds what one run can spend whatever the
12
+ * job asks for. Pages that need more bands than this get a sampled review
13
+ * (ADR 0224). */
14
+ export const PAGE_REVIEW_MAX_TILE_BUDGET = 160;
10
15
 
11
16
  /** Rectangles are native image pixels, never CSS pixels or overview pixels. */
12
17
  export interface PageReviewRect {
@@ -9,8 +9,20 @@
9
9
  //
10
10
  // Toolkit tier: this module must not import src/core (layering check).
11
11
 
12
- import type { CritiqueFinding, CritiqueProvider, CritiqueTile } from "./critique.js";
13
- import type { PageReviewNativeEvidence } from "./page-review-contracts.js";
12
+ import {
13
+ addCritiqueUsage,
14
+ type CritiqueFinding,
15
+ type CritiqueProvider,
16
+ type CritiqueTile,
17
+ type CritiqueUsage,
18
+ type CritiqueUsageTotals,
19
+ formatCritiqueUsage,
20
+ sumCritiqueUsage,
21
+ } from "./critique.js";
22
+ import {
23
+ PAGE_REVIEW_MAX_TILE_BUDGET,
24
+ type PageReviewNativeEvidence,
25
+ } from "./page-review-contracts.js";
14
26
  import {
15
27
  buildPackNativeEvidence,
16
28
  PAGE_REVIEW_CRITIQUE_SCHEMA,
@@ -69,6 +81,8 @@ export interface JudgePageReviewPackResult {
69
81
  tiles_reused: number;
70
82
  outcome: "pass" | "fail" | "skipped" | "incomplete";
71
83
  provider_meta?: Record<string, unknown>;
84
+ /** Tokens summed over the tiles sent to the provider; absent when none were. */
85
+ usage?: CritiqueUsageTotals;
72
86
  }
73
87
 
74
88
  interface WorkItem {
@@ -262,9 +276,24 @@ export async function judgePageReviewPack(
262
276
  });
263
277
 
264
278
  let meta: Record<string, unknown> | undefined;
279
+ let usage: CritiqueUsageTotals | undefined;
265
280
  let concurrency = 0;
266
281
  const tileTimings: Array<QaRunCritiqueTileTiming | undefined> = new Array(work.length);
267
- if (provider && work.length > 0) {
282
+ // Filled, not sparse: filter() skips holes, which would drop unreported tiles.
283
+ const tileUsage: Array<CritiqueUsage | undefined> = Array.from(
284
+ { length: work.length },
285
+ () => undefined,
286
+ );
287
+ // Every vision call is a separate model session. A pack above the ceiling
288
+ // (an older pack, or one assembled by hand) is refused whole rather than
289
+ // judged in part, so no flag or pack can turn one judge into hundreds of calls.
290
+ const overCeiling =
291
+ work.length > PAGE_REVIEW_MAX_TILE_BUDGET
292
+ ? `pack needs ${work.length} vision calls, above the per-run ceiling of ` +
293
+ `${PAGE_REVIEW_MAX_TILE_BUDGET}; recapture it with a smaller tile budget`
294
+ : undefined;
295
+ if (overCeiling) log(`judge: refused — ${overCeiling}`);
296
+ if (provider && work.length > 0 && !overCeiling) {
268
297
  concurrency = Math.max(
269
298
  1,
270
299
  Math.min(options.concurrency ?? provider.concurrency ?? 4, work.length),
@@ -283,7 +312,14 @@ export async function judgePageReviewPack(
283
312
  const tileStarted = performance.now();
284
313
  let tileOutcome: QaRunCritiqueTileTiming["outcome"] = "passed";
285
314
  try {
286
- const found = await provider({ url: item.url, rubric: item.rubric, tile: item.tile });
315
+ const found = await provider({
316
+ url: item.url,
317
+ rubric: item.rubric,
318
+ tile: item.tile,
319
+ reportUsage: (usage) => {
320
+ tileUsage[i] = addCritiqueUsage(tileUsage[i], usage);
321
+ },
322
+ });
287
323
  for (const finding of found) {
288
324
  entry.findings.push({ ...finding, tile: item.tile.index, tile_id: item.tile.id });
289
325
  }
@@ -302,12 +338,16 @@ export async function judgePageReviewPack(
302
338
  tile_id: item.tile.id,
303
339
  duration_ms: Math.max(0, Math.round(performance.now() - tileStarted)),
304
340
  outcome: tileOutcome,
341
+ ...(tileUsage[i] ? { usage: tileUsage[i] } : {}),
305
342
  };
306
343
  }
307
344
  }
308
345
  };
309
346
  await Promise.all(Array.from({ length: concurrency }, () => worker()));
310
347
  meta = provider.meta?.();
348
+ // Only tiles the pool reached count; an unreached tile spent nothing.
349
+ usage = sumCritiqueUsage(tileUsage.filter((_, index) => tileTimings[index] !== undefined));
350
+ log(`judge: ${formatCritiqueUsage(usage)}`);
311
351
  } else if (provider) {
312
352
  meta = provider.meta?.();
313
353
  }
@@ -319,11 +359,13 @@ export async function judgePageReviewPack(
319
359
  const unjudged = entry.review.filter((tile) => !entry.judged.has(tile.id)).length;
320
360
  const outcome: JudgedContext["outcome"] = !provider
321
361
  ? "skipped"
322
- : findings.some((f) => f.severity === "high")
323
- ? "fail"
324
- : unjudged > 0 || entry.record.capture_incomplete
325
- ? "incomplete"
326
- : "pass";
362
+ : overCeiling
363
+ ? "incomplete"
364
+ : findings.some((f) => f.severity === "high")
365
+ ? "fail"
366
+ : unjudged > 0 || entry.record.capture_incomplete
367
+ ? "incomplete"
368
+ : "pass";
327
369
  return {
328
370
  context_id: entry.record.id,
329
371
  provider: provider ? label : "none",
@@ -334,12 +376,14 @@ export async function judgePageReviewPack(
334
376
  outcome,
335
377
  findings,
336
378
  coverage: entry.record.coverage,
337
- ...(provider
338
- ? {}
339
- : {
379
+ ...(!provider
380
+ ? {
340
381
  error:
341
382
  "no critiqueProvider injected by the host (see HarneryProgramContext.critiqueProvider)",
342
- }),
383
+ }
384
+ : overCeiling
385
+ ? { error: overCeiling }
386
+ : {}),
343
387
  record: entry.record,
344
388
  evidence: {
345
389
  schema: PAGE_REVIEW_CRITIQUE_SCHEMA,
@@ -381,6 +425,7 @@ export async function judgePageReviewPack(
381
425
  tiles_reused: tilesReused,
382
426
  outcome,
383
427
  ...(meta ? { provider_meta: meta } : {}),
428
+ ...(usage ? { usage } : {}),
384
429
  };
385
430
  }
386
431
 
@@ -328,6 +328,9 @@ export interface PageReviewCritiqueRecord {
328
328
  outcome: "pass" | "fail" | "skipped" | "incomplete";
329
329
  findings: Array<CritiqueFinding & { tile_id: string }>;
330
330
  coverage: CritiqueCoverage;
331
+ /** Set by a signoff whose complete review would exceed the per-run tile
332
+ * ceiling: the capped coverage is the accepted, sampled review (ADR 0224). */
333
+ coverage_mode?: "sampled";
331
334
  error?: string;
332
335
  evidence?: {
333
336
  schema: string;
@@ -1364,7 +1367,12 @@ export function resolvePackVerdict(
1364
1367
  (row.outcome === "skipped" && machine_outcome !== "skipped"),
1365
1368
  ) ??
1366
1369
  false) ||
1367
- manifest.contexts?.some((c) => c.capture_incomplete || c.coverage.capped) ||
1370
+ manifest.contexts?.some(
1371
+ (c) =>
1372
+ c.capture_incomplete ||
1373
+ (c.coverage.capped &&
1374
+ !critique?.some((row) => row.context_id === c.id && row.coverage_mode === "sampled")),
1375
+ ) ||
1368
1376
  false;
1369
1377
  const reviewed_outcome: PageReviewVerdict["reviewed_outcome"] =
1370
1378
  blocking > 0
@@ -2067,8 +2075,11 @@ export function renderReviewMarkdown(
2067
2075
  );
2068
2076
  lines.push("|---|---|---|---|---|---|---|---|---|");
2069
2077
  for (const ctx of contexts) {
2078
+ const sampled = manifest.critique?.some(
2079
+ (row) => row.context_id === ctx.id && row.coverage_mode === "sampled",
2080
+ );
2070
2081
  const cov = ctx.coverage.capped
2071
- ? `${ctx.coverage.bands_reviewed}/${ctx.coverage.bands_total} bands, capped`
2082
+ ? `${ctx.coverage.bands_reviewed}/${ctx.coverage.bands_total} bands, ${sampled ? "sampled" : "capped"}`
2072
2083
  : "complete";
2073
2084
  const contacts = ctx.contact_sheet
2074
2085
  ? `[${CONTACT_SHEET_FILENAME}](${ctx.contact_sheet.file})`
@@ -18,6 +18,8 @@
18
18
  // Toolkit tier: this module must not import src/core (layering check).
19
19
 
20
20
  import { createHash } from "node:crypto";
21
+ import type { CritiqueUsage, CritiqueUsageTotals } from "./critique.js";
22
+ import { PAGE_REVIEW_MAX_TILE_BUDGET } from "./page-review-contracts.js";
21
23
  import type { QaContext, QaManifest } from "./qa-plan.js";
22
24
 
23
25
  export const QA_RUN_JOB_SCHEMA_VERSION = 1 as const;
@@ -152,6 +154,9 @@ export interface QaRunCritiqueTileTiming {
152
154
  tile_id: string;
153
155
  duration_ms: number;
154
156
  outcome: "passed" | "failed";
157
+ /** Tokens the provider reported for this tile, retries included. Absent
158
+ * when the backend reports none; that is unknown, not zero. */
159
+ usage?: CritiqueUsage;
155
160
  }
156
161
 
157
162
  /** What share of the page the critique tiles covered, lifted from the browse
@@ -184,6 +189,11 @@ export interface QaRunCritiqueOutcome {
184
189
  dismissed?: Array<{ finding_key: string; target: string; disposition: string }>;
185
190
  /** Tile coverage of the page. Absent when the capture died or reported none. */
186
191
  coverage?: QaRunCritiqueCoverage;
192
+ /** `full`: every band reviewed. `capped`: bands left out; a signoff blocker.
193
+ * `sampled`: signoff on a page whose complete review would exceed the
194
+ * per-run tile ceiling; every edge band, gate hit, and required scope was
195
+ * reviewed and the uncovered intervals are listed in `coverage` (ADR 0224). */
196
+ coverage_mode?: "full" | "capped" | "sampled";
187
197
  }
188
198
 
189
199
  /** The judge stage as one unit: every context's tiles through one bounded
@@ -199,6 +209,8 @@ export interface QaRunCritiquePool {
199
209
  latency_ms?: Record<string, QaRunCritiqueLatency>;
200
210
  /** One entry per attempted tile, in context and tile order. */
201
211
  tile_timings?: QaRunCritiqueTileTiming[];
212
+ /** Tokens summed over the attempted tiles. */
213
+ usage?: CritiqueUsageTotals;
202
214
  }
203
215
 
204
216
  /** Where the run's page review pack lives: the on-disk evidence an agent can
@@ -518,8 +530,15 @@ export function validateQaRunJob(value: unknown): QaRunJobValidation {
518
530
  }
519
531
  if (p?.critique_tile_budget !== undefined) {
520
532
  const n = p.critique_tile_budget;
521
- if (typeof n !== "number" || !Number.isInteger(n) || n < 8 || n > 400) {
522
- errors.push("policy.critique_tile_budget must be an integer between 8 and 400");
533
+ if (
534
+ typeof n !== "number" ||
535
+ !Number.isInteger(n) ||
536
+ n < 8 ||
537
+ n > PAGE_REVIEW_MAX_TILE_BUDGET
538
+ ) {
539
+ errors.push(
540
+ `policy.critique_tile_budget must be an integer between 8 and ${PAGE_REVIEW_MAX_TILE_BUDGET}`,
541
+ );
523
542
  }
524
543
  }
525
544
  if (p?.command_concurrency !== undefined) {
@@ -542,8 +561,15 @@ export function validateQaRunJob(value: unknown): QaRunJobValidation {
542
561
  }
543
562
  if (p?.critique_max_tiles !== undefined) {
544
563
  const n = p.critique_max_tiles;
545
- if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > 400) {
546
- errors.push("policy.critique_max_tiles must be an integer between 1 and 400");
564
+ if (
565
+ typeof n !== "number" ||
566
+ !Number.isInteger(n) ||
567
+ n < 1 ||
568
+ n > PAGE_REVIEW_MAX_TILE_BUDGET
569
+ ) {
570
+ errors.push(
571
+ `policy.critique_max_tiles must be an integer between 1 and ${PAGE_REVIEW_MAX_TILE_BUDGET}`,
572
+ );
547
573
  }
548
574
  }
549
575
  if (p?.critique_pool !== undefined) {
@@ -24,6 +24,7 @@ import {
24
24
  } from "./page-review-budget.js";
25
25
  import {
26
26
  PAGE_REVIEW_DEFAULT_TILE_BUDGET,
27
+ PAGE_REVIEW_MAX_TILE_BUDGET,
27
28
  type PageReviewCapturePlan,
28
29
  type PageReviewContextAllocation,
29
30
  } from "./page-review-contracts.js";
@@ -911,10 +912,29 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
911
912
  log(`critique provider failed to load: ${err instanceof Error ? err.message : String(err)}`);
912
913
  }
913
914
  }
914
- const bandArgs =
915
- provider?.tileBudgetPx !== undefined
916
- ? ["--check-critique-band", String(Math.max(200, Math.min(1400, provider.tileBudgetPx)))]
917
- : [];
915
+ const bandArgs = (ctx: QaRunContext): string[] => {
916
+ const args = [
917
+ ...contextRenderArgs(ctx),
918
+ ...(job.checks ?? [])
919
+ .filter((check) => check.contexts === undefined || check.contexts.includes(ctx.id))
920
+ .flatMap((check) => check.args),
921
+ ];
922
+ let requested = "1400";
923
+ for (let i = 0; i < args.length; i++) {
924
+ if (args[i] === "--check-critique-band") requested = args[i + 1] ?? "";
925
+ else if (args[i]?.startsWith("--check-critique-band=")) {
926
+ requested = args[i]!.slice("--check-critique-band=".length);
927
+ }
928
+ }
929
+ // A larger explicit request needs a declared route budget. Missing or
930
+ // invalid metadata must not turn capture planning into an unbounded path.
931
+ const budget = provider?.tileBudgetPx;
932
+ const cap = budget !== undefined && Number.isFinite(budget) && budget >= 200 ? budget : 1400;
933
+ return [
934
+ "--check-critique-band",
935
+ String(Math.max(200, Math.min(Number.parseInt(requested, 10), cap))),
936
+ ];
937
+ };
918
938
  const scopeArgs =
919
939
  visual === "full-page"
920
940
  ? []
@@ -961,7 +981,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
961
981
  "--qa-state",
962
982
  ctx.state,
963
983
  ...scopeArgs,
964
- ...bandArgs,
984
+ ...bandArgs(ctx),
965
985
  ...critiqueMaxTilesArgs,
966
986
  ]
967
987
  : []),
@@ -1185,6 +1205,10 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1185
1205
 
1186
1206
  enterStage("capture");
1187
1207
  const captureStart = Date.now();
1208
+ // Candidate tiles a complete review of every context would need. Above the
1209
+ // per-run ceiling, signoff accepts a sampled critique (ADR 0224).
1210
+ let fullCoverageTiles = 0;
1211
+ const sampledContexts = new Set<string>();
1188
1212
  if (runsVisual) {
1189
1213
  writeFileSync(
1190
1214
  join(outDir, "preliminary-gates.json"),
@@ -1205,6 +1229,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1205
1229
  );
1206
1230
  return candidate;
1207
1231
  });
1232
+ fullCoverageTiles = plans.reduce((sum, plan) => sum + plan.candidates.length, 0);
1208
1233
  const ceilings =
1209
1234
  job.policy?.critique_max_tiles === undefined
1210
1235
  ? undefined
@@ -1263,7 +1288,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1263
1288
  .flatMap((check) => check.args),
1264
1289
  ...scopeArgs,
1265
1290
  ...critiqueMaxTilesArgs,
1266
- ...bandArgs,
1291
+ ...bandArgs(ctx),
1267
1292
  ];
1268
1293
  log(
1269
1294
  `capture ${ctx.id}${scopeArgs.length > 0 ? ` [${manifest.scopes.map((s) => s.selector).join(",")}]` : ""}`,
@@ -1398,6 +1423,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1398
1423
  provider: judged.pool.provider,
1399
1424
  ...(latency ? { latency_ms: latency } : {}),
1400
1425
  tile_timings: judged.tile_timings,
1426
+ ...(judged.usage ? { usage: judged.usage } : {}),
1401
1427
  };
1402
1428
  for (const row of judged.contexts) {
1403
1429
  judgedById.set(row.context_id, row);
@@ -1477,17 +1503,43 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1477
1503
  });
1478
1504
  }
1479
1505
  // Selected bands may leave gaps anywhere. Signoff needs complete
1480
- // coverage, while review mode reports what was actually captured.
1506
+ // coverage when the whole page fits under the per-run ceiling. Past
1507
+ // it, complete coverage is unavailable by policy, so signoff accepts a
1508
+ // sampled critique that still reviewed every edge band, gate hit, and
1509
+ // required scope; the deterministic gates covered the whole page.
1510
+ let coverageMode: QaRunCritiqueOutcome["coverage_mode"] = row.coverage
1511
+ ? row.coverage.capped
1512
+ ? "capped"
1513
+ : "full"
1514
+ : undefined;
1481
1515
  if (job.mode === "signoff" && row.coverage.capped && contextOutcome !== "failed") {
1482
- contextOutcome = "unknown";
1483
- blockers.push({
1484
- stage: "critique",
1485
- context_id: row.context_id,
1486
- reason:
1487
- `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
1488
- `reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px); raise ` +
1489
- "policy.critique_tile_budget (and any explicit per-context ceiling) for full signoff coverage",
1490
- });
1516
+ // Without the allocation record nothing proves the required regions
1517
+ // were selected, so a sampled signoff is refused.
1518
+ const allocated = row.record.allocation_coverage;
1519
+ const requiredOmitted = allocated
1520
+ ? [
1521
+ ...allocated.omitted_gate_hits.map((id) => `gate hit ${id}`),
1522
+ ...allocated.omitted_scopes.map((scope) => `scope ${scope}`),
1523
+ ]
1524
+ : ["its allocation record (recapture this context)"];
1525
+ if (fullCoverageTiles > PAGE_REVIEW_MAX_TILE_BUDGET && requiredOmitted.length === 0) {
1526
+ coverageMode = "sampled";
1527
+ sampledContexts.add(row.context_id);
1528
+ } else {
1529
+ contextOutcome = "unknown";
1530
+ blockers.push({
1531
+ stage: "critique",
1532
+ context_id: row.context_id,
1533
+ reason:
1534
+ `critique coverage capped: ${row.coverage.bands_reviewed} of ${row.coverage.bands_total} bands ` +
1535
+ `reviewed (${row.coverage.reviewed_height_px} of ${row.coverage.page_height_px} px)` +
1536
+ (requiredOmitted.length > 0
1537
+ ? `; the budget left out ${requiredOmitted.join(", ")}. Raise ` +
1538
+ `policy.critique_tile_budget (at most ${PAGE_REVIEW_MAX_TILE_BUDGET}) so every gate hit and scope is reviewed`
1539
+ : `; the full page needs ${fullCoverageTiles} tiles, so raise ` +
1540
+ `policy.critique_tile_budget (and any explicit per-context ceiling) to ${fullCoverageTiles}`),
1541
+ });
1542
+ }
1491
1543
  }
1492
1544
  const scopeById = new Map(row.record.tiles.map((tile) => [tile.id, tile.scope]));
1493
1545
  critique.push({
@@ -1497,6 +1549,7 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1497
1549
  tiles_reviewed: row.tiles_reviewed,
1498
1550
  tiles_reused: row.tiles_reused,
1499
1551
  outcome: contextOutcome,
1552
+ ...(coverageMode ? { coverage_mode: coverageMode } : {}),
1500
1553
  ...(dismissed.length > 0 ? { dismissed } : {}),
1501
1554
  findings: row.findings.map((finding) => {
1502
1555
  const selector = scopeById.get(finding.tile_id);
@@ -1510,7 +1563,15 @@ export async function runQaMatrix(options: QaRunMatrixOptions): Promise<QaRunRes
1510
1563
  coverage: row.coverage,
1511
1564
  });
1512
1565
  }
1513
- finalizePack("critique", toCritiqueRecords(judged), judged.pool);
1566
+ finalizePack(
1567
+ "critique",
1568
+ toCritiqueRecords(judged).map((record) =>
1569
+ sampledContexts.has(record.context_id)
1570
+ ? { ...record, coverage_mode: "sampled" as const }
1571
+ : record,
1572
+ ),
1573
+ judged.pool,
1574
+ );
1514
1575
  }
1515
1576
  if (runsVisual) restoreHeadlessOnly();
1516
1577
  wall.critique = Date.now() - critiqueStart;