@tangle-network/agent-bench 0.1.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 (111) hide show
  1. package/README.md +15 -0
  2. package/package.json +35 -0
  3. package/src/adapters.ts +60 -0
  4. package/src/aec-gate.mts +217 -0
  5. package/src/atom-humaneval.mts +197 -0
  6. package/src/atom-mcp-e2e.mts +223 -0
  7. package/src/benchmarks/_harness.ts +206 -0
  8. package/src/benchmarks/aec-bench.test.mts +53 -0
  9. package/src/benchmarks/aec-bench.ts +319 -0
  10. package/src/benchmarks/appworld.test.mts +45 -0
  11. package/src/benchmarks/appworld.ts +426 -0
  12. package/src/benchmarks/cad-design.ts +429 -0
  13. package/src/benchmarks/cadbench.ts +135 -0
  14. package/src/benchmarks/cadgenbench.ts +121 -0
  15. package/src/benchmarks/commit0.test.mts +71 -0
  16. package/src/benchmarks/commit0.ts +266 -0
  17. package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
  18. package/src/benchmarks/enterpriseops-gym.ts +343 -0
  19. package/src/benchmarks/finsearchcomp.ts +371 -0
  20. package/src/benchmarks/frames.ts +520 -0
  21. package/src/benchmarks/hotpotqa.ts +320 -0
  22. package/src/benchmarks/humaneval.ts +251 -0
  23. package/src/benchmarks/mind2web.ts +311 -0
  24. package/src/benchmarks/programbench.test.mts +60 -0
  25. package/src/benchmarks/programbench.ts +210 -0
  26. package/src/benchmarks/simpleqa.ts +390 -0
  27. package/src/benchmarks/swe-bench.ts +152 -0
  28. package/src/benchmarks/terminal-bench.ts +182 -0
  29. package/src/benchmarks/trata-hedge.ts +496 -0
  30. package/src/benchmarks/types.ts +58 -0
  31. package/src/browser/adapters/bad-design-audit.ts +153 -0
  32. package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
  33. package/src/browser/adapters/bad.ts +165 -0
  34. package/src/browser/agent-adapter.ts +145 -0
  35. package/src/browser/process-adapter.ts +146 -0
  36. package/src/browser/run-to-spans.ts +105 -0
  37. package/src/browser/run-to-spans.verify.ts +89 -0
  38. package/src/browser/ui-reviewer.ts +200 -0
  39. package/src/browser/ui-reviewer.verify.ts +94 -0
  40. package/src/browser/verify.ts +96 -0
  41. package/src/clbench-codebase-gate.mts +314 -0
  42. package/src/clbench-context-gate.mts +305 -0
  43. package/src/cloud-loop.mts +138 -0
  44. package/src/coding-skills/minimal-diff.md +9 -0
  45. package/src/coding-skills/read-before-edit.md +9 -0
  46. package/src/coding-skills/reproduce-first.md +10 -0
  47. package/src/coding-skills/run-tests-after-edit.md +9 -0
  48. package/src/coding-skills/trace-the-failure.md +9 -0
  49. package/src/commit0-env-run.mts +59 -0
  50. package/src/commit0-env.ts +173 -0
  51. package/src/commit0-gate.mts +529 -0
  52. package/src/commit0-prereqs.sh +48 -0
  53. package/src/corpus-replay.mts +300 -0
  54. package/src/corpus-report.mts +504 -0
  55. package/src/corpus.test.mts +273 -0
  56. package/src/corpus.ts +325 -0
  57. package/src/decoder-live.mts +133 -0
  58. package/src/directives.ts +84 -0
  59. package/src/diverse-gate.mjs +112 -0
  60. package/src/egress-probe.mts +26 -0
  61. package/src/eops-skills/address-every-subtask.md +5 -0
  62. package/src/eops-skills/exact-tools-and-args.md +5 -0
  63. package/src/eops-skills/full-sequence-to-goal.md +5 -0
  64. package/src/eops-skills/ground-every-value.md +5 -0
  65. package/src/eops-skills/honor-the-policies.md +5 -0
  66. package/src/examples/README.md +58 -0
  67. package/src/examples/math-demo.mts +110 -0
  68. package/src/examples/strategy-demo.mts +119 -0
  69. package/src/fleet.mts +121 -0
  70. package/src/gate-cli.mts +101 -0
  71. package/src/gate.test.mts +129 -0
  72. package/src/gate.ts +460 -0
  73. package/src/generate-eval/certify.ts +178 -0
  74. package/src/generate-eval/schema.ts +78 -0
  75. package/src/humaneval-gate.mts +204 -0
  76. package/src/humaneval-repair-gate.mts +143 -0
  77. package/src/index.ts +19 -0
  78. package/src/mcp-mount-probe.mts +126 -0
  79. package/src/profile-coordinates.ts +134 -0
  80. package/src/profiles.ts +128 -0
  81. package/src/refine-loop.test.mts +106 -0
  82. package/src/refine-loop.ts +106 -0
  83. package/src/research-gate.mts +132 -0
  84. package/src/research-shot.ts +134 -0
  85. package/src/resolve-client.ts +58 -0
  86. package/src/router-executor.ts +51 -0
  87. package/src/run-pool.ts +48 -0
  88. package/src/runtime-hook-recorder.ts +137 -0
  89. package/src/sandbox-run.ts +125 -0
  90. package/src/search-bench/bridge.ts +124 -0
  91. package/src/search-bench/export.mts +0 -0
  92. package/src/search-bench/parametric-check.mts +63 -0
  93. package/src/search-bench/profiles.ts +98 -0
  94. package/src/search-bench/run.mts +287 -0
  95. package/src/search-bench/tasks-fresh.ts +688 -0
  96. package/src/search-bench/tasks.ts +129 -0
  97. package/src/search-tool.ts +95 -0
  98. package/src/selector.test.mts +189 -0
  99. package/src/selector.ts +366 -0
  100. package/src/skill-sandbox-smoke.mts +100 -0
  101. package/src/stats.mts +90 -0
  102. package/src/terminal-compare.ts +519 -0
  103. package/src/trajectory-assemble.mjs +130 -0
  104. package/src/trata-gate.mts +243 -0
  105. package/src/trata-gepa.mts +434 -0
  106. package/src/worker-blender.ts +230 -0
  107. package/src/worker-browser.ts +102 -0
  108. package/src/worker-build123d.ts +143 -0
  109. package/src/worker-cad.ts +451 -0
  110. package/src/worker.ts +136 -0
  111. package/src/workspace-loop.mts +133 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Language-agnostic browser-agent adapter over a process boundary.
3
+ *
4
+ * This is how we avoid overfitting to our own driver: ANY browser agent becomes a
5
+ * `BrowserAgentAdapter` by speaking one JSON protocol over stdio — no shared SDK,
6
+ * no TypeScript requirement. A Python `browser-use` runner, our TS `bad` CLI, or a
7
+ * hosted service all plug in identically.
8
+ *
9
+ * Protocol (stdio):
10
+ * - The harness spawns `cmd ...args`, writes a BrowserTask as one JSON line to
11
+ * the child's stdin, and closes stdin.
12
+ * - The child runs its agent and writes EXACTLY one JSON object — a BrowserRun —
13
+ * to stdout (anything on stderr is logged, ignored for parsing).
14
+ * - Non-zero exit, no JSON, or a malformed run → throw (fail loud; a driver that
15
+ * can't conform is not silently scored zero).
16
+ *
17
+ * The child is responsible only for producing a faithful trajectory + final state.
18
+ * The VERDICT is never the child's to give — `judgeBrowserRun` derives it from the
19
+ * benchmark's deterministic SuccessSpec against the returned final state.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process'
23
+ import type { BrowserAgentAdapter, BrowserRun, BrowserStep, BrowserTask } from './agent-adapter'
24
+
25
+ export interface ProcessAdapterOptions {
26
+ /** Attribution id for head-to-head (e.g. 'browser-use', 'bad'). */
27
+ id: string
28
+ /** Executable to spawn (e.g. 'python', 'node', 'bad'). */
29
+ cmd: string
30
+ /** Fixed args before the per-task protocol (e.g. ['runners/browser_use_runner.py']). */
31
+ args?: string[]
32
+ /** Extra env for the child (model keys, etc.). Merged over process.env. */
33
+ env?: Record<string, string>
34
+ /** Hard ceiling per task in ms (default 300_000). */
35
+ timeoutMs?: number
36
+ /** Working directory for the child. */
37
+ cwd?: string
38
+ }
39
+
40
+ /** Build a BrowserAgentAdapter that delegates to an external process speaking the
41
+ * JSON protocol above. */
42
+ export function processBrowserAdapter(opts: ProcessAdapterOptions): BrowserAgentAdapter {
43
+ const timeoutMs = opts.timeoutMs ?? 300_000
44
+ return {
45
+ id: opts.id,
46
+ async run(task: BrowserTask): Promise<BrowserRun> {
47
+ const stdout = await runChild(opts, JSON.stringify(task), timeoutMs)
48
+ const run = parseRun(stdout, task, opts.id)
49
+ return run
50
+ },
51
+ }
52
+ }
53
+
54
+ function runChild(opts: ProcessAdapterOptions, input: string, timeoutMs: number): Promise<string> {
55
+ return new Promise((resolve, reject) => {
56
+ const child = spawn(opts.cmd, opts.args ?? [], {
57
+ cwd: opts.cwd,
58
+ env: { ...process.env, ...opts.env },
59
+ stdio: ['pipe', 'pipe', 'pipe'],
60
+ })
61
+ let out = ''
62
+ let err = ''
63
+ const timer = setTimeout(() => {
64
+ child.kill('SIGKILL')
65
+ reject(new Error(`browser adapter '${opts.id}' timed out after ${timeoutMs}ms`))
66
+ }, timeoutMs)
67
+ child.stdout.on('data', (d) => (out += d.toString()))
68
+ child.stderr.on('data', (d) => (err += d.toString()))
69
+ child.on('error', (e) => {
70
+ clearTimeout(timer)
71
+ reject(new Error(`browser adapter '${opts.id}' failed to spawn ${opts.cmd}: ${e.message}`))
72
+ })
73
+ child.on('close', (code) => {
74
+ clearTimeout(timer)
75
+ if (code !== 0) {
76
+ reject(new Error(`browser adapter '${opts.id}' exited ${code}: ${err.slice(-400) || out.slice(-400)}`))
77
+ return
78
+ }
79
+ resolve(out)
80
+ })
81
+ child.stdin.write(input)
82
+ child.stdin.end()
83
+ })
84
+ }
85
+
86
+ /** Parse + validate the child's BrowserRun. The last complete JSON object on stdout
87
+ * is the result (drivers may print progress lines before it). Fail loud on a shape
88
+ * that the judge cannot trust. */
89
+ function parseRun(stdout: string, task: BrowserTask, driverId: string): BrowserRun {
90
+ const obj = lastJsonObject(stdout)
91
+ if (!obj) throw new Error(`browser adapter '${driverId}' produced no JSON BrowserRun for task ${task.id}`)
92
+ if (typeof obj.finalUrl !== 'string' || !Array.isArray(obj.steps)) {
93
+ throw new Error(`browser adapter '${driverId}' BrowserRun missing finalUrl/steps for task ${task.id}`)
94
+ }
95
+ const steps: BrowserStep[] = (obj.steps as unknown[]).map((s, i) => {
96
+ const o = (s ?? {}) as Record<string, unknown>
97
+ return {
98
+ index: typeof o.index === 'number' ? o.index : i,
99
+ url: typeof o.url === 'string' ? o.url : '',
100
+ action: typeof o.action === 'string' ? o.action : 'step',
101
+ target: typeof o.target === 'string' ? o.target : undefined,
102
+ reasoning: typeof o.reasoning === 'string' ? o.reasoning : undefined,
103
+ screenshotPath: typeof o.screenshotPath === 'string' ? o.screenshotPath : undefined,
104
+ actionBounds: isBounds(o.actionBounds) ? (o.actionBounds as BrowserStep['actionBounds']) : undefined,
105
+ }
106
+ })
107
+ const usage =
108
+ obj.usage && typeof obj.usage === 'object'
109
+ ? {
110
+ input: Number((obj.usage as Record<string, unknown>).input) || 0,
111
+ output: Number((obj.usage as Record<string, unknown>).output) || 0,
112
+ }
113
+ : undefined
114
+ return {
115
+ taskId: task.id,
116
+ steps,
117
+ finalUrl: obj.finalUrl as string,
118
+ finalDom: typeof obj.finalDom === 'string' ? obj.finalDom : undefined,
119
+ answer: typeof obj.answer === 'string' ? obj.answer : undefined,
120
+ selfReportedSuccess: typeof obj.selfReportedSuccess === 'boolean' ? obj.selfReportedSuccess : undefined,
121
+ usage,
122
+ costUsd: typeof obj.costUsd === 'number' ? obj.costUsd : undefined,
123
+ driverId,
124
+ }
125
+ }
126
+
127
+ function isBounds(v: unknown): boolean {
128
+ if (!v || typeof v !== 'object') return false
129
+ const o = v as Record<string, unknown>
130
+ return ['x', 'y', 'width', 'height'].every((k) => typeof o[k] === 'number')
131
+ }
132
+
133
+ /** Find the last top-level JSON object in mixed stdout (progress lines + result). */
134
+ function lastJsonObject(s: string): Record<string, unknown> | null {
135
+ for (let i = s.lastIndexOf('{'); i >= 0; i = s.lastIndexOf('{', i - 1)) {
136
+ const slice = s.slice(i)
137
+ try {
138
+ const parsed = JSON.parse(slice)
139
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
140
+ } catch {
141
+ // keep scanning earlier '{'
142
+ }
143
+ if (i === 0) break
144
+ }
145
+ return null
146
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pure converter: a neutral `BrowserRun` (from any `BrowserAgentAdapter`) → a
3
+ * `Span[]` trace run-capsule renders into a navigation film. One `kind:'tool'`
4
+ * span per `BrowserStep` (`browser.<action>`); when a step carries a
5
+ * `screenshotPath`, the file is read and inlined as a base64 `data:` URI on
6
+ * `attributes.screenshot` — run-capsule renders from the inline blob, NOT a path,
7
+ * so a raw path would silently render nothing.
8
+ *
9
+ * Kept pure + spawn-free (the adapter already ran the browser) so the mapping is
10
+ * unit-testable against a fixture without launching Chromium. Timestamps are
11
+ * synthesized monotonically from `opts.startTs` (default 0) — deterministic for
12
+ * the verify; the live caller passes `Date.now()`.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises'
16
+ import type { Span } from '@tangle-network/agent-eval'
17
+ import type { BrowserRun, BrowserStep } from './agent-adapter'
18
+
19
+ export interface BrowserRunToSpansOptions {
20
+ /** Base timestamp; each emitted span advances by 1ms. Default 0 (deterministic). */
21
+ startTs?: number
22
+ /** Cap on inlined screenshot bytes before base64 (skip oversized frames). Default 8 MiB. */
23
+ maxScreenshotBytes?: number
24
+ }
25
+
26
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47])
27
+ const JPG = Buffer.from([0xff, 0xd8, 0xff])
28
+
29
+ /** Sniff the on-disk image mime from magic bytes; default png. */
30
+ function imageMime(buf: Buffer): string {
31
+ if (buf.subarray(0, 4).equals(PNG)) return 'image/png'
32
+ if (buf.subarray(0, 3).equals(JPG)) return 'image/jpeg'
33
+ return 'image/png'
34
+ }
35
+
36
+ /** Resolve a step's frame to an inline `data:` URI for the film. Prefers an
37
+ * already-inline `step.screenshot` (e.g. bad's base64, whose sink is reaped),
38
+ * else reads `step.screenshotPath` off disk. Fail-soft: an unset / unreadable /
39
+ * oversized frame drops the image, never the span — the navigation step still
40
+ * belongs in the film. */
41
+ async function resolveFrame(step: BrowserStep, maxBytes: number): Promise<string | undefined> {
42
+ if (step.screenshot) {
43
+ return step.screenshot.length <= maxBytes * 2 ? step.screenshot : undefined
44
+ }
45
+ if (!step.screenshotPath) return undefined
46
+ const buf = await readFile(step.screenshotPath).catch(() => undefined)
47
+ if (!buf || buf.length === 0 || buf.length > maxBytes) return undefined
48
+ return `data:${imageMime(buf)};base64,${buf.toString('base64')}`
49
+ }
50
+
51
+ /**
52
+ * Map a `BrowserRun` to a run-capsule-ready `Span[]`. Async because it reads the
53
+ * step screenshots off disk. The returned trace is ordered by step index; the
54
+ * agent's final answer (extraction tasks) becomes a trailing generic span so the
55
+ * film closes on the result.
56
+ */
57
+ export async function browserRunToSpans(
58
+ run: BrowserRun,
59
+ opts: BrowserRunToSpansOptions = {},
60
+ ): Promise<Span[]> {
61
+ const maxBytes = opts.maxScreenshotBytes ?? 8 * 1024 * 1024
62
+ let ts = opts.startTs ?? 0
63
+ const tick = (): number => (ts += 1)
64
+ const runId = `browser-${run.taskId}`
65
+
66
+ const spans: Span[] = []
67
+ for (const step of run.steps) {
68
+ const action = step.action || 'navigate'
69
+ const screenshot = await resolveFrame(step, maxBytes)
70
+ const startedAt = tick()
71
+ spans.push({
72
+ spanId: `s-${step.index}`,
73
+ runId,
74
+ kind: 'tool',
75
+ name: `browser ${action}`,
76
+ toolName: `browser.${action}`,
77
+ args: { url: step.url, target: step.target, reasoning: step.reasoning },
78
+ startedAt,
79
+ endedAt: tick(),
80
+ status: 'ok',
81
+ // attributes is the run-capsule contract: inline base64 screenshot + the
82
+ // action box for a replay cursor overlay. Only set keys that are present.
83
+ attributes: {
84
+ ...(screenshot ? { screenshot } : {}),
85
+ ...(step.actionBounds ? { actionBounds: step.actionBounds } : {}),
86
+ },
87
+ } as Span)
88
+ }
89
+
90
+ if (run.answer) {
91
+ const startedAt = tick()
92
+ spans.push({
93
+ spanId: 's-answer',
94
+ runId,
95
+ kind: 'custom',
96
+ name: 'final answer',
97
+ startedAt,
98
+ endedAt: tick(),
99
+ status: 'ok',
100
+ attributes: { answer: run.answer, finalUrl: run.finalUrl },
101
+ } as Span)
102
+ }
103
+
104
+ return spans
105
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Deterministic self-check for browserRunToSpans —
3
+ * `tsx src/browser/run-to-spans.verify.ts`.
4
+ *
5
+ * Proves the run-capsule film contract: each BrowserStep becomes a kind:'tool'
6
+ * `browser.<action>` span; a step's screenshotPath is READ and inlined as a
7
+ * base64 `data:` URI on attributes.screenshot (NEVER passed through as a raw
8
+ * path — the regression that renders an empty film); a missing screenshot drops
9
+ * the image but keeps the step's span.
10
+ */
11
+
12
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join } from 'node:path'
15
+ import type { BrowserRun } from './agent-adapter'
16
+ import { browserRunToSpans } from './run-to-spans'
17
+
18
+ let failures = 0
19
+ function check(name: string, cond: boolean, detail = ''): void {
20
+ if (cond) console.log(` ✓ ${name}`)
21
+ else {
22
+ failures += 1
23
+ console.log(` ✗ ${name} ${detail}`)
24
+ }
25
+ }
26
+
27
+ const dir = mkdtempSync(join(tmpdir(), 'run-to-spans-'))
28
+ // A 1x1 PNG on disk — the magic bytes make imageMime resolve to image/png.
29
+ const pngBytes = Buffer.from(
30
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000154a24f7d0000000049454e44ae426082',
31
+ 'hex',
32
+ )
33
+ const shotPath = join(dir, 'step-1.png')
34
+ writeFileSync(shotPath, pngBytes)
35
+
36
+ const run: BrowserRun = {
37
+ taskId: 'usps-track',
38
+ driverId: 'bad',
39
+ steps: [
40
+ { index: 1, url: 'https://tools.usps.com/', action: 'navigate', target: 'tracking input', screenshotPath: shotPath },
41
+ { index: 2, url: 'https://tools.usps.com/go/TrackConfirmAction', action: 'click', target: 'Track button', actionBounds: { x: 10, y: 20, width: 80, height: 30 } },
42
+ ],
43
+ finalUrl: 'https://tools.usps.com/go/TrackConfirmAction',
44
+ answer: 'Delivered',
45
+ selfReportedSuccess: true,
46
+ }
47
+
48
+ console.log('browserRunToSpans verify:')
49
+
50
+ const spans = await browserRunToSpans(run, { startTs: 1000 })
51
+
52
+ check('one tool span per step + a trailing answer span', spans.length === 3, `got ${spans.length}`)
53
+ const s1 = spans[0]
54
+ const s2 = spans[1]
55
+ const answer = spans[2]
56
+ check('step 1 is a kind:tool browser.navigate span', s1?.kind === 'tool' && (s1 as { toolName?: string }).toolName === 'browser.navigate')
57
+ check('step 1 args carry the url + target', (s1 as { args?: { url?: string; target?: string } }).args?.url === 'https://tools.usps.com/' && (s1 as { args?: { target?: string } }).args?.target === 'tracking input')
58
+
59
+ const shot = (s1?.attributes?.screenshot ?? '') as string
60
+ check('screenshotPath was READ + inlined as a base64 data URI (not a path)', shot.startsWith('data:image/png;base64,') && shot.length > 60, shot.slice(0, 40))
61
+ check('the raw on-disk path is NEVER present in the span', !JSON.stringify(s1).includes(shotPath))
62
+
63
+ check('step 2 has no screenshot (none provided) but still emits a span', s2?.kind === 'tool' && s2?.attributes?.screenshot === undefined)
64
+ check('step 2 carries the actionBounds overlay', JSON.stringify(s2?.attributes?.actionBounds) === JSON.stringify({ x: 10, y: 20, width: 80, height: 30 }))
65
+
66
+ check('the answer span closes the film', answer?.kind === 'custom' && answer?.name === 'final answer' && (answer?.attributes as { answer?: string })?.answer === 'Delivered')
67
+ check('timestamps are monotonic from startTs', (s1?.startedAt ?? 0) >= 1000 && (answer?.startedAt ?? 0) > (s1?.startedAt ?? 0))
68
+
69
+ // Inline base64 screenshot (the real `bad` case — state.screenshot, no disk path):
70
+ // used directly, no file read.
71
+ const inlineRun = await browserRunToSpans({
72
+ taskId: 't', driverId: 'bad', finalUrl: 'https://x.test',
73
+ steps: [{ index: 1, url: 'https://x.test', action: 'click', screenshot: 'data:image/jpeg;base64,/9j/INLINE' }],
74
+ })
75
+ check('inline step.screenshot is carried through directly (no disk read)', (inlineRun[0]?.attributes?.screenshot as string) === 'data:image/jpeg;base64,/9j/INLINE')
76
+
77
+ // A run whose only screenshot path is unreadable: span stays, image drops (fail-soft).
78
+ const ghost = await browserRunToSpans({
79
+ taskId: 't', driverId: 'bad', finalUrl: '', steps: [{ index: 1, url: 'about:blank', action: 'navigate', screenshotPath: '/no/such/frame.png' }],
80
+ })
81
+ check('unreadable screenshot drops the image but keeps the step span', ghost.length === 1 && ghost[0]?.attributes?.screenshot === undefined)
82
+
83
+ rmSync(dir, { recursive: true, force: true })
84
+
85
+ if (failures > 0) {
86
+ console.log(`\n❌ ${failures} check(s) failed`)
87
+ process.exit(1)
88
+ }
89
+ console.log('\n✅ browserRunToSpans verified — steps → tool spans, screenshots inlined as base64, no raw paths')
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Driver-agnostic UI-REVIEWER PANEL — the design-review analogue of the
3
+ * browser-agent harness (./agent-adapter.ts).
4
+ *
5
+ * "More reviewers, the better": run a PANEL of UI reviewers over the same target
6
+ * and union their findings — #114's runLoop ui-auditor, browser-agent-driver's
7
+ * `bad design-audit`, a future reviewer — each a pluggable `UiReviewerAdapter`,
8
+ * none of them owning the verdict.
9
+ *
10
+ * Attestability is the spine, exactly as in agent-adapter.ts: a UI design verdict
11
+ * is SUBJECTIVE if it comes from an LLM/heuristic, so the panel NEVER lets a
12
+ * reviewer's own findings or headline score (e.g. a `healthScore`) be the verdict.
13
+ * The attestable verdict is derived ONLY from the DETERMINISTIC floor a reviewer
14
+ * MEASURES — axe a11y violations + WCAG AA contrast — re-applied by `judgeUiFloor`.
15
+ * Subjective findings are surfaced (deduped, attributed) but never gate.
16
+ *
17
+ * A reviewer that contributes no deterministic measurements adds findings to the
18
+ * panel but cannot, by itself, produce an attestable verdict — that is fail-loud,
19
+ * not a silent pass.
20
+ */
21
+
22
+ /** What to review: a site + the routes to visit. */
23
+ export interface UiReviewTarget {
24
+ url: string
25
+ /** Route labels/paths to audit; defaults to a single 'home' at `url`. */
26
+ routes?: { label: string; url: string }[]
27
+ viewport?: { width: number; height: number }
28
+ /** Playwright storageState for authenticated review. */
29
+ storageState?: string
30
+ }
31
+
32
+ /** A SUBJECTIVE finding (LLM/heuristic). Surfaced + deduped, never the verdict. */
33
+ export interface UiFinding {
34
+ reviewerId: string
35
+ /** Lens/category (consistency, hierarchy, accessibility, contrast, ux-flow, …). */
36
+ lens: string
37
+ severity: 'critical' | 'major' | 'minor'
38
+ route: string
39
+ title: string
40
+ observation: string
41
+ impact?: string
42
+ suggestedFix?: string
43
+ /** CSS/Playwright selector, when the reviewer pins one — used for dedup. */
44
+ selector?: string
45
+ }
46
+
47
+ /** The DETERMINISTIC floor — MEASURED, not judged. This is the attestable layer
48
+ * (mirrors browser-agent-driver's `measurements`: axe + WCAG contrast). */
49
+ export interface DesignMeasurements {
50
+ /** axe a11y violations with rule id + impact. */
51
+ a11yViolations: { id: string; impact?: string }[]
52
+ /** WCAG AA contrast pass rate in [0,1] over `contrastTotalChecked` text elements. */
53
+ contrastAaPassRate: number
54
+ contrastAaFailures: number
55
+ contrastTotalChecked: number
56
+ }
57
+
58
+ /** One reviewer's result for one route. */
59
+ export interface UiReviewRun {
60
+ reviewerId: string
61
+ route: string
62
+ findings: UiFinding[]
63
+ /** Present only if the reviewer measured the deterministic floor. */
64
+ measurements?: DesignMeasurements
65
+ /** The reviewer's OWN headline score (e.g. healthScore 0-100). Recorded for
66
+ * comparison, NEVER the verdict. */
67
+ selfReportedScore?: number
68
+ usage?: { input: number; output: number }
69
+ costUsd?: number
70
+ }
71
+
72
+ /** The general seam every UI reviewer implements — `bad design-audit`, the #114
73
+ * ui-auditor, a hosted service. `review` returns one run per route. */
74
+ export interface UiReviewerAdapter {
75
+ readonly id: string
76
+ review(target: UiReviewTarget): Promise<UiReviewRun[]>
77
+ }
78
+
79
+ /** Thresholds for the deterministic blocking gate (defaults mirror the
80
+ * browser-agent-driver design-audit floor: 5+ critical a11y OR >25% contrast fail). */
81
+ export interface UiFloorGateConfig {
82
+ maxCriticalA11y?: number
83
+ maxContrastFailRate?: number
84
+ }
85
+
86
+ const A11Y_CRITICAL = new Set(['critical', 'serious'])
87
+
88
+ /**
89
+ * The ATTESTABLE verdict — derived ONLY from the deterministic floor across the
90
+ * runs that measured it. Ignores subjective findings AND selfReportedScore.
91
+ * resolved = no blocking deterministic issues; score = deterministic design-health
92
+ * in [0,1] (contrast pass rate × a11y cleanliness). Fail-loud if NO run measured
93
+ * the floor — a panel of purely-subjective reviewers cannot attest a verdict.
94
+ */
95
+ export function judgeUiFloor(
96
+ runs: ReadonlyArray<UiReviewRun>,
97
+ cfg: UiFloorGateConfig = {},
98
+ ): { resolved: boolean; score: number; detail: string } {
99
+ const maxCriticalA11y = cfg.maxCriticalA11y ?? 5
100
+ const maxContrastFailRate = cfg.maxContrastFailRate ?? 0.25
101
+ const measured = runs.filter((r) => r.measurements)
102
+ if (measured.length === 0) {
103
+ throw new Error(
104
+ 'judgeUiFloor: no reviewer produced deterministic measurements (axe/contrast) — ' +
105
+ 'subjective findings are not attestable; add a reviewer with a measured floor',
106
+ )
107
+ }
108
+ let criticalA11y = 0
109
+ let worstContrastFailRate = 0
110
+ let passRateSum = 0
111
+ for (const r of measured) {
112
+ const m = r.measurements as DesignMeasurements
113
+ criticalA11y += m.a11yViolations.filter((v) => v.impact != null && A11Y_CRITICAL.has(v.impact)).length
114
+ const failRate = 1 - m.contrastAaPassRate
115
+ if (failRate > worstContrastFailRate) worstContrastFailRate = failRate
116
+ passRateSum += m.contrastAaPassRate
117
+ }
118
+ const blocking = criticalA11y >= maxCriticalA11y || worstContrastFailRate > maxContrastFailRate
119
+ const avgContrastPass = passRateSum / measured.length
120
+ // a11y cleanliness: 1 when no critical issues, decaying toward 0 as they approach the cap.
121
+ const a11yHealth = Math.max(0, 1 - criticalA11y / maxCriticalA11y)
122
+ const score = Number((0.6 * avgContrastPass + 0.4 * a11yHealth).toFixed(4))
123
+ return {
124
+ resolved: !blocking,
125
+ score,
126
+ detail: JSON.stringify({
127
+ criticalA11y,
128
+ worstContrastFailRate: Number(worstContrastFailRate.toFixed(4)),
129
+ avgContrastPass: Number(avgContrastPass.toFixed(4)),
130
+ measuredReviewers: measured.map((r) => r.reviewerId),
131
+ blocking,
132
+ }),
133
+ }
134
+ }
135
+
136
+ /** Dedup key for a finding across reviewers: same lens + route + (selector or a
137
+ * normalized title) is one issue, regardless of how many reviewers flagged it. */
138
+ function findingKey(f: UiFinding): string {
139
+ const anchor = f.selector?.trim() || f.title.toLowerCase().replace(/\s+/g, ' ').trim()
140
+ return `${f.lens.toLowerCase()}|${f.route}|${anchor}`
141
+ }
142
+
143
+ export interface UiPanelFinding extends UiFinding {
144
+ /** All reviewers that independently flagged this issue (consensus signal). */
145
+ flaggedBy: string[]
146
+ }
147
+
148
+ export interface UiPanelResult {
149
+ perReviewer: Record<string, UiReviewRun[]>
150
+ /** Deduped union of findings across reviewers, each tagged with who flagged it. */
151
+ findings: UiPanelFinding[]
152
+ /** The attestable deterministic-floor verdict (NOT a vote over findings). */
153
+ verdict: { resolved: boolean; score: number; detail: string }
154
+ }
155
+
156
+ /**
157
+ * Run a PANEL of reviewers over one target, union+dedup their subjective findings
158
+ * (attributed, with a consensus count), and attest the deterministic floor. A
159
+ * reviewer that throws is recorded as empty (its absence is visible in
160
+ * perReviewer), never silently merged as a pass.
161
+ */
162
+ export async function runUiReviewerPanel(
163
+ target: UiReviewTarget,
164
+ reviewers: ReadonlyArray<UiReviewerAdapter>,
165
+ cfg: UiFloorGateConfig = {},
166
+ ): Promise<UiPanelResult> {
167
+ if (reviewers.length === 0) throw new Error('runUiReviewerPanel: no reviewers given')
168
+ const settled = await Promise.all(
169
+ reviewers.map(async (rev) => {
170
+ try {
171
+ return { id: rev.id, runs: await rev.review(target) }
172
+ } catch (err) {
173
+ return { id: rev.id, runs: [] as UiReviewRun[], error: err instanceof Error ? err.message : String(err) }
174
+ }
175
+ }),
176
+ )
177
+ const perReviewer: Record<string, UiReviewRun[]> = {}
178
+ const allRuns: UiReviewRun[] = []
179
+ for (const s of settled) {
180
+ perReviewer[s.id] = s.runs
181
+ allRuns.push(...s.runs)
182
+ }
183
+ const byKey = new Map<string, UiPanelFinding>()
184
+ for (const run of allRuns) {
185
+ for (const f of run.findings) {
186
+ const key = findingKey(f)
187
+ const existing = byKey.get(key)
188
+ if (existing) {
189
+ if (!existing.flaggedBy.includes(f.reviewerId)) existing.flaggedBy.push(f.reviewerId)
190
+ } else {
191
+ byKey.set(key, { ...f, flaggedBy: [f.reviewerId] })
192
+ }
193
+ }
194
+ }
195
+ return {
196
+ perReviewer,
197
+ findings: [...byKey.values()],
198
+ verdict: judgeUiFloor(allRuns, cfg),
199
+ }
200
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Deterministic self-check for the UI-reviewer panel — `tsx src/browser/ui-reviewer.verify.ts`.
3
+ *
4
+ * Defends the same property as the browser harness: the verdict is ATTESTABLE —
5
+ * it comes from the measured deterministic floor (axe + contrast), never a
6
+ * reviewer's subjective findings or self-reported headline score.
7
+ */
8
+
9
+ import {
10
+ judgeUiFloor,
11
+ runUiReviewerPanel,
12
+ type UiReviewRun,
13
+ type UiReviewerAdapter,
14
+ type UiReviewTarget,
15
+ } from './ui-reviewer'
16
+
17
+ let failures = 0
18
+ function check(name: string, cond: boolean, detail = '') {
19
+ if (cond) console.log(` ✓ ${name}`)
20
+ else {
21
+ failures += 1
22
+ console.log(` ✗ ${name} ${detail}`)
23
+ }
24
+ }
25
+
26
+ const clean: UiReviewRun = {
27
+ reviewerId: 'bad',
28
+ route: 'home',
29
+ findings: [],
30
+ measurements: { a11yViolations: [], contrastAaPassRate: 0.99, contrastAaFailures: 1, contrastTotalChecked: 120 },
31
+ selfReportedScore: 92,
32
+ }
33
+ // A reviewer SCREAMING success (healthScore 100) while the measured floor is blocking.
34
+ const liar: UiReviewRun = {
35
+ reviewerId: 'bad',
36
+ route: 'home',
37
+ findings: [],
38
+ selfReportedScore: 100,
39
+ measurements: {
40
+ a11yViolations: [
41
+ { id: 'color-contrast', impact: 'serious' },
42
+ { id: 'aria-required', impact: 'critical' },
43
+ { id: 'label', impact: 'critical' },
44
+ { id: 'button-name', impact: 'critical' },
45
+ { id: 'link-name', impact: 'serious' },
46
+ ],
47
+ contrastAaPassRate: 0.5,
48
+ contrastAaFailures: 60,
49
+ contrastTotalChecked: 120,
50
+ },
51
+ }
52
+
53
+ console.log('ui-reviewer panel verify:')
54
+
55
+ const okV = judgeUiFloor([clean])
56
+ check('clean floor → resolved=true, high score', okV.resolved === true && okV.score > 0.9, okV.detail)
57
+
58
+ const liarV = judgeUiFloor([liar])
59
+ check('self-reported 100 IGNORED — measured floor blocks → resolved=false', liarV.resolved === false, liarV.detail)
60
+ check('blocking score is low', liarV.score < 0.6, liarV.detail)
61
+
62
+ // fail-loud: a purely-subjective panel (no measurements) cannot attest.
63
+ let threw = false
64
+ try {
65
+ judgeUiFloor([{ reviewerId: 'ui-auditor', route: 'home', findings: [], selfReportedScore: 80 }])
66
+ } catch {
67
+ threw = true
68
+ }
69
+ check('no measured floor → throws (subjective findings are not attestable)', threw)
70
+
71
+ // panel dedup + consensus across two reviewers flagging the same issue.
72
+ const finding = (reviewerId: string) => ({
73
+ reviewerId,
74
+ lens: 'contrast',
75
+ severity: 'major' as const,
76
+ route: 'home',
77
+ title: 'Low contrast on primary CTA',
78
+ observation: 'x',
79
+ selector: 'button.cta',
80
+ })
81
+ const revA: UiReviewerAdapter = { id: 'bad', async review() { return [{ ...clean, reviewerId: 'bad', findings: [finding('bad')] }] } }
82
+ const revB: UiReviewerAdapter = { id: 'ui-auditor', async review() { return [{ reviewerId: 'ui-auditor', route: 'home', findings: [finding('ui-auditor')] }] } }
83
+ const target: UiReviewTarget = { url: 'https://example.com' }
84
+ const panel = await runUiReviewerPanel(target, [revA, revB])
85
+ check('panel dedups the same issue across reviewers to ONE finding', panel.findings.length === 1, `got ${panel.findings.length}`)
86
+ check('deduped finding records BOTH reviewers (consensus)', (panel.findings[0]?.flaggedBy ?? []).length === 2, JSON.stringify(panel.findings[0]?.flaggedBy))
87
+ check('panel verdict comes from the measured reviewer (bad), resolved=true', panel.verdict.resolved === true, panel.verdict.detail)
88
+ check('perReviewer records both reviewers', Object.keys(panel.perReviewer).length === 2)
89
+
90
+ if (failures > 0) {
91
+ console.log(`\n❌ ${failures} check(s) failed`)
92
+ process.exit(1)
93
+ }
94
+ console.log('\n✅ UI-reviewer panel verified — verdict is the measured floor, self-report ignored, findings deduped across reviewers')