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
@@ -18,6 +18,17 @@ import { withRepoLock } from "../runners/process-registry.js";
18
18
  * Kept inside the repo (not `$AGENT_DEALER_HOME`) so `agent-deck use` grants cover the
19
19
  * worker cwd and `bind_workspace` to that path can succeed. */
20
20
  export const WORKTREES_DIR_NAME = ".agent-dealer-worktrees";
21
+ /** Directory name for coordinator-staged CI visual artifacts inside a reviewer
22
+ * worktree (NOT-384). The reviewer has no shell, so it cannot list the worktree
23
+ * root itself — but its file tools read paths the prompt names, and only paths
24
+ * inside the worktree are readable under Claude Code's `--restricted` file
25
+ * confinement. The directory is untracked and ignored by `isWorktreeClean`
26
+ * below, so staged evidence never reads as reviewer dirt. It only ever exists
27
+ * in detached-HEAD reviewer checkouts (developer sessions never create it); a
28
+ * developer-side file of the same hidden name would be ignored by the clean
29
+ * check too, but salvage (`git add -A`) still sweeps it up, so nothing is lost —
30
+ * and the coordinator never stages it anywhere but a reviewer worktree. */
31
+ export const REVIEWER_VISUAL_STAGING_DIR_NAME = ".agent-dealer-visual";
21
32
  const execFileAsync = promisify(execFile);
22
33
  const gitCommandObservers = new Set();
23
34
  /** Registers `observer` for every adapter `git` invocation; returns the unsubscribe. */
@@ -214,8 +225,34 @@ export async function removeWorktree(opts) {
214
225
  async function readOnlyStatus(worktreePath) {
215
226
  return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
216
227
  }
228
+ /** True when a `git status --porcelain` line names only the coordinator-staged
229
+ * visual-evidence directory (NOT-384) — `?? .agent-dealer-visual/` for the
230
+ * untracked dir, or a path under it. Anything else on the line means real dirt. */
231
+ function isVisualStagingStatusLine(line) {
232
+ const entry = line.slice(0, 2);
233
+ if (entry !== "??")
234
+ return false;
235
+ const shown = line.slice(3).trim().replace(/^"|"$/g, "");
236
+ return shown === REVIEWER_VISUAL_STAGING_DIR_NAME || shown.startsWith(`${REVIEWER_VISUAL_STAGING_DIR_NAME}/`);
237
+ }
217
238
  export async function isWorktreeClean(worktreePath) {
218
- return (await readOnlyStatus(worktreePath)).trim().length === 0;
239
+ const out = (await readOnlyStatus(worktreePath)).trim();
240
+ if (!out)
241
+ return true;
242
+ return out
243
+ .split("\n")
244
+ .map((line) => line.trimEnd())
245
+ .filter((line) => line.length > 0)
246
+ .every(isVisualStagingStatusLine);
247
+ }
248
+ /** Repo-relative paths changed between `baseSha` and `headSha` (NOT-384: the
249
+ * UI-affecting classifier's input). Empty when the SHAs match. */
250
+ export async function listChangedFiles(opts) {
251
+ const { stdout } = await git(opts.worktreePath, ["diff", "--name-only", opts.baseSha, opts.headSha]);
252
+ return stdout
253
+ .split("\n")
254
+ .map((line) => line.trim())
255
+ .filter((line) => line.length > 0);
219
256
  }
220
257
  /** Commit message for a timeout salvage tip (NOT-145). */
221
258
  export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
@@ -334,6 +334,17 @@ export function formatChecksFailureDetails(opts) {
334
334
  }
335
335
  return parts.join("\n");
336
336
  }
337
+ export function parseWorkflowRunInfo(raw) {
338
+ if (typeof raw !== "object" || raw === null)
339
+ return null;
340
+ const row = raw;
341
+ if (typeof row.databaseId !== "number" || typeof row.headSha !== "string" || !row.headSha) {
342
+ return null;
343
+ }
344
+ const conclusion = typeof row.conclusion === "string" && row.conclusion ? row.conclusion : null;
345
+ const status = typeof row.status === "string" && row.status ? row.status : null;
346
+ return { databaseId: row.databaseId, headSha: row.headSha, conclusion, status };
347
+ }
337
348
  const NO_COMMITS_PATTERN = /no commits between/i;
338
349
  const defaultExec = (args, opts) => run("gh", args, { cwd: opts.cwd, maxBuffer: opts.maxBuffer });
339
350
  async function ghPrView(exec, cwd, fields, selector) {
@@ -407,6 +418,45 @@ export function createGithubAdapter(exec = defaultExec) {
407
418
  return "UNKNOWN";
408
419
  }
409
420
  },
421
+ async listWorkflowRunsForCommit({ cwd, workflow, commitSha }) {
422
+ try {
423
+ const { stdout } = await exec([
424
+ "run",
425
+ "list",
426
+ "--workflow",
427
+ workflow,
428
+ "--commit",
429
+ commitSha,
430
+ "--limit",
431
+ "5",
432
+ "--json",
433
+ "databaseId,headSha,conclusion,status",
434
+ ], { cwd });
435
+ const rows = JSON.parse(stdout);
436
+ if (!Array.isArray(rows))
437
+ return [];
438
+ const runs = [];
439
+ for (const row of rows) {
440
+ const parsed = parseWorkflowRunInfo(row);
441
+ if (parsed)
442
+ runs.push(parsed);
443
+ }
444
+ return runs;
445
+ }
446
+ catch (err) {
447
+ const message = err.stderr ?? err.message;
448
+ throw new Error(`gh run list failed: ${message}`);
449
+ }
450
+ },
451
+ async downloadRunArtifact({ cwd, runId, name, destDir }) {
452
+ try {
453
+ await exec(["run", "download", String(runId), "--name", name, "--dir", destDir], { cwd });
454
+ }
455
+ catch (err) {
456
+ const message = err.stderr ?? err.message;
457
+ throw new Error(`gh run download failed: ${message}`);
458
+ }
459
+ },
410
460
  async publishReview({ cwd, number, event, bodyFilePath }) {
411
461
  const result = await runReview(exec, cwd, number, event, bodyFilePath);
412
462
  if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
@@ -1,7 +1,7 @@
1
1
  // packages/server/src/adapters/github.test.ts
2
2
  import { test } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, } from "./github.js";
4
+ import { parsePrView, summarizeChecks, pollPrChecks, pollPrChecksDetailed, baseRefHasPullRequestWorkflow, workflowHasPullRequestTrigger, createGithubAdapter, fetchChecksFailureEvidence, sanitizeCiText, sanitizeUrl, extractActionsRunId, buildFailureExcerpt, formatChecksFailureDetails, CHECKS_EVIDENCE_MAX_EXCERPT_CHARS, CHECKS_EVIDENCE_MAX_EXCERPT_LINES, CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN, CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER, CHECKS_FAILURE_GENERIC_REASON, PR_VIEW_FIELDS, parseWorkflowRunInfo, } from "./github.js";
5
5
  test("parsePrView extracts the ground-truth handoff fields, including draft status", () => {
6
6
  const view = parsePrView(JSON.stringify({
7
7
  number: 7,
@@ -814,3 +814,91 @@ test("NOT-252: formatChecksFailureDetails labels the excerpt as untrusted, not i
814
814
  });
815
815
  assert.match(noExcerpt, /excerpt unavailable/);
816
816
  });
817
+ test("NOT-384: listWorkflowRunsForCommit filters by workflow and exact commit, parsing runs", async () => {
818
+ const { exec, calls } = queuedExec([
819
+ {
820
+ stdout: JSON.stringify([
821
+ { databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
822
+ { databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
823
+ ]),
824
+ },
825
+ ]);
826
+ const runs = await createGithubAdapter(exec).listWorkflowRunsForCommit({
827
+ cwd: "/repo",
828
+ workflow: "visual.yml",
829
+ commitSha: HEAD_SHA,
830
+ });
831
+ assert.deepEqual(calls[0], [
832
+ "run",
833
+ "list",
834
+ "--workflow",
835
+ "visual.yml",
836
+ "--commit",
837
+ HEAD_SHA,
838
+ "--limit",
839
+ "5",
840
+ "--json",
841
+ "databaseId,headSha,conclusion,status",
842
+ ]);
843
+ assert.deepEqual(runs, [
844
+ { databaseId: 42, headSha: HEAD_SHA, conclusion: "success", status: "completed" },
845
+ { databaseId: 41, headSha: "other", conclusion: null, status: "in_progress" },
846
+ ]);
847
+ });
848
+ test("NOT-384: listWorkflowRunsForCommit returns [] when the workflow never ran, skips malformed rows, throws on gh failure", async () => {
849
+ const empty = queuedExec([{ stdout: "[]" }]);
850
+ assert.deepEqual(await createGithubAdapter(empty.exec).listWorkflowRunsForCommit({
851
+ cwd: "/repo",
852
+ workflow: "visual.yml",
853
+ commitSha: HEAD_SHA,
854
+ }), []);
855
+ const malformed = queuedExec([
856
+ {
857
+ stdout: JSON.stringify([
858
+ { databaseId: "nope", headSha: HEAD_SHA },
859
+ { databaseId: 42 },
860
+ null,
861
+ { databaseId: 43, headSha: HEAD_SHA, conclusion: "", status: "" },
862
+ ]),
863
+ },
864
+ ]);
865
+ assert.deepEqual(await createGithubAdapter(malformed.exec).listWorkflowRunsForCommit({
866
+ cwd: "/repo",
867
+ workflow: "visual.yml",
868
+ commitSha: HEAD_SHA,
869
+ }), [{ databaseId: 43, headSha: HEAD_SHA, conclusion: null, status: null }]);
870
+ const failing = queuedExec([{ error: "HTTP 401" }]);
871
+ await assert.rejects(createGithubAdapter(failing.exec).listWorkflowRunsForCommit({
872
+ cwd: "/repo",
873
+ workflow: "visual.yml",
874
+ commitSha: HEAD_SHA,
875
+ }), /gh run list failed/);
876
+ });
877
+ test("NOT-384: parseWorkflowRunInfo rejects rows without a numeric id and head SHA", () => {
878
+ assert.equal(parseWorkflowRunInfo(null), null);
879
+ assert.equal(parseWorkflowRunInfo({ databaseId: 1 }), null);
880
+ assert.equal(parseWorkflowRunInfo({ databaseId: 1, headSha: "" }), null);
881
+ assert.deepEqual(parseWorkflowRunInfo({ databaseId: 1, headSha: "h" }), {
882
+ databaseId: 1,
883
+ headSha: "h",
884
+ conclusion: null,
885
+ status: null,
886
+ });
887
+ });
888
+ test("NOT-384: downloadRunArtifact downloads one named artifact into the dest dir, throwing on gh failure", async () => {
889
+ const { exec, calls } = queuedExec([{ stdout: "" }]);
890
+ await createGithubAdapter(exec).downloadRunArtifact({
891
+ cwd: "/repo",
892
+ runId: 42,
893
+ name: "ui-diff",
894
+ destDir: "/store/ui-diff",
895
+ });
896
+ assert.deepEqual(calls[0], ["run", "download", "42", "--name", "ui-diff", "--dir", "/store/ui-diff"]);
897
+ const failing = queuedExec([{ error: "no such artifact" }]);
898
+ await assert.rejects(createGithubAdapter(failing.exec).downloadRunArtifact({
899
+ cwd: "/repo",
900
+ runId: 42,
901
+ name: "ui-diff",
902
+ destDir: "/store/ui-diff",
903
+ }), /gh run download failed/);
904
+ });
@@ -985,6 +985,121 @@ test("NOT-215: lowering 2 → 1 never preempts; admission pauses until occupancy
985
985
  assert.equal((await admitNext())?.issueId, c.id);
986
986
  assert.equal(getIssue(c.id).status, "developing");
987
987
  });
988
+ test("NOT-378: limit 5 fills all five slots in one tick across same and different repositories; a sixth waits on global capacity", async () => {
989
+ setMaxActiveIssues(5);
990
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-five-"));
991
+ const same1 = readyIssueInRepo("five-s1", repo);
992
+ const same2 = readyIssueInRepo("five-s2", repo);
993
+ const same3 = readyIssueInRepo("five-s3", repo);
994
+ const diff1 = readyIssue("five-d1");
995
+ const diff2 = readyIssue("five-d2");
996
+ const sixth = readyIssue("five-d3");
997
+ enqueueIssue(same1.id);
998
+ enqueueIssue(same2.id);
999
+ enqueueIssue(same3.id);
1000
+ enqueueIssue(diff1.id);
1001
+ enqueueIssue(diff2.id);
1002
+ enqueueIssue(sixth.id);
1003
+ const first = await admitNext();
1004
+ assert.equal(first?.issueId, same1.id);
1005
+ for (const issue of [same1, same2, same3, diff1, diff2]) {
1006
+ assert.equal(getIssue(issue.id).status, "developing");
1007
+ assert.equal(instanceCount(issue.id), 1);
1008
+ }
1009
+ const instanceIds = new Set([same1, same2, same3, diff1, diff2].map((issue) => getActiveWorkflowInstance(issue.id).id));
1010
+ assert.equal(instanceIds.size, 5, "each issue gets its own active workflow instance");
1011
+ assert.equal(getIssue(sixth.id).status, "ready");
1012
+ assert.equal(getActiveWorkflowInstance(sixth.id), null);
1013
+ assert.equal(instanceCount(sixth.id), 0);
1014
+ // Live global wait reason on read — never a repository-slot reason anywhere.
1015
+ const live = queueStatusForIssue(sixth.id)?.waitReason ?? "";
1016
+ assert.match(live, /waiting for slot/);
1017
+ assert.doesNotMatch(live, /repository slot/);
1018
+ for (const read of listQueuedEntriesForRead()) {
1019
+ assert.doesNotMatch(read.waitReason ?? "", /repository slot/);
1020
+ }
1021
+ // A second tick persists the slot reason and admits nothing new.
1022
+ assert.equal(await admitNext(), null);
1023
+ assert.match(getQueuedEntryForIssue(sixth.id)?.waitReason ?? "", /waiting for slot/);
1024
+ assert.equal(instanceCount(sixth.id), 0);
1025
+ });
1026
+ test("NOT-378: declared blockers skip ahead correctly with five slots; the waiter admits once its blocker clears", async () => {
1027
+ setMaxActiveIssues(5);
1028
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admit-fiveblk-"));
1029
+ const blocked = linearIssueInRepo("fblk", "lin-378-blk", repo);
1030
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [
1031
+ i.externalId,
1032
+ i.externalId === "lin-378-blk"
1033
+ ? [{ id: "", identifier: "NOT-1", stateName: "In Progress", stateType: "started" }]
1034
+ : [],
1035
+ ])));
1036
+ const free = [
1037
+ readyIssueInRepo("fblk-f1", repo),
1038
+ readyIssueInRepo("fblk-f2", repo),
1039
+ readyIssue("fblk-f3"),
1040
+ readyIssue("fblk-f4"),
1041
+ readyIssue("fblk-f5"),
1042
+ ];
1043
+ enqueueIssue(blocked.id);
1044
+ for (const issue of free)
1045
+ enqueueIssue(issue.id);
1046
+ assert.equal((await admitNext())?.issueId, free[0].id);
1047
+ for (const issue of free) {
1048
+ assert.equal(getIssue(issue.id).status, "developing");
1049
+ assert.equal(instanceCount(issue.id), 1);
1050
+ }
1051
+ assert.equal(getIssue(blocked.id).status, "ready");
1052
+ assert.equal(getActiveWorkflowInstance(blocked.id), null);
1053
+ assert.equal(instanceCount(blocked.id), 0);
1054
+ const entry = getQueuedEntryForIssue(blocked.id);
1055
+ assert.equal(entry.state, "queued");
1056
+ assert.match(entry.waitReason ?? "", /waiting on NOT-1/);
1057
+ assert.doesNotMatch(entry.waitReason ?? "", /repository slot/);
1058
+ // The declared blocker is satisfied and a slot frees — the next tick admits the waiter.
1059
+ setBlockersProviderForTests(async (issues) => new Map(issues.map((i) => [i.externalId, []])));
1060
+ releaseToNeedsHuman(free[0].id);
1061
+ assert.equal((await admitNext())?.issueId, blocked.id);
1062
+ assert.equal(getIssue(blocked.id).status, "developing");
1063
+ });
1064
+ test("NOT-378: lowering 5 → 2 never preempts; admission pauses until occupancy drops below 2", async () => {
1065
+ setMaxActiveIssues(5);
1066
+ const active = [
1067
+ readyIssue("low5-a"),
1068
+ readyIssue("low5-b"),
1069
+ readyIssue("low5-c"),
1070
+ readyIssue("low5-d"),
1071
+ readyIssue("low5-e"),
1072
+ ];
1073
+ for (const issue of active)
1074
+ enqueueIssue(issue.id);
1075
+ await admitNext();
1076
+ for (const issue of active) {
1077
+ assert.equal(getIssue(issue.id).status, "developing");
1078
+ }
1079
+ setMaxActiveIssues(2);
1080
+ const waiter = readyIssue("low5-w");
1081
+ enqueueIssue(waiter.id);
1082
+ assert.equal(await admitNext(), null);
1083
+ for (const issue of active) {
1084
+ assert.equal(getIssue(issue.id).status, "developing", "lowering never stops running work");
1085
+ }
1086
+ assert.equal(getIssue(waiter.id).status, "ready");
1087
+ assert.match(getQueuedEntryForIssue(waiter.id)?.waitReason ?? "", /waiting for slot/);
1088
+ const over = getAdmissionStatus();
1089
+ assert.equal(over.active, 5);
1090
+ assert.equal(over.limit, 2);
1091
+ assert.equal(over.overCap, true);
1092
+ // Occupancy at the new limit is still not enough — it must fall *below* 2.
1093
+ releaseToNeedsHuman(active[0].id);
1094
+ releaseToNeedsHuman(active[1].id);
1095
+ releaseToNeedsHuman(active[2].id);
1096
+ assert.equal(await admitNext(), null);
1097
+ assert.equal(getIssue(waiter.id).status, "ready");
1098
+ assert.equal(getAdmissionStatus().overCap, false);
1099
+ releaseToNeedsHuman(active[3].id);
1100
+ assert.equal((await admitNext())?.issueId, waiter.id);
1101
+ assert.equal(getIssue(waiter.id).status, "developing");
1102
+ });
988
1103
  test("NOT-215: restart recovery observes the persisted setting without duplicate admission", async () => {
989
1104
  setMaxActiveIssues(2);
990
1105
  const a = readyIssue("rs-a");
@@ -34,6 +34,7 @@ import { triggerLinearPostMerge } from "./linear-merge-verify.js";
34
34
  import { startBaseAdvancedScan } from "./base-advanced-scan.js";
35
35
  import { runHeldWork } from "../power/host-awake-lifecycle.js";
36
36
  import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
37
+ import { readVisualEvidenceHold } from "./reviewer-visual-evidence.js";
37
38
  const run = promisify(execFile);
38
39
  /** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
39
40
  export const GH_MERGE_TIMEOUT_MS = 20_000;
@@ -339,6 +340,12 @@ async function finalizeAutoMergeOnce(issueId) {
339
340
  startBaseAdvancedScan(getIssue(issueId) ?? issue);
340
341
  return merged;
341
342
  }
343
+ /** One visual-evidence hold, for the gate's reason/question. */
344
+ function formatVisualHold(hold) {
345
+ return (`CI visual evidence for this head is ${hold.state}: ${hold.reason} ` +
346
+ `Head: \`${hold.headSha}\`. ` +
347
+ `Missing/unusable artifacts: ${hold.missingArtifacts.map((name) => `\`${name}\``).join(", ")}.`);
348
+ }
342
349
  /**
343
350
  * NOT-314: the `[operator]` merge gate. After reviewer approve and before the
344
351
  * `gh` merge: when the frozen snapshot holds operator criteria and no result is
@@ -347,9 +354,16 @@ async function finalizeAutoMergeOnce(issueId) {
347
354
  * unmerged and the issue parks in `needs_human` with an `operator_verification`
348
355
  * action listing each criterion and its command.
349
356
  *
350
- * Returns null when the merge may proceed (no operator criteria, a recorded
351
- * result for this head, or no workflow to gate). Idempotent: a second finalize
352
- * for the same head reuses the open action instead of raising another.
357
+ * NOT-384: a UI-affecting head whose recorded CI visual evidence is `missing`
358
+ * or `failed` holds at the same gate — same action type, same intent, same
359
+ * verified/waive/repair resolutions. One open action covers the whole head
360
+ * (criteria and visual items together), so one recorded result releases it;
361
+ * no repair round is ever queued for missing evidence alone.
362
+ *
363
+ * Returns null when the merge may proceed (no operator criteria, no visual
364
+ * hold, a recorded result for this head, or no workflow to gate). Idempotent:
365
+ * a second finalize for the same head reuses the open action instead of
366
+ * raising another.
353
367
  *
354
368
  * Fail-closed: when operator criteria exist, every non-proceed path returns a
355
369
  * result (never null), so a missing head SHA or an unexpected status blocks the
@@ -364,9 +378,10 @@ export function gateOperatorVerification(issueId) {
364
378
  if (!active)
365
379
  return null;
366
380
  const criteria = getOperatorCriteriaForIssue(current);
367
- if (criteria.length === 0)
368
- return null;
369
381
  const head = current.headSha;
382
+ const visualHold = head ? readVisualEvidenceHold(issueId, head) : null;
383
+ if (criteria.length === 0 && !visualHold)
384
+ return null;
370
385
  if (head && hasOperatorVerificationForHead(issueId, head))
371
386
  return null;
372
387
  if (current.status === "done")
@@ -447,6 +462,8 @@ export function gateOperatorVerification(issueId) {
447
462
  triggerReflect: false,
448
463
  };
449
464
  }
465
+ // One open action covers the whole head — criteria items, visual hold, or
466
+ // both — so reuse is by head alone, never per hold flavor.
450
467
  const existing = findOpenHumanAction(issueId, "operator_verification");
451
468
  if (existing && operatorActionHeadSha(existing) === head) {
452
469
  if (current.status !== "needs_human") {
@@ -465,15 +482,30 @@ export function gateOperatorVerification(issueId) {
465
482
  };
466
483
  }
467
484
  const shortHead = head.slice(0, 8);
468
- const reason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
485
+ const criteriaReason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
469
486
  `${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
470
487
  `to verify (tagged [operator] in the frozen task snapshot) and no result is recorded ` +
471
488
  `for this head. The PR stays unmerged until the result is recorded:\n` +
472
489
  formatOperatorCriteria(criteria).join("\n");
490
+ // Criteria-only keeps the exact NOT-314 text; a visual hold appends its
491
+ // own section (or stands alone), always naming the head SHA and the
492
+ // missing artifacts.
493
+ const reason = !visualHold
494
+ ? criteriaReason
495
+ : criteria.length === 0
496
+ ? `Reviewer approved ${shortHead}, but the head changes the web app and no result is recorded ` +
497
+ `for this head. The PR stays unmerged until the result is recorded:\n` +
498
+ formatVisualHold(visualHold)
499
+ : `${criteriaReason}\nAdditionally, the head changes the web app: ${formatVisualHold(visualHold)}`;
473
500
  transitionIssue(issueId, "needs_human", {
474
501
  currentOwner: "human",
475
502
  currentIntent: OPERATOR_VERIFICATION_INTENT,
476
503
  });
504
+ const questionSuffix = visualHold && criteria.length === 0
505
+ ? `Verify the rendered UI for this head by hand and paste what you checked, waive with a reason, ` +
506
+ `or send the work back for another repair round?`
507
+ : `Paste the probe output to record verification, waive with a reason, ` +
508
+ `or send the work back for another repair round?`;
477
509
  // Head-pinned request_id: a concurrent finalize for the same head dedupes
478
510
  // onto this action through createHumanAction's open-request conflict path;
479
511
  // new commits after the gate get a fresh action with their own commands.
@@ -482,9 +514,14 @@ export function gateOperatorVerification(issueId) {
482
514
  workflowInstanceId: active.id,
483
515
  actionType: "operator_verification",
484
516
  reason,
485
- question: `${reason}\n\nPaste the probe output to record verification, waive with a reason, ` +
486
- `or send the work back for another repair round?`,
487
- evidence: { operatorVerification: { criteria, headSha: head } },
517
+ question: `${reason}\n\n${questionSuffix}`,
518
+ evidence: {
519
+ operatorVerification: {
520
+ criteria,
521
+ headSha: head,
522
+ ...(visualHold ? { visualEvidence: visualHold } : {}),
523
+ },
524
+ },
488
525
  responseOptions: [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS],
489
526
  requestId: operatorVerificationRequestId(head),
490
527
  });
@@ -103,6 +103,12 @@ export function getTaskSnapshot(issue) {
103
103
  * NOT-358: the same parked-edit surface covers an open `policy_escalation` (e.g. after a
104
104
  * developer budget failure) so the owner can swap the developer and/or reviewer agent
105
105
  * profile and resume in the same worktree instead of recreating the issue.
106
+ *
107
+ * NOT-422: an open `product_scope_decision` on an active workflow is contract-editable
108
+ * the same way — title/description/acceptance-criteria only (no agent swap, see
109
+ * `parkedEditableFields`) — so the operator can adopt an amended contract and resume
110
+ * without aborting. A running session is checked separately by `applyParkedEdit` and
111
+ * the PATCH guard, which fail closed with 409.
106
112
  */
107
113
  export function canEditParkedIssue(issue) {
108
114
  if (issue.status !== "needs_human")
@@ -110,7 +116,8 @@ export function canEditParkedIssue(issue) {
110
116
  if (!getActiveWorkflowInstance(issue.id))
111
117
  return false;
112
118
  if (!findOpenHumanAction(issue.id, "attempts_exhausted") &&
113
- !findOpenHumanAction(issue.id, "policy_escalation")) {
119
+ !findOpenHumanAction(issue.id, "policy_escalation") &&
120
+ !findOpenHumanAction(issue.id, "product_scope_decision")) {
114
121
  return false;
115
122
  }
116
123
  return !listWorkItemsForIssue(issue.id).some((w) => w.status === "pending" || w.status === "leased");
@@ -156,6 +163,36 @@ export const PARKED_EDITABLE_FIELDS = new Set([
156
163
  "developerAgentId",
157
164
  "reviewerAgentId",
158
165
  ]);
166
+ /**
167
+ * NOT-422: the task-text subset of `PARKED_EDITABLE_FIELDS`. A product-scope pause
168
+ * adopts an amended contract only — repository, agents, policy, rounds, PR, branch
169
+ * and worktree all stay exactly as the running workflow saw them, so an agent swap
170
+ * at that park is a 409, not a silent accept.
171
+ */
172
+ export const PARKED_TASK_EDITABLE_FIELDS = new Set([
173
+ "title",
174
+ "description",
175
+ "acceptanceCriteria",
176
+ ]);
177
+ /**
178
+ * NOT-422: which parked-edit fields the issue's open actions allow. An open
179
+ * `attempts_exhausted` or `policy_escalation` keeps the full re-scope-plus-swap
180
+ * surface; a pure `product_scope_decision` park (neither of those open) allows
181
+ * the task-text subset only.
182
+ */
183
+ export function parkedEditableFields(issueId) {
184
+ if (findOpenHumanAction(issueId, "attempts_exhausted") ||
185
+ findOpenHumanAction(issueId, "policy_escalation")) {
186
+ return PARKED_EDITABLE_FIELDS;
187
+ }
188
+ return PARKED_TASK_EDITABLE_FIELDS;
189
+ }
190
+ /** Human-readable field list for the parked-edit 409, matching the allowed set. */
191
+ export function parkedEditableFieldsLabel(issueId) {
192
+ return parkedEditableFields(issueId) === PARKED_EDITABLE_FIELDS
193
+ ? "title, description, acceptanceCriteria, developerAgentId and reviewerAgentId"
194
+ : "title, description and acceptanceCriteria";
195
+ }
159
196
  /**
160
197
  * NOT-358: applies a parked-issue edit (re-scope and/or agent swap) as one short
161
198
  * transaction: guard re-check + agent-profile existence + row write + `issue.reassigned`
@@ -176,12 +213,13 @@ export function applyParkedEdit(issueId, patch) {
176
213
  if (!getActiveWorkflowInstance(issueId) || !canEditParkedIssue(fresh)) {
177
214
  return { ok: false, code: 409, error: "Cannot edit an issue with an active workflow" };
178
215
  }
179
- const blocked = Object.keys(patch).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
216
+ const allowed = parkedEditableFields(issueId);
217
+ const blocked = Object.keys(patch).filter((k) => !allowed.has(k));
180
218
  if (blocked.length > 0) {
181
219
  return {
182
220
  ok: false,
183
221
  code: 409,
184
- error: `Only title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`,
222
+ error: `Only ${parkedEditableFieldsLabel(issueId)} can be edited while parked (got: ${blocked.join(", ")})`,
185
223
  };
186
224
  }
187
225
  for (const role of ["developerAgentId", "reviewerAgentId"]) {
@@ -224,12 +262,30 @@ export function applyParkedEdit(issueId, patch) {
224
262
  }
225
263
  }
226
264
  /**
227
- * Re-freezes the snapshot when the operator edited title/description/criteria
228
- * (or the contract compiled from the description); returns the changed fields
229
- * (empty = no-op). Source and compiled contract always re-freeze atomically —
230
- * one artifact write carries both, so they can never diverge.
265
+ * Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
266
+ * excluded: it is content-addressed from the compared fields under an
267
+ * environment-local root, so it must never count as a task change.
231
268
  */
232
- function refreshTaskSnapshotIfEdited(issue) {
269
+ function attachmentDiffKey(r) {
270
+ return JSON.stringify({
271
+ linearAttachmentId: r.linearAttachmentId,
272
+ kind: r.kind,
273
+ title: r.title,
274
+ safeFileName: r.safeFileName ?? null,
275
+ contentType: r.contentType ?? null,
276
+ sizeBytes: r.sizeBytes ?? null,
277
+ sha256: r.sha256 ?? null,
278
+ url: r.url,
279
+ subtitle: r.subtitle ?? null,
280
+ source: r.source ?? null,
281
+ });
282
+ }
283
+ /**
284
+ * Live-vs-frozen task diff behind both the resume re-freeze and the Issue Detail
285
+ * pending-snapshot notice: the fields a resume would re-freeze (empty = in sync).
286
+ * Read-only — freezing stays with `refreshTaskSnapshotIfEdited` below.
287
+ */
288
+ export function diffTaskSnapshotFromLive(issue) {
233
289
  const frozen = getTaskSnapshot(issue);
234
290
  const liveContract = tryCompileContract(issue.description);
235
291
  const changed = [];
@@ -242,6 +298,26 @@ function refreshTaskSnapshotIfEdited(issue) {
242
298
  if (JSON.stringify(frozen.executionContract ?? null) !== JSON.stringify(liveContract ?? null)) {
243
299
  changed.push("executionContract");
244
300
  }
301
+ // NOT-422: a parked Linear reload can swap the attachment rows without
302
+ // touching the text. Without this, an attachment-only change never
303
+ // re-freezes on resume — the next round keeps the stale manifest (and its
304
+ // pruned blobs) and the amended inputs are never shown as pending.
305
+ const frozenManifest = (frozen.sourceAttachments ?? []).map(attachmentDiffKey).sort();
306
+ const liveManifest = listSourceAttachments(issue.id).map(attachmentDiffKey).sort();
307
+ if (JSON.stringify(frozenManifest) !== JSON.stringify(liveManifest)) {
308
+ changed.push("sourceAttachments");
309
+ }
310
+ return changed;
311
+ }
312
+ /**
313
+ * Re-freezes the snapshot when the operator edited title/description/criteria,
314
+ * the attachment manifest (or the contract compiled from the description);
315
+ * returns the changed fields (empty = no-op). Source and compiled contract
316
+ * always re-freeze atomically — one artifact write carries both, so they can
317
+ * never diverge.
318
+ */
319
+ function refreshTaskSnapshotIfEdited(issue) {
320
+ const changed = diffTaskSnapshotFromLive(issue);
245
321
  if (changed.length > 0)
246
322
  freezeTaskSnapshot(issue);
247
323
  return changed;
@@ -2057,8 +2133,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
2057
2133
  // read the re-scoped task. An unedited issue writes nothing.
2058
2134
  // NOT-358: a policy_escalation park is editable the same way (title/description/
2059
2135
  // criteria as well as agent profiles), so its resume re-freezes too.
2136
+ // NOT-422: a product_scope_decision resume re-freezes the amended contract the
2137
+ // same way — the pre-start branch returned above, so this is the active-workflow
2138
+ // pause only. The one-shot scope note still rides the next developer payload.
2060
2139
  if ((action.actionType === "attempts_exhausted" && resolution.choice === "retry") ||
2061
- (action.actionType === "policy_escalation" && resolution.choice === "resume")) {
2140
+ (action.actionType === "policy_escalation" && resolution.choice === "resume") ||
2141
+ (action.actionType === "product_scope_decision" && resolution.choice === "resume")) {
2062
2142
  const changedFields = refreshTaskSnapshotIfEdited(issueNow);
2063
2143
  if (changedFields.length > 0) {
2064
2144
  ev.emit("task_snapshot.refreshed", { actorType: "human", payload: { actionId: action.id, changedFields } });