agent-dealer 1.3.1 → 1.3.3

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/agent-health.test.js +52 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  3. package/bundle/server/dist/adapters/github.js +50 -0
  4. package/bundle/server/dist/adapters/github.test.js +89 -1
  5. package/bundle/server/dist/capacity/service.js +66 -6
  6. package/bundle/server/dist/capacity/service.test.js +220 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  8. package/bundle/server/dist/coordinator/commands.js +89 -9
  9. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  10. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  11. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  12. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  13. package/bundle/server/dist/coordinator/prompts.js +57 -2
  14. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  15. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +89 -10
  16. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +100 -0
  17. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  18. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  19. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  20. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  21. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  22. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  23. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  24. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  25. package/bundle/server/dist/db/index.js +8 -0
  26. package/bundle/server/dist/db/schema.sql +5 -1
  27. package/bundle/server/dist/paths.js +24 -0
  28. package/bundle/server/dist/repository/issues.js +8 -1
  29. package/bundle/server/dist/repository/issues.test.js +34 -0
  30. package/bundle/server/dist/repository/runtime-availability.js +29 -11
  31. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  32. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  33. package/bundle/server/dist/routes/issues.js +23 -8
  34. package/bundle/server/dist/routes/issues.test.js +80 -0
  35. package/bundle/server/dist/runners/usage-cap.js +66 -15
  36. package/bundle/server/dist/runners/usage-cap.test.js +96 -2
  37. package/bundle/server/package.json +2 -2
  38. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +64 -0
  39. package/bundle/server/static-ui/index.html +1 -1
  40. package/bundle/shared/dist/runtime-capacity.d.ts +85 -0
  41. package/bundle/shared/dist/runtime-capacity.js +19 -0
  42. package/bundle/shared/package.json +1 -1
  43. package/package.json +1 -1
  44. package/bundle/server/static-ui/assets/index-Bz_c-UbO.js +0 -64
@@ -13,7 +13,8 @@ const { createIssue, getIssue, updateIssue, transitionIssue } = await import("..
13
13
  const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
14
14
  const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
15
15
  const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
16
- const { claimWorkItem, getWorkItem } = await import("../repository/work-items.js");
16
+ const { claimWorkItem, getWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
17
+ const { createIssueArtifact } = await import("../repository/artifacts.js");
17
18
  const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, responseOptionsFor, } = await import("./commands.js");
18
19
  const { ReviewerResult } = await import("./reviewer-result.js");
19
20
  const { setMergePrForTests, clearFinalizeInflightForTests, finalizeAutoMerge, gateOperatorVerification, OPERATOR_VERIFICATION_INTENT, } = await import("./auto-merge.js");
@@ -345,3 +346,103 @@ test("responseOptionsFor(operator_verification) matches the gate's stored option
345
346
  { choice: "repair", label: "Another repair round" },
346
347
  ]);
347
348
  });
349
+ // NOT-384: a UI-affecting head whose recorded CI visual evidence is `missing`
350
+ // holds at the same gate — one operator_verification action naming the head
351
+ // SHA and the missing artifacts, no merge, and crucially no repair round.
352
+ function recordVisualEvidence(issueId, opts) {
353
+ createIssueArtifact({
354
+ issueId,
355
+ kind: "visual_evidence",
356
+ content: {
357
+ headSha: opts.headSha,
358
+ baseSha: "base1",
359
+ uiAffecting: true,
360
+ state: opts.state,
361
+ runId: opts.state === "missing" ? null : 42,
362
+ artifactNames: opts.state === "available" ? ["ui-screenshots", "ui-baseline", "ui-diff"] : [],
363
+ reason: opts.state === "available"
364
+ ? null
365
+ : opts.state === "failed"
366
+ ? "Visual run 42 for this head concluded failure — its captures are unusable"
367
+ : "no Visual workflow run found for head abc123",
368
+ },
369
+ author: "system",
370
+ });
371
+ }
372
+ function assertNoRepairWorkScheduled(issueId) {
373
+ const pending = listWorkItemsForIssue(issueId).filter((item) => item.status === "pending" || item.status === "leased");
374
+ assert.deepEqual(pending.map((item) => item.kind), [], "missing visual evidence must never schedule a repair round");
375
+ }
376
+ for (const state of ["missing", "failed"]) {
377
+ test(`NOT-384: ${state} visual evidence on a UI head raises operator_verification and never a repair round`, async () => {
378
+ let merges = 0;
379
+ setMergePrForTests(async () => {
380
+ merges += 1;
381
+ return { ok: true };
382
+ });
383
+ const issueId = newIssue("It works");
384
+ startWorkflow(issueId);
385
+ recordVisualEvidence(issueId, { headSha: "abc123", state });
386
+ const result = await approve(issueId);
387
+ assert.equal(result.applied, true);
388
+ if (result.applied) {
389
+ assert.equal(result.issueStatus, "needs_human");
390
+ assert.equal(result.nextWorkItemId, null, "the gate queues no repair round");
391
+ assert.ok(result.humanActionId);
392
+ }
393
+ assert.equal(merges, 0, "the merge adapter must not run behind the gate");
394
+ assertNoRepairWorkScheduled(issueId);
395
+ const action = openOperatorAction(issueId);
396
+ assert.match(action.question, /abc123/, "the action names the head SHA");
397
+ assert.match(action.question, /ui-screenshots/, "the action names the missing artifacts");
398
+ assert.match(action.question, new RegExp(state), "the action names the evidence state");
399
+ const evidence = JSON.parse(action.evidenceJson);
400
+ assert.equal(evidence.operatorVerification.headSha, "abc123");
401
+ assert.equal(evidence.operatorVerification.visualEvidence.state, state);
402
+ assert.ok(evidence.operatorVerification.visualEvidence.missingArtifacts.length > 0);
403
+ const options = JSON.parse(action.responseOptionsJson);
404
+ assert.deepEqual(options.map((o) => o.choice), ["verified", "waive", "repair"]);
405
+ });
406
+ }
407
+ test("NOT-384: available visual evidence on a UI head merges normally", async () => {
408
+ const calls = [];
409
+ setMergePrForTests(async (opts) => {
410
+ calls.push(opts);
411
+ return { ok: true };
412
+ });
413
+ const issueId = newIssue("It works");
414
+ startWorkflow(issueId);
415
+ recordVisualEvidence(issueId, { headSha: "abc123", state: "available" });
416
+ const result = await approve(issueId);
417
+ assert.equal(result.applied, true);
418
+ if (result.applied)
419
+ assert.equal(result.issueStatus, "done");
420
+ assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
421
+ assert.equal(getIssue(issueId).status, "done");
422
+ });
423
+ test("NOT-384: [operator] criteria plus a visual hold raise one action naming both", async () => {
424
+ let merges = 0;
425
+ setMergePrForTests(async () => {
426
+ merges += 1;
427
+ return { ok: true };
428
+ });
429
+ const issueId = newIssue();
430
+ startWorkflow(issueId);
431
+ recordVisualEvidence(issueId, { headSha: "abc123", state: "missing" });
432
+ await approve(issueId);
433
+ assert.equal(merges, 0);
434
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
435
+ assert.equal(open.length, 1, "criteria and visual hold share one action for the head");
436
+ assert.equal(open[0].actionType, "operator_verification");
437
+ assert.match(open[0].question, /sign in with SSO/);
438
+ assert.match(open[0].question, /ui-screenshots/);
439
+ const evidence = JSON.parse(open[0].evidenceJson);
440
+ assert.equal(evidence.operatorVerification.criteria.length, 1);
441
+ assert.equal(evidence.operatorVerification.visualEvidence.state, "missing");
442
+ // One recorded result releases the combined action — a second finalize for
443
+ // the same head reuses it instead of raising another.
444
+ const again = await finalizeAutoMerge(issueId);
445
+ assert.equal(again.humanActionId, open[0].id);
446
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1);
447
+ assert.equal(merges, 0);
448
+ });
@@ -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) {