agent-dealer 1.3.0 → 1.3.2

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 (44) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  2. package/bundle/server/dist/adapters/github.js +50 -0
  3. package/bundle/server/dist/adapters/github.test.js +89 -1
  4. package/bundle/server/dist/coordinator/admission.test.js +115 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  6. package/bundle/server/dist/coordinator/commands.js +89 -9
  7. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  8. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  9. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  10. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  11. package/bundle/server/dist/coordinator/prompts.js +57 -2
  12. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1083 -0
  14. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -0
  15. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  16. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  18. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  19. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  20. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  21. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  22. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  23. package/bundle/server/dist/coordinator/worker-loop.js +1 -1
  24. package/bundle/server/dist/paths.js +24 -0
  25. package/bundle/server/dist/repository/admission-settings.js +5 -5
  26. package/bundle/server/dist/repository/admission-settings.test.js +71 -15
  27. package/bundle/server/dist/repository/issues.js +8 -1
  28. package/bundle/server/dist/repository/issues.test.js +34 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  31. package/bundle/server/dist/routes/issues.js +23 -8
  32. package/bundle/server/dist/routes/issues.test.js +80 -0
  33. package/bundle/server/dist/routes/queue.js +1 -1
  34. package/bundle/server/dist/routes/queue.test.js +55 -20
  35. package/bundle/server/dist/runners/process-registry.js +1 -1
  36. package/bundle/server/package.json +2 -2
  37. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  38. package/bundle/server/static-ui/index.html +1 -1
  39. package/bundle/shared/dist/queue-entries.d.ts +5 -4
  40. package/bundle/shared/dist/queue-entries.js +5 -4
  41. package/bundle/shared/package.json +1 -1
  42. package/package.json +1 -1
  43. package/templates/prod.env.example +5 -2
  44. package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
@@ -0,0 +1,630 @@
1
+ // packages/server/src/coordinator/visual-qa.ts
2
+ //
3
+ // NOT-381: runtime-aware developer visual QA with SHA-bound receipts.
4
+ //
5
+ // NOT-303 proved `Google Chrome.app` headless aborts inside the Muse sandbox and
6
+ // shipped a Muse-specific preflight (`adapters/muse-visual-qa.ts`) whose explicit
7
+ // non-goal was changing how Claude Code, Codex Local, or Cursor run Chrome. A
8
+ // Claude Code takeover then demonstrated the preferred capable-runtime path: run the
9
+ // branch's real backend and frontend without mocks, drive headless Chrome, and
10
+ // inspect the actual UI at desktop and mobile viewports. This module makes that the
11
+ // normal Dealer developer behavior when the runtime supports it while preserving
12
+ // the Muse fallback untouched:
13
+ //
14
+ // - `developerVisualQaPolicy` resolves the runtime to the Muse preflight or the
15
+ // capable-runtime section (`capableDeveloperVisualQaPromptSection`). Only
16
+ // `muse_code` takes the Muse path; every other runtime attempts one bounded
17
+ // in-session probe and reports a structured receipt. Unknown runtimes default
18
+ // to capable: the probe degrades gracefully to `unavailable`, so a wrong
19
+ // default costs one probe, never a missed verification.
20
+ // - `parseVisualQaReceipt` reads the structured `Visual QA:` block from the
21
+ // implementation conclusion (status, HEAD SHA, real-app/mocks declaration,
22
+ // scenario, viewports, commands, screenshots; `capability` for unavailable).
23
+ // - `collectDeveloperVisualQa` validates a `verified` receipt against the exact
24
+ // developer handoff SHA, copies accepted screenshots into Dealer-owned storage,
25
+ // removes the transient worktree directory, and persists the receipt plus blob
26
+ // references. Rejections persist a loud `visual_qa_rejected` record and never
27
+ // block the handoff: visual QA is evidence for review, not a gate (gates stay
28
+ // CI/operator), so a bad receipt must not burn another implementation round.
29
+ // - `readLatestVisualQa` + `visualQaReviewerSection` carry the validated receipt
30
+ // into the reviewer input, SHA-checked against the pinned head. An unavailable
31
+ // receipt is never presented as a verification.
32
+ //
33
+ // Out of scope (see ticket non-goals): reviewer-side browsers, CI/operator
34
+ // verification changes, pixel diffs, cross-browser testing, committing screenshots
35
+ // to the product repository.
36
+ import { createHash } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
40
+ import { getVisualQaDir } from "../paths.js";
41
+ /** Worktree-relative directory carrying the worker's screenshots (transient). */
42
+ export const VISUAL_QA_DIR_NAME = ".agent-dealer-visual-qa";
43
+ /** Root-anchored git info/exclude pattern: screenshots never dirty the tree, never
44
+ * get salvaged into a commit, and never reach the product repository. */
45
+ export const VISUAL_QA_EXCLUDE_LINE = "/.agent-dealer-visual-qa/";
46
+ /** Persisted kind for an accepted visual-QA receipt (any status). */
47
+ export const VISUAL_QA_RECEIPT_KIND = "visual_qa_receipt";
48
+ /** Persisted kind for a loudly rejected receipt (fail-loud evidence, not a gate). */
49
+ export const VISUAL_QA_REJECTED_KIND = "visual_qa_rejected";
50
+ /** Default viewports when the ticket states none: desktop + mobile. */
51
+ export const DEFAULT_VISUAL_QA_VIEWPORTS = ["1440x900", "390x800"];
52
+ /** Per-screenshot storage cap — a bounded guard, not a quality bar. */
53
+ export const MAX_VISUAL_QA_FILE_BYTES = 25 * 1024 * 1024;
54
+ /** Screenshot extensions the collector accepts (case-insensitive). */
55
+ const SCREENSHOT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
56
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
57
+ const VIEWPORT_RE = /^(\d+)x(\d+)$/;
58
+ export function developerVisualQaPolicy(runtime) {
59
+ return runtime === "muse_code" ? "muse" : "capable";
60
+ }
61
+ /**
62
+ * Worker-prompt section for browser-capable runtimes (Claude Code, Codex Local,
63
+ * Cursor Local). One bounded probe, then the real application — never the
64
+ * blanket no-browser assumption, and never an install/retry/sandbox-weakening
65
+ * loop: the sandbox has no network and a failed capability is reported once.
66
+ */
67
+ export function capableDeveloperVisualQaPromptSection() {
68
+ return [
69
+ `## Visual QA (in-session browser verification)`,
70
+ `This runtime can usually launch a headless browser and serve the app on loopback — verify UI-visible work in-session instead of assuming visual QA is unavailable.`,
71
+ `- First run ONE bounded capability probe: launch headless Chromium once (for example \`chromium --headless --screenshot=/tmp/dealer-visual-probe.png --window-size=800,600 about:blank\`, or the runtime's installed equivalent) and confirm the app can listen on loopback. If either fails, stop: do not install browsers or packages (no network), do not retry the failed capability, and do not weaken any sandbox flag to force a launch — report \`unavailable\` with the concrete failed capability instead.`,
72
+ `- When capable, run the REAL backend and frontend from this worktree at the current HEAD and exercise the affected interaction, not just a static route. Use mocks only when an acceptance criterion explicitly permits them, and say so in the receipt.`,
73
+ `- Capture the ticket's stated viewports, or desktop \`1440x900\` and mobile \`390x800\` by default. Save PNGs under \`.agent-dealer-visual-qa/\` (for example \`.agent-dealer-visual-qa/desktop-1440x900.png\`). The coordinator collects and deletes this directory — never commit it.`,
74
+ `- A new commit invalidates earlier rendered evidence: capture at the final HEAD and name it in the receipt (\`git rev-parse HEAD\`).`,
75
+ `End your implementation conclusion with a structured \`Visual QA:\` receipt block: \`Visual QA: verified\` plus \`head:\`, \`app:\`, \`scenario:\`, \`viewports:\`, \`commands:\`, \`screenshots:\` lines (screenshot paths relative to the worktree root, inside \`.agent-dealer-visual-qa/\`); or \`Visual QA: unavailable\` plus \`capability:\` naming the one concrete failed capability (browser launch, loopback listen, or another concrete failure); or \`Visual QA: not_required\` when the change has no UI-visible surface.`,
76
+ ``,
77
+ ];
78
+ }
79
+ const STATUS_LINE_RE = /^\s*visual qa\s*:\s*(.+?)\s*$/i;
80
+ const FIELD_LINE_RE = /^\s*(head|sha|app|scenario|path|viewports?|commands?|screenshots?|capability|note|reason)\s*:\s*(.*?)\s*$/i;
81
+ const BLOCK_END_RE = /^\s*(#{1,6}\s|```)/;
82
+ function normalizeStatus(token) {
83
+ const t = token.toLowerCase().replace(/[\s_-]+/g, " ").trim();
84
+ if (t === "verified")
85
+ return "verified";
86
+ if (t === "unavailable")
87
+ return "unavailable";
88
+ if (t === "not required")
89
+ return "not_required";
90
+ // Legacy Muse line (`Visual QA: not run (<reason>)`) — the incapable-runtime
91
+ // report, normalized to unavailable (never to a pass).
92
+ if (t === "not run")
93
+ return "unavailable";
94
+ return null;
95
+ }
96
+ function splitList(value) {
97
+ return value
98
+ .split(/[,;\n]+/)
99
+ .map((p) => p.trim().replace(/^`+|`+$/g, "").replace(/^"+|"+$/g, "").trim())
100
+ .filter((p) => p.length > 0);
101
+ }
102
+ /**
103
+ * Parse the LAST `Visual QA:` block in the conclusion (a worker that corrects
104
+ * itself wins). Fields are `key: value` lines after the status line until a
105
+ * markdown heading/fence or the end; unknown lines are ignored, last key wins.
106
+ * No `Visual QA:` line → `{ found: false }` (status quo, not an error).
107
+ */
108
+ export function parseVisualQaReceipt(conclusion) {
109
+ const lines = conclusion.split(/\r?\n/);
110
+ let statusIndex = -1;
111
+ let statusText = "";
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const m = lines[i].match(STATUS_LINE_RE);
114
+ if (m) {
115
+ statusIndex = i;
116
+ statusText = m[1].trim();
117
+ }
118
+ }
119
+ if (statusIndex < 0)
120
+ return { found: false };
121
+ // `Visual QA: not run (<reason>)` / `unavailable (<reason>)` carry the failed
122
+ // capability inline; legacy `verified (<png>, exit <code>)` carries no fields.
123
+ let token = statusText;
124
+ let inline = null;
125
+ const paren = statusText.match(/^([^()]+?)\s*\((.+)\)\s*$/);
126
+ if (paren) {
127
+ token = paren[1].trim();
128
+ inline = paren[2].trim() || null;
129
+ }
130
+ const status = normalizeStatus(token);
131
+ if (!status) {
132
+ return {
133
+ found: true,
134
+ receipt: null,
135
+ error: `unknown Visual QA status "${token}" (expected verified, unavailable, or not_required)`,
136
+ };
137
+ }
138
+ const fields = new Map();
139
+ for (let i = statusIndex + 1; i < lines.length; i++) {
140
+ const line = lines[i];
141
+ if (BLOCK_END_RE.test(line))
142
+ break;
143
+ const m = line.match(FIELD_LINE_RE);
144
+ if (m)
145
+ fields.set(m[1].toLowerCase(), m[2].trim());
146
+ }
147
+ const get = (...names) => {
148
+ for (const n of names) {
149
+ const v = fields.get(n);
150
+ if (v !== undefined && v.length > 0)
151
+ return v;
152
+ }
153
+ return null;
154
+ };
155
+ const headRaw = get("head", "sha");
156
+ const headSha = headRaw && FULL_SHA_RE.test(headRaw.toLowerCase()) ? headRaw.toLowerCase() : null;
157
+ const viewportsRaw = get("viewports", "viewport");
158
+ const viewports = viewportsRaw
159
+ ? splitList(viewportsRaw)
160
+ .map((v) => v.toLowerCase())
161
+ .filter((v) => VIEWPORT_RE.test(v))
162
+ : [];
163
+ const screenshotsRaw = get("screenshots", "screenshot");
164
+ const capabilityField = get("capability");
165
+ // The inline parenthetical is the failed capability for unavailable/not-run;
166
+ // otherwise it is a free note (e.g. legacy `verified (<png>, exit 0)`).
167
+ const capability = status === "unavailable" ? (capabilityField ?? inline) : capabilityField;
168
+ const note = get("note", "reason") ?? (status === "unavailable" ? null : inline);
169
+ return {
170
+ found: true,
171
+ receipt: {
172
+ status,
173
+ headSha,
174
+ headRaw,
175
+ realApp: get("app"),
176
+ scenario: get("scenario", "path"),
177
+ viewports,
178
+ viewportsRaw,
179
+ commands: get("commands", "command"),
180
+ screenshots: screenshotsRaw ? splitList(screenshotsRaw) : [],
181
+ capability,
182
+ note,
183
+ },
184
+ error: null,
185
+ };
186
+ }
187
+ const invalid = (reason) => ({ ok: false, reason });
188
+ /**
189
+ * Validate a parsed receipt. A `verified` receipt must name the exact handoff
190
+ * SHA and every screenshot must be a regular file inside the dedicated
191
+ * worktree directory — symlinks, path escapes, and missing files fail loudly.
192
+ * `unavailable` must name one concrete failed capability; `not_required`
193
+ * requires nothing and never collects screenshots.
194
+ */
195
+ export function validateVisualQaReceipt(receipt, opts) {
196
+ if (receipt.status === "not_required")
197
+ return { ok: true };
198
+ if (receipt.status === "unavailable") {
199
+ if (!receipt.capability) {
200
+ return invalid("unavailable Visual QA receipt names no failed capability (capability: browser launch, loopback listen, or another concrete failure)");
201
+ }
202
+ return { ok: true };
203
+ }
204
+ // Verified: every field is required, and the SHA must bind exactly.
205
+ if (!receipt.headRaw)
206
+ return invalid("verified Visual QA receipt names no HEAD SHA (head: <40-hex>)");
207
+ if (!receipt.headSha) {
208
+ return invalid(`verified Visual QA receipt names an invalid HEAD SHA ("${receipt.headRaw}")`);
209
+ }
210
+ const expected = opts.expectedHeadSha.trim().toLowerCase();
211
+ if (receipt.headSha !== expected) {
212
+ return invalid(`verified Visual QA receipt is bound to ${receipt.headSha.slice(0, 8)} but the developer handoff is ${expected.slice(0, 8)}`);
213
+ }
214
+ if (!receipt.realApp) {
215
+ return invalid("verified Visual QA receipt declares no real-app/mocks statement (app: real ...)");
216
+ }
217
+ if (!receipt.scenario) {
218
+ return invalid("verified Visual QA receipt names no scenario/path (scenario: <route + interaction>)");
219
+ }
220
+ if (receipt.viewports.length === 0) {
221
+ const raw = receipt.viewportsRaw ? ` ("${receipt.viewportsRaw}")` : "";
222
+ return invalid(`verified Visual QA receipt names no viewport sizes${raw} (viewports: 1440x900, 390x800)`);
223
+ }
224
+ if (!receipt.commands) {
225
+ return invalid("verified Visual QA receipt names no commands summary (commands: <app + capture commands>)");
226
+ }
227
+ if (receipt.screenshots.length === 0) {
228
+ return invalid(`verified Visual QA receipt names no screenshot files (screenshots: ${VISUAL_QA_DIR_NAME}/<name>.png, ...)`);
229
+ }
230
+ const worktreeResolved = path.resolve(opts.worktreePath);
231
+ const visualDir = path.join(worktreeResolved, VISUAL_QA_DIR_NAME);
232
+ // Realpath containment: lexical checks alone cannot see a symlinked visual
233
+ // dir or subdirectory pointing outside the worktree (`lstat` follows
234
+ // intermediate symlinks), so bind every path to its real location.
235
+ let worktreeReal;
236
+ try {
237
+ worktreeReal = fs.realpathSync(worktreeResolved);
238
+ }
239
+ catch {
240
+ worktreeReal = worktreeResolved;
241
+ }
242
+ // The artifact dir itself must be a real directory inside the worktree —
243
+ // never a symlink, even one pointing back inside.
244
+ let visualDirReal = null;
245
+ try {
246
+ const dirStat = fs.lstatSync(visualDir);
247
+ if (dirStat.isSymbolicLink()) {
248
+ return invalid("verified Visual QA receipt visual-artifact directory is a symlink, refusing");
249
+ }
250
+ if (dirStat.isDirectory()) {
251
+ try {
252
+ visualDirReal = fs.realpathSync(visualDir);
253
+ }
254
+ catch {
255
+ return invalid("verified Visual QA receipt visual-artifact directory is unreadable, refusing");
256
+ }
257
+ if (visualDirReal !== worktreeReal && !visualDirReal.startsWith(worktreeReal + path.sep)) {
258
+ return invalid("verified Visual QA receipt visual-artifact directory escapes the worktree, refusing");
259
+ }
260
+ }
261
+ }
262
+ catch {
263
+ // Missing dir: the per-file checks below report the missing screenshots.
264
+ visualDirReal = null;
265
+ }
266
+ for (const declared of receipt.screenshots) {
267
+ if (!declared || declared.trim().length === 0) {
268
+ return invalid("verified Visual QA receipt names an empty screenshot path");
269
+ }
270
+ if (path.isAbsolute(declared)) {
271
+ return invalid(`verified Visual QA receipt names an absolute screenshot path, refusing: "${declared}"`);
272
+ }
273
+ const resolved = path.resolve(worktreeResolved, declared);
274
+ if (resolved !== visualDir && !resolved.startsWith(visualDir + path.sep)) {
275
+ return invalid(`verified Visual QA receipt screenshot escapes the visual-artifact directory, refusing: "${declared}"`);
276
+ }
277
+ // Reject a symlinked intermediate component (symlinked subdirectory),
278
+ // not just a symlinked final file.
279
+ const relParts = path.relative(worktreeResolved, resolved).split(path.sep);
280
+ let prefix = worktreeResolved;
281
+ for (let i = 0; i < relParts.length - 1; i++) {
282
+ prefix = path.join(prefix, relParts[i]);
283
+ let partStat;
284
+ try {
285
+ partStat = fs.lstatSync(prefix);
286
+ }
287
+ catch {
288
+ break; // Missing component: the file check below reports it.
289
+ }
290
+ if (partStat.isSymbolicLink()) {
291
+ return invalid(`verified Visual QA receipt screenshot traverses a symlink, refusing: "${declared}"`);
292
+ }
293
+ }
294
+ let st;
295
+ try {
296
+ st = fs.lstatSync(resolved);
297
+ }
298
+ catch {
299
+ return invalid(`verified Visual QA receipt screenshot is missing: "${declared}"`);
300
+ }
301
+ if (st.isSymbolicLink()) {
302
+ return invalid(`verified Visual QA receipt screenshot is a symlink, refusing: "${declared}"`);
303
+ }
304
+ if (!st.isFile()) {
305
+ return invalid(`verified Visual QA receipt screenshot is not a regular file: "${declared}"`);
306
+ }
307
+ // Bind the file to its real location: it must sit inside the real
308
+ // artifact dir, which itself sits inside the real worktree.
309
+ let fileReal;
310
+ try {
311
+ fileReal = fs.realpathSync(resolved);
312
+ }
313
+ catch {
314
+ return invalid(`verified Visual QA receipt screenshot is missing: "${declared}"`);
315
+ }
316
+ if (!visualDirReal) {
317
+ try {
318
+ visualDirReal = fs.realpathSync(visualDir);
319
+ }
320
+ catch {
321
+ return invalid(`verified Visual QA receipt screenshot is missing: "${declared}"`);
322
+ }
323
+ if (visualDirReal !== worktreeReal && !visualDirReal.startsWith(worktreeReal + path.sep)) {
324
+ return invalid("verified Visual QA receipt visual-artifact directory escapes the worktree, refusing");
325
+ }
326
+ }
327
+ if (fileReal !== visualDirReal && !fileReal.startsWith(visualDirReal + path.sep)) {
328
+ return invalid(`verified Visual QA receipt screenshot escapes the visual-artifact directory, refusing: "${declared}"`);
329
+ }
330
+ if (st.size === 0) {
331
+ return invalid(`verified Visual QA receipt screenshot is empty: "${declared}"`);
332
+ }
333
+ if (st.size > MAX_VISUAL_QA_FILE_BYTES) {
334
+ return invalid(`verified Visual QA receipt screenshot exceeds the ${MAX_VISUAL_QA_FILE_BYTES}-byte cap: "${declared}"`);
335
+ }
336
+ if (!SCREENSHOT_EXTENSIONS.has(path.extname(resolved).toLowerCase())) {
337
+ return invalid(`verified Visual QA receipt screenshot has an unsupported extension (expected .png): "${declared}"`);
338
+ }
339
+ }
340
+ return { ok: true };
341
+ }
342
+ function safeBlobBase(index, declared) {
343
+ const base = path.posix.basename(declared.replace(/\\/g, "/")) || `screenshot-${index}.png`;
344
+ const cleaned = base.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || `screenshot-${index}.png`;
345
+ return `${index}-${cleaned}`;
346
+ }
347
+ /**
348
+ * Parse, validate, and persist the developer's visual-QA receipt. Accepted
349
+ * screenshots are copied into Dealer-owned issue storage and the transient
350
+ * worktree directory is removed; rejections are persisted loudly and the
351
+ * handoff proceeds — a bad receipt is failed evidence, never a new round.
352
+ */
353
+ export function collectDeveloperVisualQa(opts) {
354
+ const now = opts.now ?? (() => new Date().toISOString());
355
+ const parsed = parseVisualQaReceipt(opts.conclusion);
356
+ if (!parsed.found)
357
+ return { kind: "none" };
358
+ if (!parsed.receipt) {
359
+ const art = createIssueArtifact({
360
+ issueId: opts.issueId,
361
+ workerSessionId: opts.sessionId,
362
+ kind: VISUAL_QA_REJECTED_KIND,
363
+ author: "system",
364
+ content: {
365
+ status: null,
366
+ headSha: null,
367
+ expectedHeadSha: opts.expectedHeadSha,
368
+ reason: parsed.error,
369
+ recordedAt: now(),
370
+ },
371
+ });
372
+ return { kind: "rejected", artifactId: art.id, reason: parsed.error };
373
+ }
374
+ const receipt = parsed.receipt;
375
+ if (receipt.status !== "verified") {
376
+ const validation = validateVisualQaReceipt(receipt, opts);
377
+ if (!validation.ok) {
378
+ const art = createIssueArtifact({
379
+ issueId: opts.issueId,
380
+ workerSessionId: opts.sessionId,
381
+ kind: VISUAL_QA_REJECTED_KIND,
382
+ author: "system",
383
+ content: {
384
+ status: receipt.status,
385
+ headSha: receipt.headSha,
386
+ expectedHeadSha: opts.expectedHeadSha,
387
+ reason: validation.reason,
388
+ recordedAt: now(),
389
+ },
390
+ });
391
+ return { kind: "rejected", artifactId: art.id, reason: validation.reason };
392
+ }
393
+ // not_required never collects screenshots, even when the worker names some.
394
+ const art = createIssueArtifact({
395
+ issueId: opts.issueId,
396
+ workerSessionId: opts.sessionId,
397
+ kind: VISUAL_QA_RECEIPT_KIND,
398
+ author: "agent",
399
+ content: {
400
+ status: receipt.status,
401
+ headSha: receipt.headSha,
402
+ realApp: receipt.realApp,
403
+ scenario: receipt.scenario,
404
+ viewports: receipt.viewports,
405
+ commands: receipt.commands,
406
+ screenshots: [],
407
+ capability: receipt.capability,
408
+ note: receipt.note,
409
+ recordedAt: now(),
410
+ },
411
+ });
412
+ return { kind: "receipt", artifactId: art.id, status: receipt.status };
413
+ }
414
+ const validation = validateVisualQaReceipt(receipt, opts);
415
+ if (!validation.ok) {
416
+ // Rejected files stay in the worktree for inspection — the directory is
417
+ // git-excluded, so they can neither dirty the tree nor leak into a commit.
418
+ const art = createIssueArtifact({
419
+ issueId: opts.issueId,
420
+ workerSessionId: opts.sessionId,
421
+ kind: VISUAL_QA_REJECTED_KIND,
422
+ author: "system",
423
+ content: {
424
+ status: receipt.status,
425
+ headSha: receipt.headSha,
426
+ expectedHeadSha: opts.expectedHeadSha,
427
+ reason: validation.reason,
428
+ recordedAt: now(),
429
+ },
430
+ });
431
+ return { kind: "rejected", artifactId: art.id, reason: validation.reason };
432
+ }
433
+ // Accepted: copy into Dealer-owned storage, then remove the transient dir.
434
+ const issueDir = path.join(getVisualQaDir(), opts.issueId);
435
+ fs.mkdirSync(issueDir, { recursive: true });
436
+ const refs = [];
437
+ const copied = [];
438
+ try {
439
+ receipt.screenshots.forEach((declared, i) => {
440
+ const src = path.resolve(opts.worktreePath, declared);
441
+ const fileName = safeBlobBase(i, declared);
442
+ const dest = path.join(issueDir, `${opts.sessionId}-${fileName}`);
443
+ fs.copyFileSync(src, dest);
444
+ copied.push(dest);
445
+ const bytes = fs.readFileSync(dest);
446
+ refs.push({
447
+ fileName,
448
+ blobPath: dest,
449
+ sizeBytes: bytes.byteLength,
450
+ sha256: createHash("sha256").update(bytes).digest("hex"),
451
+ });
452
+ });
453
+ }
454
+ catch (err) {
455
+ for (const p of copied)
456
+ fs.rmSync(p, { force: true });
457
+ const reason = `could not store Visual QA screenshots: ${err instanceof Error ? err.message : String(err)}`;
458
+ const art = createIssueArtifact({
459
+ issueId: opts.issueId,
460
+ workerSessionId: opts.sessionId,
461
+ kind: VISUAL_QA_REJECTED_KIND,
462
+ author: "system",
463
+ content: {
464
+ status: receipt.status,
465
+ headSha: receipt.headSha,
466
+ expectedHeadSha: opts.expectedHeadSha,
467
+ reason,
468
+ recordedAt: now(),
469
+ },
470
+ });
471
+ return { kind: "rejected", artifactId: art.id, reason };
472
+ }
473
+ fs.rmSync(path.resolve(opts.worktreePath, VISUAL_QA_DIR_NAME), { recursive: true, force: true });
474
+ const art = createIssueArtifact({
475
+ issueId: opts.issueId,
476
+ workerSessionId: opts.sessionId,
477
+ kind: VISUAL_QA_RECEIPT_KIND,
478
+ author: "agent",
479
+ content: {
480
+ status: receipt.status,
481
+ headSha: receipt.headSha,
482
+ realApp: receipt.realApp,
483
+ scenario: receipt.scenario,
484
+ viewports: receipt.viewports,
485
+ commands: receipt.commands,
486
+ screenshots: refs,
487
+ capability: receipt.capability,
488
+ note: receipt.note,
489
+ recordedAt: now(),
490
+ },
491
+ });
492
+ return { kind: "receipt", artifactId: art.id, status: receipt.status };
493
+ }
494
+ function parseReceiptContent(raw) {
495
+ if (!raw)
496
+ return null;
497
+ try {
498
+ const c = JSON.parse(raw);
499
+ if (c.status !== "verified" && c.status !== "unavailable" && c.status !== "not_required")
500
+ return null;
501
+ if (!Array.isArray(c.screenshots))
502
+ return null;
503
+ return {
504
+ status: c.status,
505
+ headSha: typeof c.headSha === "string" ? c.headSha : null,
506
+ realApp: typeof c.realApp === "string" ? c.realApp : null,
507
+ scenario: typeof c.scenario === "string" ? c.scenario : null,
508
+ viewports: Array.isArray(c.viewports) ? c.viewports.filter((v) => typeof v === "string") : [],
509
+ commands: typeof c.commands === "string" ? c.commands : null,
510
+ screenshots: c.screenshots.filter((s) => !!s && typeof s === "object" && typeof s.blobPath === "string"),
511
+ capability: typeof c.capability === "string" ? c.capability : null,
512
+ note: typeof c.note === "string" ? c.note : null,
513
+ recordedAt: typeof c.recordedAt === "string" ? c.recordedAt : "",
514
+ };
515
+ }
516
+ catch {
517
+ return null;
518
+ }
519
+ }
520
+ function parseRejectedContent(raw) {
521
+ if (!raw)
522
+ return null;
523
+ try {
524
+ const c = JSON.parse(raw);
525
+ if (typeof c.reason !== "string" || !c.reason)
526
+ return null;
527
+ if (typeof c.expectedHeadSha !== "string" || !c.expectedHeadSha)
528
+ return null;
529
+ return {
530
+ status: c.status === "verified" || c.status === "unavailable" || c.status === "not_required" ? c.status : null,
531
+ headSha: typeof c.headSha === "string" ? c.headSha : null,
532
+ expectedHeadSha: c.expectedHeadSha,
533
+ reason: c.reason,
534
+ recordedAt: typeof c.recordedAt === "string" ? c.recordedAt : "",
535
+ };
536
+ }
537
+ catch {
538
+ return null;
539
+ }
540
+ }
541
+ /**
542
+ * The newest visual-QA record of either kind for the issue (a repair round's
543
+ * receipt supersedes an earlier round's; a same-millisecond tie prefers the
544
+ * rejection, the louder record). Null when no attempt reported visual QA.
545
+ */
546
+ export function readLatestVisualQa(issueId) {
547
+ const receiptArt = latestIssueArtifact(issueId, VISUAL_QA_RECEIPT_KIND);
548
+ const rejectedArt = latestIssueArtifact(issueId, VISUAL_QA_REJECTED_KIND);
549
+ const receipt = parseReceiptContent(receiptArt?.contentJson ?? null);
550
+ const rejected = parseRejectedContent(rejectedArt?.contentJson ?? null);
551
+ if (receipt && receiptArt && rejected && rejectedArt) {
552
+ return rejectedArt.createdAt >= receiptArt.createdAt
553
+ ? { kind: "rejected", content: rejected, createdAt: rejectedArt.createdAt }
554
+ : { kind: "receipt", content: receipt, createdAt: receiptArt.createdAt };
555
+ }
556
+ if (rejected && rejectedArt)
557
+ return { kind: "rejected", content: rejected, createdAt: rejectedArt.createdAt };
558
+ if (receipt && receiptArt)
559
+ return { kind: "receipt", content: receipt, createdAt: receiptArt.createdAt };
560
+ return null;
561
+ }
562
+ /**
563
+ * Reviewer-prompt section for the validated visual receipt, SHA-checked against
564
+ * the pinned head. A receipt bound to another head is stale evidence, never
565
+ * verification of this head; an unavailable receipt is a routing state, never
566
+ * a pass. Null renders nothing so receipt-free reviews stay byte-for-byte.
567
+ */
568
+ export function visualQaReviewerSection(record, pinnedHeadSha) {
569
+ if (!record)
570
+ return [];
571
+ const pinned = pinnedHeadSha.trim().toLowerCase();
572
+ if (record.kind === "rejected") {
573
+ const expected = record.content.expectedHeadSha.trim().toLowerCase();
574
+ if (expected !== pinned) {
575
+ return [
576
+ `## Visual QA (superseded rejection — not this head)`,
577
+ `A prior attempt's Visual QA receipt was rejected (${record.content.reason}) for head \`${record.content.expectedHeadSha.slice(0, 8)}\`, not the pinned head \`${pinnedHeadSha.slice(0, 8)}\`. Judge this head's visual evidence on its own.`,
578
+ ``,
579
+ ];
580
+ }
581
+ return [
582
+ `## Visual QA (INVALID receipt — the claimed verification is rejected)`,
583
+ `The developer claimed visual verification for this head, but the coordinator rejected the receipt: ${record.content.reason}. Judge this head as if no visual evidence exists — a rejected receipt is never verification.`,
584
+ ``,
585
+ ];
586
+ }
587
+ const c = record.content;
588
+ const bound = c.headSha?.trim().toLowerCase() || null;
589
+ if (c.status === "verified") {
590
+ if (!bound || bound !== pinned) {
591
+ return [
592
+ `## Visual QA (stale receipt — not this head)`,
593
+ `The developer verified rendered UI at \`${(c.headSha ?? "unknown").slice(0, 8)}\`, not the pinned head \`${pinnedHeadSha.slice(0, 8)}\`. A new commit invalidates earlier rendered evidence — do not treat this as verification of the pinned head.`,
594
+ ``,
595
+ ];
596
+ }
597
+ const lines = [
598
+ `## Visual QA (developer receipt — SHA-bound to this head)`,
599
+ `The developer's coordinator-validated receipt names this exact head. Treat it as evidence for the UI-visible criteria — not as your own verification: you still review the code. The screenshots are archived as Dealer blobs (you cannot view them); judge the binding, the real-app declaration, and whether the scenario covers the UI-visible acceptance criteria.`,
600
+ ``,
601
+ `- status: verified at \`${c.headSha}\``,
602
+ `- app: ${c.realApp ?? "(undeclared)"}`,
603
+ `- scenario: ${c.scenario ?? "(undeclared)"}`,
604
+ `- viewports: ${c.viewports.length ? c.viewports.join(", ") : "(undeclared)"}`,
605
+ `- commands: ${c.commands ?? "(undeclared)"}`,
606
+ ];
607
+ if (c.screenshots.length > 0) {
608
+ lines.push(`- screenshots (${c.screenshots.length} archived):`);
609
+ for (const s of c.screenshots) {
610
+ lines.push(` - ${s.fileName} (${s.sizeBytes.toLocaleString("en-US")} bytes, sha256:${s.sha256.slice(0, 16)}…)`);
611
+ }
612
+ }
613
+ if (c.note)
614
+ lines.push(`- note: ${c.note}`);
615
+ lines.push(``);
616
+ return lines;
617
+ }
618
+ if (c.status === "unavailable") {
619
+ return [
620
+ `## Visual QA (UNAVAILABLE — not verified)`,
621
+ `The developer runtime could not produce rendered evidence in-session. Failed capability: ${c.capability ?? "(undeclared)"}. This is a verification-routing state, never a pass: judge UI-visible criteria from the code and route rendered verification to CI or operator evidence. Missing runtime capability is not itself a defect.`,
622
+ ``,
623
+ ];
624
+ }
625
+ return [
626
+ `## Visual QA (not required — no UI-visible surface claimed)`,
627
+ `The developer reported this change has no UI-visible surface. If you find UI-visible effects, the receipt is wrong: missing visual evidence for UI-visible acceptance criteria is a blocking finding per the screenshot rule in the verdict contract.`,
628
+ ``,
629
+ ];
630
+ }