agent-dealer 1.3.1 → 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 (34) 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/auto-merge.js +46 -9
  5. package/bundle/server/dist/coordinator/commands.js +89 -9
  6. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  7. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  8. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  9. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  10. package/bundle/server/dist/coordinator/prompts.js +57 -2
  11. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  12. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  14. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  15. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  16. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  18. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  19. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  20. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  21. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  22. package/bundle/server/dist/paths.js +24 -0
  23. package/bundle/server/dist/repository/issues.js +8 -1
  24. package/bundle/server/dist/repository/issues.test.js +34 -0
  25. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  26. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  27. package/bundle/server/dist/routes/issues.js +23 -8
  28. package/bundle/server/dist/routes/issues.test.js +80 -0
  29. package/bundle/server/package.json +2 -2
  30. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  31. package/bundle/server/static-ui/index.html +1 -1
  32. package/bundle/shared/package.json +1 -1
  33. package/package.json +1 -1
  34. package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
@@ -0,0 +1,249 @@
1
+ // NOT-422: the NOT-405-like product-scope sequence end to end — reviewer
2
+ // escalation → amended Linear ticket adopted at the pause → Resume → next
3
+ // developer and reviewer enforce only the amended snapshot, on the same
4
+ // workflow, PR, branch, and worktree.
5
+ import { test, before, beforeEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-scope-contract-"));
11
+ const { migrate, getDb } = await import("../db/index.js");
12
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
13
+ const { createIssue, getIssue } = await import("../repository/issues.js");
14
+ const { startWorkflow, applyCompletion, applyParkedEdit, resolveHumanActionAndAdvance, getTaskSnapshot, diffTaskSnapshotFromLive, TASK_SNAPSHOT_ARTIFACT_KIND, } = await import("./commands.js");
15
+ const { reloadIssueSourceFromLinear } = await import("./source-reload.js");
16
+ const { claimWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
17
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
18
+ const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
19
+ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
20
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
21
+ const { ReviewerResult } = await import("./reviewer-result.js");
22
+ const { buildDeveloperPrompt, buildReviewerPrompt } = await import("./prompts.js");
23
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
24
+ before(() => migrate());
25
+ beforeEach(() => {
26
+ getDb().exec(`
27
+ DELETE FROM work_items;
28
+ DELETE FROM human_actions;
29
+ DELETE FROM workflow_events;
30
+ DELETE FROM worker_sessions;
31
+ DELETE FROM artifacts;
32
+ DELETE FROM workflow_instances;
33
+ DELETE FROM queue_entries;
34
+ DELETE FROM issues;
35
+ `);
36
+ stubManagedCloneForTests("acme/app");
37
+ });
38
+ // The ticket as frozen at workflow start: clean-machine proof is still an
39
+ // [operator] criterion the reviewer escalates on.
40
+ const CONTRACT_V1 = [
41
+ "Ship the friend-path run command.",
42
+ "",
43
+ "## Builder execution mode",
44
+ "feature",
45
+ "",
46
+ "## Non-goals",
47
+ "- Rewriting the installer",
48
+ "",
49
+ "## Exit predicate",
50
+ "The friend-path run command works on a clean machine with operator proof.",
51
+ "",
52
+ "## One-PR stopping point",
53
+ "Stop after the run command lands with its regression test.",
54
+ "",
55
+ "## Acceptance criteria",
56
+ "- [ ] Friend-path run works",
57
+ " Evidence: run fixtures | run the tests | green",
58
+ "- [ ] [operator] Clean-machine proof attached",
59
+ " Evidence: operator run | paste the output | reviewed",
60
+ "",
61
+ ].join("\n");
62
+ // The amended ticket: clean-machine proof moved out, simulator proof in.
63
+ const CONTRACT_V2 = [
64
+ "Ship the friend-path run command.",
65
+ "",
66
+ "## Builder execution mode",
67
+ "feature",
68
+ "",
69
+ "## Non-goals",
70
+ "- Rewriting the installer",
71
+ "",
72
+ "## Exit predicate",
73
+ "The friend-path run command works with simulator proof; clean-machine proof moved out.",
74
+ "",
75
+ "## One-PR stopping point",
76
+ "Stop after the run command lands with its regression test.",
77
+ "",
78
+ "## Acceptance criteria",
79
+ "- [ ] Friend-path run works",
80
+ " Evidence: run fixtures | run the tests | green",
81
+ "- [ ] Simulator proof attached",
82
+ " Evidence: simulator run | paste the output | reviewed",
83
+ "",
84
+ ].join("\n");
85
+ const REMOVED_CRITERION = "[operator] Clean-machine proof attached";
86
+ const AMENDED_EXIT = "The friend-path run command works with simulator proof; clean-machine proof moved out.";
87
+ async function adoptViaReload(issueId) {
88
+ // The operator reloads the amended Linear ticket at the pause (injected
89
+ // fetch — no network; the CommonJS fetch mock lives in the route suites).
90
+ const reloaded = await reloadIssueSourceFromLinear(issueId, {
91
+ fetchLinearIssue: async (externalId) => ({
92
+ id: externalId,
93
+ identifier: "NOT-405",
94
+ title: "Friend-path run, rescoped",
95
+ description: CONTRACT_V2,
96
+ url: "https://linear.app/not-so-fat/issue/NOT-405/x",
97
+ attachments: [],
98
+ }),
99
+ });
100
+ assert.equal(reloaded.ok, true);
101
+ }
102
+ async function adoptViaLocalEdit(issueId) {
103
+ // The Dealer-local path: the same amended contract saved through the
104
+ // parked-edit surface instead of a Linear reload.
105
+ const { compileExecutionContract } = await import("@agent-dealer/shared");
106
+ const edited = applyParkedEdit(issueId, {
107
+ title: "NOT-405: friend-path run, rescoped",
108
+ description: CONTRACT_V2,
109
+ acceptanceCriteria: compileExecutionContract(CONTRACT_V2).acceptanceCriteria,
110
+ });
111
+ assert.equal(edited.ok, true);
112
+ }
113
+ async function runScopePauseAdoptionFlow(adopt) {
114
+ const issueId = createIssue({
115
+ title: "NOT-405: friend-path run",
116
+ description: CONTRACT_V1,
117
+ repo: "acme/app",
118
+ baseBranch: "main",
119
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
120
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
121
+ source: "linear",
122
+ externalId: "linear-uuid-405",
123
+ externalLabel: "NOT-405",
124
+ externalUrl: "https://linear.app/not-so-fat/issue/NOT-405/x",
125
+ }).id;
126
+ assert.equal(startWorkflow(issueId).ok, true);
127
+ // Round 1: developer hands off, reviewer escalates a product-scope question.
128
+ const dev1 = claimWorkItem("scope-contract-test", { leaseMs: 60_000 });
129
+ await applyCompletion(dev1.id, dev1.leaseToken, {
130
+ kind: "clean_handoff",
131
+ branch: "dealer/not-405",
132
+ headSha: "abc123",
133
+ baseSha: "base1",
134
+ prNumber: 42,
135
+ prUrl: "https://gh/pr/42",
136
+ });
137
+ const rev1 = claimWorkItem("scope-contract-test", { leaseMs: 60_000 });
138
+ await applyCompletion(rev1.id, rev1.leaseToken, {
139
+ kind: "verdict",
140
+ result: ReviewerResult.parse({
141
+ verdict: "escalated",
142
+ productScopeQuestion: "Is clean-machine operator proof still required?",
143
+ baseSha: "b",
144
+ headSha: "h",
145
+ acceptanceCriteriaAssessment: "ok",
146
+ evidenceAssessment: "ok",
147
+ findings: [],
148
+ risks: [],
149
+ }),
150
+ });
151
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
152
+ const instanceBefore = getActiveWorkflowInstance(issueId);
153
+ const issueBefore = getIssue(issueId);
154
+ assert.equal(issueBefore.status, "needs_human");
155
+ // The frozen snapshot still enforces the old operator criterion.
156
+ assert.ok(getTaskSnapshot(issueBefore).acceptanceCriteria.includes(REMOVED_CRITERION));
157
+ // The operator adopts the amended contract at the pause — reload or local
158
+ // edit depending on the variant under test.
159
+ await adopt(issueId);
160
+ // The amended task is pending inspection: live rows changed, the open
161
+ // action and every workflow identity preserved, the snapshot untouched.
162
+ const parked = getIssue(issueId);
163
+ assert.equal(parked.description, CONTRACT_V2);
164
+ assert.equal(parked.executionContract?.exitPredicate, AMENDED_EXIT);
165
+ assert.equal(listHumanActionsForIssue(issueId).find((a) => a.id === action.id).status, "open");
166
+ assert.equal(getActiveWorkflowInstance(issueId).id, instanceBefore.id);
167
+ assert.equal(parked.prNumber, 42);
168
+ assert.equal(parked.branch, "dealer/not-405");
169
+ assert.ok(getTaskSnapshot(parked).acceptanceCriteria.includes(REMOVED_CRITERION));
170
+ assert.ok(diffTaskSnapshotFromLive(parked).length > 0);
171
+ // Resume freezes exactly one new snapshot before the next developer item.
172
+ const sessionsBeforeResume = listWorkerSessionsForIssue(issueId);
173
+ const snapshotsBefore = listArtifactsForIssue(issueId).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND).length;
174
+ const resolved = resolveHumanActionAndAdvance(action.id, "operator", "resume", {
175
+ note: "Clean-machine proof moved to NOT-421; enforce simulator proof.",
176
+ });
177
+ assert.equal(resolved.ok, true);
178
+ const snapshotsAfter = listArtifactsForIssue(issueId).filter((a) => a.kind === TASK_SNAPSHOT_ARTIFACT_KIND);
179
+ assert.equal(snapshotsAfter.length, snapshotsBefore + 1);
180
+ const events = listWorkflowEventsForIssue(issueId);
181
+ const refreshed = events.filter((e) => e.type === "task_snapshot.refreshed");
182
+ assert.equal(refreshed.length, 1);
183
+ const types = events.map((e) => e.type);
184
+ assert.ok(types.indexOf("task_snapshot.refreshed") < types.lastIndexOf("repair.started"));
185
+ const resumed = getIssue(issueId);
186
+ assert.equal(getActiveWorkflowInstance(issueId).id, instanceBefore.id);
187
+ assert.equal(resumed.prNumber, issueBefore.prNumber);
188
+ assert.equal(resumed.prUrl, issueBefore.prUrl);
189
+ assert.equal(resumed.branch, issueBefore.branch);
190
+ assert.deepEqual(listWorkerSessionsForIssue(issueId), sessionsBeforeResume);
191
+ const pendingDev = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
192
+ assert.equal(pendingDev.length, 1);
193
+ assert.equal(pendingDev[0].kind, "developer");
194
+ assert.equal(pendingDev[0].workflowInstanceId, instanceBefore.id);
195
+ const devPayload = JSON.parse(pendingDev[0].payloadJson);
196
+ assert.equal(devPayload.scopeDecisionNote, "Clean-machine proof moved to NOT-421; enforce simulator proof.");
197
+ // The next developer prompt — built the way the developer effect builds it,
198
+ // from this work item's own round and one-shot note over the frozen
199
+ // snapshot — enforces only the amended snapshot.
200
+ const developerPrompt = buildDeveloperPrompt({
201
+ taskSnapshot: getTaskSnapshot(resumed),
202
+ round: pendingDev[0].round,
203
+ scopeDecisionNote: devPayload.scopeDecisionNote,
204
+ });
205
+ assert.ok(developerPrompt.includes(AMENDED_EXIT));
206
+ assert.ok(developerPrompt.includes("Simulator proof attached"));
207
+ assert.ok(!developerPrompt.includes(REMOVED_CRITERION));
208
+ assert.ok(!developerPrompt.includes("clean machine with operator proof"));
209
+ // The developer completes; the following reviewer prompt enforces the same
210
+ // amended snapshot — no escalation for the removed criterion can recur.
211
+ const dev2 = claimWorkItem("scope-contract-test", { leaseMs: 60_000 });
212
+ assert.equal(dev2.id, pendingDev[0].id);
213
+ await applyCompletion(dev2.id, dev2.leaseToken, {
214
+ kind: "clean_handoff",
215
+ branch: "dealer/not-405",
216
+ headSha: "def456",
217
+ baseSha: "base1",
218
+ prNumber: 42,
219
+ prUrl: "https://gh/pr/42",
220
+ });
221
+ const reviewRound = getIssue(issueId);
222
+ assert.equal(reviewRound.status, "reviewing");
223
+ assert.equal(getActiveWorkflowInstance(issueId).id, instanceBefore.id);
224
+ assert.equal(reviewRound.branch, "dealer/not-405");
225
+ // The following reviewer work item — claimed the way the effect worker
226
+ // claims it — carries the same workflow instance, and its prompt inputs
227
+ // (this item's round over the frozen snapshot) enforce the amended
228
+ // snapshot with no escalation for the removed criterion recurring.
229
+ const rev2 = claimWorkItem("scope-contract-test", { leaseMs: 60_000 });
230
+ assert.equal(rev2.kind, "reviewer");
231
+ assert.equal(rev2.workflowInstanceId, instanceBefore.id);
232
+ const reviewerPrompt = buildReviewerPrompt({
233
+ taskSnapshot: getTaskSnapshot(reviewRound),
234
+ round: rev2.round,
235
+ baseSha: "a".repeat(40),
236
+ headSha: "b".repeat(40),
237
+ diff: "diff --git a/x b/x\n+added line\n",
238
+ });
239
+ assert.ok(reviewerPrompt.includes(AMENDED_EXIT));
240
+ assert.ok(reviewerPrompt.includes("Simulator proof attached"));
241
+ assert.ok(!reviewerPrompt.includes(REMOVED_CRITERION));
242
+ assert.ok(!reviewerPrompt.includes("clean machine with operator proof"));
243
+ }
244
+ test("NOT-422: reviewer escalation → scope-pause reload → Resume → both roles enforce only the amended snapshot", async () => {
245
+ await runScopePauseAdoptionFlow(adoptViaReload);
246
+ });
247
+ test("NOT-422: reviewer escalation → scope-pause local edit → Resume → both roles enforce only the amended snapshot", async () => {
248
+ await runScopePauseAdoptionFlow(adoptViaLocalEdit);
249
+ });
@@ -1,6 +1,7 @@
1
1
  import { sourceAttachmentsDeveloperSection, sourceAttachmentsReviewerSection, } from "./source-attachments.js";
2
2
  import { MUSE_SANDBOX_CAPABILITIES } from "../runners/muse-code-args.js";
3
3
  import { checkMuseVisualQa, museVisualQaPromptSection, } from "../adapters/muse-visual-qa.js";
4
+ import { capableDeveloperVisualQaPromptSection, developerVisualQaPolicy, visualQaReviewerSection, } from "./visual-qa.js";
4
5
  import { formatVerificationReceiptSection, } from "./verification-receipt.js";
5
6
  import { formatOperatorCriteria, } from "./operator-criteria.js";
6
7
  /**
@@ -284,10 +285,15 @@ export function buildDeveloperPrompt(input) {
284
285
  parts.push(...museEnvironmentLimitsSection());
285
286
  // NOT-303: the screenshot-path preflight verdict is embedded before the session
286
287
  // starts, so the worker reads it instead of discovering the Chrome.app abort
287
- // mid-session. Non-Muse developers are untouched.
288
- if (input.museDeveloper) {
288
+ // mid-session. NOT-381: non-Muse runtimes get the capable-runtime in-session
289
+ // attempt instead of the old blanket no-browser assumption.
290
+ const visualQaPolicy = input.museDeveloper ? "muse" : developerVisualQaPolicy(input.runtime);
291
+ if (visualQaPolicy === "muse") {
289
292
  parts.push(...museVisualQaPromptSection(input.museVisualQa ?? checkMuseVisualQa()));
290
293
  }
294
+ else {
295
+ parts.push(...capableDeveloperVisualQaPromptSection());
296
+ }
291
297
  parts.push(`## Required`,
292
298
  // NOT-315: the coordinator provisions dependencies before the session spawns —
293
299
  // the sandbox has no network, so an in-session install can never work.
@@ -370,6 +376,49 @@ export function formatDiffForPrompt(diff) {
370
376
  omittedPaths,
371
377
  };
372
378
  }
379
+ /**
380
+ * NOT-384: SHA-bound CI visual evidence for this head. `available` names the
381
+ * staged paths, both SHAs, and the diff summary, and instructs the reviewer to
382
+ * judge the screenshots and the base-vs-head diff at both viewports. `missing`
383
+ * and `failed` name the state and forbid a visual pass — and forbid a blocking
384
+ * finding about the missing evidence itself, since Dealer holds the head for a
385
+ * human operator automatically (a repair round must never start solely for
386
+ * missing visual evidence). Absent renders nothing.
387
+ */
388
+ function visualEvidenceSection(evidence) {
389
+ if (!evidence)
390
+ return [];
391
+ const head8 = evidence.headSha.slice(0, 8);
392
+ if (evidence.state === "available") {
393
+ const staged = evidence.staged;
394
+ const lines = [
395
+ `## Visual evidence (CI-captured at ${head8})`,
396
+ `The \`Visual\` CI workflow captured this exact head (\`${evidence.headSha}\`, base \`${evidence.baseSha}\`${evidence.runId != null ? `, run ${evidence.runId}` : ""}) at the required viewports (1440x900 and 390x800), running each route's interaction steps before the screenshot. Judge the screenshots and the base-vs-head diff at both viewports — a nonzero pixel diff is evidence, never an automatic failure; decide whether the rendered changes match the task. A genuine rendered defect IS a coding finding; captures you cannot judge are a risk, never a pass.`,
397
+ ``,
398
+ ];
399
+ if (staged) {
400
+ lines.push(`- Head captures: \`${staged.screenshotsDir}/\``, `- Baseline captures: \`${staged.baselineDir}/\``, `- Diffs and summary: \`${staged.diffDir}/\``, ``);
401
+ }
402
+ if (evidence.files) {
403
+ for (const name of ["ui-screenshots", "ui-baseline", "ui-diff"]) {
404
+ const listing = evidence.files[name];
405
+ const extra = listing.total > listing.files.length ? ` (+${listing.total - listing.files.length} more)` : "";
406
+ lines.push(`\`${name}/\` (${listing.total} files${extra}): ${listing.files.map((f) => `\`${f}\``).join(", ") || "(empty)"}`);
407
+ }
408
+ lines.push(``);
409
+ }
410
+ lines.push(`### Diff summary (\`SUMMARY.md\`${staged?.summaryPath ? ` at \`${staged.summaryPath}\`` : ""})`, evidence.summary ?? "(the staged ui-diff directory has no SUMMARY.md — judge the diff PNGs directly)", ``, `Read the PNGs with your file tools — every path above is inside your read-only checkout. Record \`visual QA: verified (<n> shots at ${head8})\` in \`evidenceAssessment\`, naming the shots you judged.`, ``);
411
+ return lines;
412
+ }
413
+ const reason = evidence.reason ?? "no usable CI visual captures";
414
+ return [
415
+ `## Visual evidence: ${evidence.state} — no usable CI captures for this head`,
416
+ `The \`Visual\` CI workflow produced no usable screenshots for this exact head (\`${evidence.headSha}\`, base \`${evidence.baseSha}\`): ${reason}.`,
417
+ ``,
418
+ `Do NOT report a visual pass: write \`visual QA: not run (${evidence.state})\` in \`evidenceAssessment\`. Do not raise a blocking finding about the missing evidence itself — Dealer holds this head for a human operator automatically, and a repair round must never start solely for missing visual evidence. Judge only the code in the diff.`,
419
+ ``,
420
+ ];
421
+ }
373
422
  export function buildReviewerPrompt(input) {
374
423
  const parts = [
375
424
  input.round === 1
@@ -405,6 +454,12 @@ export function buildReviewerPrompt(input) {
405
454
  if (input.checksSummary) {
406
455
  parts.push(`## CI checks`, input.checksSummary, ``);
407
456
  }
457
+ // NOT-384: SHA-bound CI captures for this head (or the missing/failed
458
+ // notice) — rendered only when the coordinator resolved visual evidence.
459
+ parts.push(...visualEvidenceSection(input.visualEvidence));
460
+ // NOT-381: the coordinator-validated visual receipt (or loud rejection),
461
+ // SHA-bound to the pinned head — the raw conclusion line above is unverified.
462
+ parts.push(...visualQaReviewerSection(input.visualQa?.record ?? null, input.visualQa?.pinnedHeadSha ?? input.headSha));
408
463
  if (input.findings?.length) {
409
464
  parts.push(`## Findings from prior rounds (verify each is actually resolved)`);
410
465
  for (const f of input.findings) {
@@ -288,9 +288,64 @@ test("NOT-303: museDeveloper prompt names the pre-installed headless shell when
288
288
  assert.match(prompt, /--screenshot=<png>/);
289
289
  assert.match(prompt, /Google Chrome\.app/);
290
290
  });
291
- test("NOT-303: a non-Muse developer prompt has no visual-QA section", () => {
291
+ // NOT-381: non-Muse runtimes attempt in-session browser verification instead of
292
+ // the old blanket no-browser assumption — the prompt carries the bounded probe,
293
+ // the real-app requirement, the default viewports, and the receipt format.
294
+ test("NOT-381: a non-Muse developer prompt carries the capable-runtime visual-QA section", () => {
292
295
  const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
293
- assert.doesNotMatch(prompt, /## Visual QA/);
296
+ assert.match(prompt, /## Visual QA \(in-session browser verification\)/);
297
+ assert.match(prompt, /ONE bounded capability probe/);
298
+ assert.match(prompt, /REAL backend and frontend/);
299
+ assert.match(prompt, /1440x900/);
300
+ assert.match(prompt, /390x800/);
301
+ assert.match(prompt, /do not install browsers or packages/);
302
+ assert.match(prompt, /do not retry the failed capability/);
303
+ assert.match(prompt, /do not weaken any sandbox flag/);
304
+ assert.match(prompt, /never commit it/);
305
+ assert.doesNotMatch(prompt, /RegisterApplication/);
306
+ });
307
+ // NOT-381: the policy is runtime-aware — Muse (boolean or runtime id) keeps the
308
+ // NOT-303 preflight, Claude Code / Codex Local / Cursor Local get the attempt.
309
+ test("NOT-381: runtime policy keeps Muse behavior and gives capable runtimes the attempt", () => {
310
+ const saved = process.env.MUSE_HEADLESS_SHELL_BIN;
311
+ delete process.env.MUSE_HEADLESS_SHELL_BIN;
312
+ try {
313
+ for (const runtime of ["claude_code", "codex_local", "cursor_local"]) {
314
+ const prompt = buildDeveloperPrompt({
315
+ taskSnapshot,
316
+ round: 1,
317
+ deckId: "deck-1",
318
+ worktreePath: "/wt",
319
+ runtime,
320
+ });
321
+ assert.match(prompt, /## Visual QA \(in-session browser verification\)/, runtime);
322
+ assert.match(prompt, /ONE bounded capability probe/, runtime);
323
+ assert.doesNotMatch(prompt, /RegisterApplication/);
324
+ }
325
+ const museByRuntime = buildDeveloperPrompt({
326
+ taskSnapshot,
327
+ round: 1,
328
+ deckId: "deck-1",
329
+ worktreePath: "/wt",
330
+ runtime: "muse_code",
331
+ });
332
+ assert.match(museByRuntime, /## Visual QA \(screenshots\)/);
333
+ assert.match(museByRuntime, /RegisterApplication/);
334
+ assert.match(museByRuntime, /Visual QA: not run/);
335
+ const museByFlag = buildDeveloperPrompt({
336
+ taskSnapshot,
337
+ round: 1,
338
+ deckId: "deck-1",
339
+ worktreePath: "/wt",
340
+ museDeveloper: true,
341
+ runtime: "claude_code",
342
+ });
343
+ assert.match(museByFlag, /RegisterApplication/, "museDeveloper flag keeps the Muse preflight");
344
+ }
345
+ finally {
346
+ if (saved !== undefined)
347
+ process.env.MUSE_HEADLESS_SHELL_BIN = saved;
348
+ }
294
349
  });
295
350
  test("NOT-303: reviewer prompt states a missing screenshot is never a pass", () => {
296
351
  const prompt = buildReviewerPrompt(reviewerBase);
@@ -542,6 +597,96 @@ test("NOT-364: attachment-free developer prompts render no source-attachment sec
542
597
  assert.doesNotMatch(without, /## Source attachments/);
543
598
  assert.equal(buildDeveloperPrompt({ taskSnapshot, round: 1 }), without);
544
599
  });
600
+ // NOT-381: the reviewer input carries the coordinator-validated visual receipt
601
+ // SHA-bound to the pinned head; unavailable is a routing state, never a pass.
602
+ test("NOT-381: reviewer prompt includes the validated visual receipt bound to the pinned head", () => {
603
+ const headSha = "b".repeat(40);
604
+ const prompt = buildReviewerPrompt({
605
+ ...reviewerBase,
606
+ visualQa: {
607
+ pinnedHeadSha: headSha,
608
+ record: {
609
+ kind: "receipt",
610
+ createdAt: "2026-10-08T00:00:00.000Z",
611
+ content: {
612
+ status: "verified",
613
+ headSha,
614
+ realApp: "real (no mocks)",
615
+ scenario: "/widgets/new — fill the form and submit",
616
+ viewports: ["1440x900", "390x800"],
617
+ commands: "npm run dev; capture",
618
+ screenshots: [
619
+ { fileName: "0-desktop.png", blobPath: "/blobs/0-desktop.png", sizeBytes: 10, sha256: "ab".repeat(32) },
620
+ ],
621
+ capability: null,
622
+ note: null,
623
+ recordedAt: "2026-10-08T00:00:00.000Z",
624
+ },
625
+ },
626
+ },
627
+ });
628
+ assert.match(prompt, /## Visual QA \(developer receipt — SHA-bound to this head\)/);
629
+ assert.match(prompt, new RegExp(headSha));
630
+ assert.match(prompt, /real \(no mocks\)/);
631
+ assert.match(prompt, /0-desktop\.png/);
632
+ });
633
+ test("NOT-381: reviewer prompt presents unavailable and not-required receipts without normalizing to a pass", () => {
634
+ const unavailable = buildReviewerPrompt({
635
+ ...reviewerBase,
636
+ visualQa: {
637
+ pinnedHeadSha: reviewerBase.headSha,
638
+ record: {
639
+ kind: "receipt",
640
+ createdAt: "2026-10-08T00:00:00.000Z",
641
+ content: {
642
+ status: "unavailable",
643
+ headSha: reviewerBase.headSha,
644
+ realApp: null,
645
+ scenario: null,
646
+ viewports: [],
647
+ commands: null,
648
+ screenshots: [],
649
+ capability: "loopback listen: EPERM",
650
+ note: null,
651
+ recordedAt: "2026-10-08T00:00:00.000Z",
652
+ },
653
+ },
654
+ },
655
+ });
656
+ assert.match(unavailable, /## Visual QA \(UNAVAILABLE — not verified\)/);
657
+ assert.match(unavailable, /loopback listen: EPERM/);
658
+ assert.match(unavailable, /never a pass/);
659
+ assert.doesNotMatch(unavailable, /SHA-bound to this head/);
660
+ const notRequired = buildReviewerPrompt({
661
+ ...reviewerBase,
662
+ visualQa: {
663
+ pinnedHeadSha: reviewerBase.headSha,
664
+ record: {
665
+ kind: "receipt",
666
+ createdAt: "2026-10-08T00:00:00.000Z",
667
+ content: {
668
+ status: "not_required",
669
+ headSha: null,
670
+ realApp: null,
671
+ scenario: null,
672
+ viewports: [],
673
+ commands: null,
674
+ screenshots: [],
675
+ capability: null,
676
+ note: null,
677
+ recordedAt: "2026-10-08T00:00:00.000Z",
678
+ },
679
+ },
680
+ },
681
+ });
682
+ assert.match(notRequired, /## Visual QA \(not required/);
683
+ assert.doesNotMatch(notRequired, /SHA-bound to this head/);
684
+ const without = buildReviewerPrompt(reviewerBase);
685
+ assert.doesNotMatch(without, /## Visual QA \(developer receipt/);
686
+ assert.doesNotMatch(without, /## Visual QA \(UNAVAILABLE/);
687
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualQa: undefined }), without);
688
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualQa: null }), without);
689
+ });
545
690
  // NOT-364: reviewers get the immutable manifest as metadata only — no
546
691
  // worktree path, no server blob path, no file contents.
547
692
  test("NOT-364: reviewer prompt carries manifest metadata without file paths", () => {
@@ -576,3 +721,92 @@ test("NOT-364: reviewer prompt carries manifest metadata without file paths", ()
576
721
  const without = buildReviewerPrompt(reviewerBase);
577
722
  assert.doesNotMatch(without, /## Source attachments/);
578
723
  });
724
+ // NOT-384: available visual evidence cites the staged paths, both SHAs, and
725
+ // the diff summary, and instructs the reviewer to judge both viewports.
726
+ test("NOT-384: reviewer prompt cites staged SHA-bound screenshots and diff for available evidence", () => {
727
+ const prompt = buildReviewerPrompt({
728
+ ...reviewerBase,
729
+ visualEvidence: {
730
+ state: "available",
731
+ headSha: "b".repeat(40),
732
+ baseSha: "a".repeat(40),
733
+ runId: 42,
734
+ staged: {
735
+ stagedDir: "/wt/.agent-dealer-visual",
736
+ screenshotsDir: "/wt/.agent-dealer-visual/ui-screenshots",
737
+ baselineDir: "/wt/.agent-dealer-visual/ui-baseline",
738
+ diffDir: "/wt/.agent-dealer-visual/ui-diff",
739
+ summaryPath: "/wt/.agent-dealer-visual/ui-diff/SUMMARY.md",
740
+ },
741
+ summary: "## UI diff (base vs head, report-only)\n\n| `issues-home` | 1440x900 | 12 |",
742
+ files: {
743
+ "ui-screenshots": { files: ["issues-home-1440x900.png"], total: 8 },
744
+ "ui-baseline": { files: ["issues-home-1440x900.png"], total: 8 },
745
+ "ui-diff": { files: ["SUMMARY.md", "diff-issues-home-1440x900.png"], total: 9 },
746
+ },
747
+ reason: null,
748
+ },
749
+ });
750
+ assert.match(prompt, /## Visual evidence \(CI-captured at b{8}\)/);
751
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-screenshots/"), "staged head captures must be named");
752
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-baseline/"), "staged baseline must be named");
753
+ assert.ok(prompt.includes("/wt/.agent-dealer-visual/ui-diff/"), "staged diff must be named");
754
+ assert.ok(prompt.includes("`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`"), "head SHA must be cited");
755
+ assert.ok(prompt.includes("`aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`"), "base SHA must be cited");
756
+ assert.match(prompt, /run 42/);
757
+ assert.match(prompt, /1440x900 and 390x800/);
758
+ assert.match(prompt, /Judge the screenshots and the base-vs-head diff at both viewports/);
759
+ assert.match(prompt, /nonzero pixel diff is evidence, never an automatic failure/);
760
+ assert.match(prompt, /## UI diff \(base vs head, report-only\)/);
761
+ assert.match(prompt, /visual QA: verified/);
762
+ });
763
+ // NOT-384: missing and failed evidence name the state and forbid a visual
764
+ // pass — and forbid a blocking finding about the evidence itself, since Dealer
765
+ // holds the head for an operator instead of queuing a repair round.
766
+ test("NOT-384: reviewer prompt forbids a visual pass for missing evidence", () => {
767
+ const prompt = buildReviewerPrompt({
768
+ ...reviewerBase,
769
+ visualEvidence: {
770
+ state: "missing",
771
+ headSha: "b".repeat(40),
772
+ baseSha: "a".repeat(40),
773
+ runId: null,
774
+ staged: null,
775
+ summary: null,
776
+ files: null,
777
+ reason: "no Visual workflow run found for head bbbbbbbb",
778
+ },
779
+ });
780
+ assert.match(prompt, /## Visual evidence: missing/);
781
+ assert.ok(prompt.includes("`bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`"), "head SHA must be named");
782
+ assert.match(prompt, /no Visual workflow run found/);
783
+ assert.match(prompt, /Do NOT report a visual pass/);
784
+ assert.match(prompt, /visual QA: not run \(missing\)/);
785
+ assert.match(prompt, /Do not raise a blocking finding about the missing evidence itself/);
786
+ assert.match(prompt, /never start solely for missing visual evidence/);
787
+ });
788
+ test("NOT-384: reviewer prompt forbids a visual pass for failed evidence", () => {
789
+ const prompt = buildReviewerPrompt({
790
+ ...reviewerBase,
791
+ visualEvidence: {
792
+ state: "failed",
793
+ headSha: "b".repeat(40),
794
+ baseSha: "a".repeat(40),
795
+ runId: 43,
796
+ staged: null,
797
+ summary: null,
798
+ files: null,
799
+ reason: "Visual run 43 for this head concluded failure — its captures are unusable",
800
+ },
801
+ });
802
+ assert.match(prompt, /## Visual evidence: failed/);
803
+ assert.match(prompt, /concluded failure/);
804
+ assert.match(prompt, /Do NOT report a visual pass/);
805
+ assert.match(prompt, /visual QA: not run \(failed\)/);
806
+ assert.match(prompt, /Do not raise a blocking finding about the missing evidence itself/);
807
+ });
808
+ test("NOT-384: no visual evidence produces byte-for-byte the same reviewer prompt as before", () => {
809
+ const without = buildReviewerPrompt(reviewerBase);
810
+ assert.doesNotMatch(without, /## Visual evidence/);
811
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, visualEvidence: undefined }), without);
812
+ });