agent-dealer 1.2.8 → 1.2.9

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 (51) hide show
  1. package/bundle/server/dist/adapters/github.js +80 -4
  2. package/bundle/server/dist/adapters/github.test.js +83 -1
  3. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  4. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +205 -1
  6. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  7. package/bundle/server/dist/coordinator/commands.js +194 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +145 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  11. package/bundle/server/dist/coordinator/human-resolution.js +19 -0
  12. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  13. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  14. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  15. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  16. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  17. package/bundle/server/dist/coordinator/projection.js +10 -4
  18. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  19. package/bundle/server/dist/coordinator/prompts.js +134 -1
  20. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  21. package/bundle/server/dist/coordinator/recovery.js +57 -3
  22. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  23. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  24. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  25. package/bundle/server/dist/coordinator/routing.js +45 -6
  26. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  27. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  28. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  29. package/bundle/server/dist/db/index.js +13 -0
  30. package/bundle/server/dist/db/schema.sql +2 -0
  31. package/bundle/server/dist/repository/issues.js +39 -3
  32. package/bundle/server/dist/repository/work-items.js +7 -0
  33. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  34. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  35. package/bundle/server/package.json +2 -2
  36. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  37. package/bundle/server/static-ui/index.html +1 -1
  38. package/bundle/shared/dist/human-actions.d.ts +4 -4
  39. package/bundle/shared/dist/human-actions.js +8 -0
  40. package/bundle/shared/dist/issues.d.ts +30 -0
  41. package/bundle/shared/dist/issues.js +14 -0
  42. package/bundle/shared/dist/issues.test.js +2 -0
  43. package/bundle/shared/dist/workflow.d.ts +4 -4
  44. package/bundle/shared/dist/workflow.js +6 -0
  45. package/bundle/shared/dist/workflow.test.js +1 -0
  46. package/bundle/shared/package.json +1 -1
  47. package/dist/action.js +4 -1
  48. package/dist/action.test.js +42 -0
  49. package/dist/index.js +1 -1
  50. package/package.json +1 -1
  51. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -0,0 +1,168 @@
1
+ // packages/server/src/coordinator/operator-criteria.ts
2
+ //
3
+ // NOT-314: `[operator]`-tagged acceptance criteria are a first-class merge gate.
4
+ // Some ACs can only be proven by a human with real credentials, a real login, or
5
+ // a paid session — the builder discovers this mid-run and today burns rounds
6
+ // before raising a product-scope escalation. Instead the tag is deliberate: the
7
+ // frozen snapshot's operator criteria block auto-merge until a human records the
8
+ // result (verified / waive) or sends the work back (repair).
9
+ //
10
+ // This module is pure except for the frozen-snapshot + verification-record
11
+ // readers at the bottom. The gate itself lives in auto-merge.ts
12
+ // (finalizeAutoMergeOnce, after reviewer approve, before the `gh` merge);
13
+ // resolution lives in commands.ts's resolveHumanActionAndAdvance.
14
+ import { latestIssueArtifact } from "../repository/artifacts.js";
15
+ import { listArtifactsForIssueByKind } from "../repository/artifacts-for-issue.js";
16
+ import { listWorkflowEventsForIssue } from "../repository/workflow-events.js";
17
+ /** The frozen snapshot artifact kind — same string as commands.ts's
18
+ * TASK_SNAPSHOT_ARTIFACT_KIND, repeated here so this module never imports the
19
+ * coordinator kernel (commands.ts imports auto-merge.ts, which imports this). */
20
+ const TASK_SNAPSHOT_KIND = "task_snapshot";
21
+ /** Issue artifact kind holding a recorded `verified` result (author human). */
22
+ export const OPERATOR_VERIFICATION_ARTIFACT_KIND = "operator_verification";
23
+ /**
24
+ * NOT-314: the stored response options for an `operator_verification` action.
25
+ * Shared by auto-merge.ts (which raises it) and commands.ts's responseOptionsFor
26
+ * so the two can never drift apart — same pattern as MERGE_FAILURE_RESPONSE_OPTIONS.
27
+ */
28
+ export const OPERATOR_VERIFICATION_RESPONSE_OPTIONS = [
29
+ { choice: "verified", label: "Verified — merge" },
30
+ { choice: "waive", label: "Waive — merge without it" },
31
+ { choice: "repair", label: "Another repair round" },
32
+ ];
33
+ /** Dedupe key for the gate's action: one open action per issue + head SHA. */
34
+ export function operatorVerificationRequestId(headSha) {
35
+ return `operator-verification:${headSha}`;
36
+ }
37
+ const OPERATOR_TAG_RE = /\[operator\]/i;
38
+ /** An indented checkbox (a nested sub-task) — never a command, never a gate. */
39
+ const INDENTED_CHECKBOX_RE = /^\s+[-*]\s*\[[ xX]?\]/;
40
+ /** An indented plain bullet — an evidence/command sub-bullet. */
41
+ const INDENTED_BULLET_RE = /^\s+[-*]\s+(.*)$/;
42
+ /**
43
+ * Returns each top-level (unindented) checkbox AC whose text contains the token
44
+ * `[operator]` (case-insensitive, anywhere in the line), together with the
45
+ * indented bullet lines directly under it (the command to run).
46
+ *
47
+ * "Top-level" means the checkbox marker starts at column 0; indented
48
+ * checkboxes (sub-tasks) never open the gate. Collection of a criterion's
49
+ * commands stops at the next column-0 checkbox, a non-bullet line, or EOF —
50
+ * blank lines inside the sub-bullet block are skipped, not terminators.
51
+ */
52
+ export function extractOperatorCriteria(acceptanceCriteria) {
53
+ const found = [];
54
+ const lines = acceptanceCriteria.split("\n");
55
+ let i = 0;
56
+ while (i < lines.length) {
57
+ const line = lines[i];
58
+ const top = line.match(/^[-*]\s*\[[ xX]?\]\s*(.*)$/);
59
+ if (top && OPERATOR_TAG_RE.test(top[1] ?? "")) {
60
+ const criterion = { text: (top[1] ?? "").trim(), commands: [] };
61
+ i += 1;
62
+ while (i < lines.length) {
63
+ const sub = lines[i];
64
+ if (/^\s*$/.test(sub)) {
65
+ i += 1;
66
+ continue;
67
+ }
68
+ // A column-0 checkbox ends this criterion's block (operator or not —
69
+ // the outer loop classifies it).
70
+ if (/^[-*]\s*\[[ xX]?\]\s*/.test(sub))
71
+ break;
72
+ // A nested (indented) checkbox is a sub-task, not a command — it ends
73
+ // the block without being collected.
74
+ if (INDENTED_CHECKBOX_RE.test(sub))
75
+ break;
76
+ const bullet = sub.match(INDENTED_BULLET_RE);
77
+ if (bullet) {
78
+ criterion.commands.push((bullet[1] ?? "").trim());
79
+ i += 1;
80
+ continue;
81
+ }
82
+ break;
83
+ }
84
+ found.push(criterion);
85
+ continue;
86
+ }
87
+ i += 1;
88
+ }
89
+ return found;
90
+ }
91
+ /** The frozen snapshot's acceptanceCriteria, falling back to the live issue
92
+ * fields for items queued before snapshots existed (same fallback as
93
+ * commands.ts's getTaskSnapshot). */
94
+ export function snapshotAcceptanceCriteria(issue) {
95
+ try {
96
+ const artifact = latestIssueArtifact(issue.id, TASK_SNAPSHOT_KIND);
97
+ if (artifact?.contentJson) {
98
+ const parsed = JSON.parse(artifact.contentJson);
99
+ if (typeof parsed.acceptanceCriteria === "string")
100
+ return parsed.acceptanceCriteria;
101
+ }
102
+ }
103
+ catch {
104
+ // fall through to the live-field fallback below
105
+ }
106
+ return issue.acceptanceCriteria ?? "";
107
+ }
108
+ /** The frozen snapshot's `[operator]` criteria for this issue ([] when none). */
109
+ export function getOperatorCriteriaForIssue(issue) {
110
+ return extractOperatorCriteria(snapshotAcceptanceCriteria(issue));
111
+ }
112
+ function recordHeadSha(record) {
113
+ return record && typeof record.headSha === "string" && record.headSha ? record.headSha : null;
114
+ }
115
+ /**
116
+ * True when the human already recorded a result for exactly `headSha`: either a
117
+ * `verified` artifact (kind `operator_verification`, author human) or a
118
+ * `operator_verification.waived` workflow event. A result pins the head it was
119
+ * recorded at — new commits re-block the gate.
120
+ */
121
+ export function hasOperatorVerificationForHead(issueId, headSha) {
122
+ if (!headSha)
123
+ return false;
124
+ try {
125
+ for (const artifact of listArtifactsForIssueByKind(issueId, OPERATOR_VERIFICATION_ARTIFACT_KIND)) {
126
+ if (!artifact.contentJson)
127
+ continue;
128
+ try {
129
+ const record = JSON.parse(artifact.contentJson);
130
+ if (recordHeadSha(record) === headSha)
131
+ return true;
132
+ }
133
+ catch {
134
+ // unreadable artifact content cannot prove verification
135
+ }
136
+ }
137
+ }
138
+ catch {
139
+ // a read failure fails closed below (no verification found → gate blocks)
140
+ }
141
+ try {
142
+ for (const event of listWorkflowEventsForIssue(issueId)) {
143
+ if (event.type !== "operator_verification.waived" || !event.payloadJson)
144
+ continue;
145
+ try {
146
+ const record = JSON.parse(event.payloadJson);
147
+ if (recordHeadSha(record) === headSha)
148
+ return true;
149
+ }
150
+ catch {
151
+ // unreadable payload cannot prove a waiver
152
+ }
153
+ }
154
+ }
155
+ catch {
156
+ // same fail-closed reasoning as above
157
+ }
158
+ return false;
159
+ }
160
+ /** One numbered criterion + its commands, for the gate's reason/question. */
161
+ export function formatOperatorCriteria(criteria) {
162
+ return criteria.flatMap((criterion, index) => {
163
+ const lines = [`${index + 1}. ${criterion.text}`];
164
+ for (const command of criterion.commands)
165
+ lines.push(` - ${command}`);
166
+ return lines;
167
+ });
168
+ }
@@ -0,0 +1,78 @@
1
+ // NOT-314: `[operator]` criterion parser — pure cases (no DB).
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { extractOperatorCriteria } from "./operator-criteria.js";
5
+ test("two [operator] ACs return both; the indented command sub-bullet is collected", () => {
6
+ const criteria = extractOperatorCriteria([
7
+ "- [ ] Agent can list sessions from the registry",
8
+ "- [ ] Operator can sign in with SSO and see the org dashboard [operator]",
9
+ " - `npm run probe:sso -- --env staging`",
10
+ "- [ ] Sessions expire after 30 days of inactivity",
11
+ "- [ ] Operator can complete a paid checkout in the sandbox [operator]",
12
+ "",
13
+ "- [ ] Docs describe the probe",
14
+ ].join("\n"));
15
+ assert.equal(criteria.length, 2);
16
+ assert.equal(criteria[0].text, "Operator can sign in with SSO and see the org dashboard [operator]");
17
+ assert.deepEqual(criteria[0].commands, ["`npm run probe:sso -- --env staging`"]);
18
+ assert.equal(criteria[1].text, "Operator can complete a paid checkout in the sandbox [operator]");
19
+ assert.deepEqual(criteria[1].commands, []);
20
+ });
21
+ test("a snapshot with no [operator] AC returns an empty list", () => {
22
+ assert.deepEqual(extractOperatorCriteria("- [ ] Agent lists sessions\n- [ ] Sessions expire after 30d\n"), []);
23
+ assert.deepEqual(extractOperatorCriteria(""), []);
24
+ });
25
+ test("tag case variants match: [OPERATOR], [Operator], [oPeRaToR]", () => {
26
+ const criteria = extractOperatorCriteria(["- [ ] First [OPERATOR]", "- [ ] Second [Operator]", "- [ ] Third [oPeRaToR]"].join("\n"));
27
+ assert.equal(criteria.length, 3);
28
+ });
29
+ test("tag position variants match: leading, middle, trailing, and bare", () => {
30
+ const criteria = extractOperatorCriteria([
31
+ "- [ ] [operator] Leading tag proves login",
32
+ "- [ ] Middle [operator] tag proves login",
33
+ "- [ ] Trailing tag proves login [operator]",
34
+ "- [ ] [operator]",
35
+ ].join("\n"));
36
+ assert.equal(criteria.length, 4);
37
+ assert.equal(criteria[0].text, "[operator] Leading tag proves login");
38
+ });
39
+ test("multiple indented sub-bullets all collect; blank lines inside the block are skipped", () => {
40
+ const criteria = extractOperatorCriteria([
41
+ "- [ ] Paid checkout works end to end [operator]",
42
+ " - `npm run probe:checkout -- --env staging`",
43
+ "",
44
+ " - see docs/probes/checkout.md for the test card",
45
+ ].join("\n"));
46
+ assert.equal(criteria.length, 1);
47
+ assert.deepEqual(criteria[0].commands, [
48
+ "`npm run probe:checkout -- --env staging`",
49
+ "see docs/probes/checkout.md for the test card",
50
+ ]);
51
+ });
52
+ test("indented (nested) checkboxes never open the gate, even with the tag", () => {
53
+ const criteria = extractOperatorCriteria(["- [ ] Parent task", " - [ ] Nested subtask [operator]", "- [ ] Other"].join("\n"));
54
+ assert.deepEqual(criteria, []);
55
+ });
56
+ test("a checked box with the tag still matches, and `*` markers work", () => {
57
+ const criteria = extractOperatorCriteria(["* [x] Already proven by hand [operator]", "- [ ] Plain task"].join("\n"));
58
+ assert.equal(criteria.length, 1);
59
+ assert.equal(criteria[0].text, "Already proven by hand [operator]");
60
+ });
61
+ test("the next top-level checkbox ends the command block; prose ends it too", () => {
62
+ const criteria = extractOperatorCriteria([
63
+ "- [ ] First [operator]",
64
+ " - `cmd-one`",
65
+ "- [ ] Second without tag",
66
+ "- [ ] Third [operator]",
67
+ " - `cmd-three`",
68
+ "Trailing prose, not a bullet.",
69
+ "- [ ] Fourth [operator]",
70
+ ].join("\n"));
71
+ assert.equal(criteria.length, 3);
72
+ assert.deepEqual(criteria[0].commands, ["`cmd-one`"]);
73
+ assert.deepEqual(criteria[1].commands, ["`cmd-three`"]);
74
+ assert.deepEqual(criteria[2].commands, []);
75
+ });
76
+ test("[agent] in AC text is not an operator criterion", () => {
77
+ assert.deepEqual(extractOperatorCriteria("- [ ] Agent lists sessions [agent]\n"), []);
78
+ });
@@ -0,0 +1,347 @@
1
+ // NOT-314: `[operator]` gate — approved reviews block before merge until a
2
+ // human records the result, then merge on verified/waive and repair on repair.
3
+ // DB-backed (same harness shape as auto-merge.integration.test.ts).
4
+ import { test, before, beforeEach, afterEach } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not314-"));
10
+ const { migrate, getDb } = await import("../db/index.js");
11
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
12
+ const { createIssue, getIssue, updateIssue, transitionIssue } = await import("../repository/issues.js");
13
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
14
+ const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
15
+ const { listWorkflowEventsForIssue, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
16
+ const { claimWorkItem, getWorkItem } = await import("../repository/work-items.js");
17
+ const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHumanActionAndAdvanceAsync, responseOptionsFor, } = await import("./commands.js");
18
+ const { ReviewerResult } = await import("./reviewer-result.js");
19
+ const { setMergePrForTests, clearFinalizeInflightForTests, finalizeAutoMerge, gateOperatorVerification, OPERATOR_VERIFICATION_INTENT, } = await import("./auto-merge.js");
20
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
21
+ const OPERATOR_AC = [
22
+ "- [ ] Agent can list sessions from the registry",
23
+ "- [ ] Operator can sign in with SSO and see the org dashboard [operator]",
24
+ " - `npm run probe:sso -- --env staging`",
25
+ "- [ ] Docs describe the probe",
26
+ ].join("\n");
27
+ let managedCwd = "";
28
+ before(() => {
29
+ migrate();
30
+ managedCwd = stubManagedCloneForTests("acme/app");
31
+ });
32
+ beforeEach(() => {
33
+ getDb().exec(`
34
+ DELETE FROM work_items;
35
+ DELETE FROM human_actions;
36
+ DELETE FROM workflow_events;
37
+ DELETE FROM findings;
38
+ DELETE FROM review_publications;
39
+ DELETE FROM worker_sessions;
40
+ DELETE FROM artifacts;
41
+ DELETE FROM usage_events;
42
+ DELETE FROM workflow_instances;
43
+ DELETE FROM issues;
44
+ `);
45
+ clearFinalizeInflightForTests();
46
+ setMergePrForTests(async () => ({ ok: true }));
47
+ });
48
+ afterEach(() => {
49
+ setMergePrForTests(null);
50
+ clearFinalizeInflightForTests();
51
+ });
52
+ function newIssue(acceptanceCriteria = OPERATOR_AC) {
53
+ return createIssue({
54
+ title: "Coordinate me",
55
+ description: "d",
56
+ acceptanceCriteria,
57
+ repo: "acme/app",
58
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
59
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
60
+ baseBranch: "main",
61
+ maxReviewRounds: 3,
62
+ maxInfraAttempts: 3,
63
+ source: "manual",
64
+ autoMerge: true,
65
+ }).id;
66
+ }
67
+ function claim(issueId) {
68
+ const item = claimWorkItem(`test-${issueId}`, { leaseMs: 60_000 });
69
+ assert.ok(item && item.issueId === issueId, "expected to lease this issue's work item");
70
+ return item;
71
+ }
72
+ const okReview = ReviewerResult.parse({
73
+ verdict: "approved",
74
+ baseSha: "b",
75
+ headSha: "h",
76
+ acceptanceCriteriaAssessment: "ok",
77
+ evidenceAssessment: "ok",
78
+ findings: [],
79
+ risks: [],
80
+ });
81
+ const cleanHandoff = {
82
+ kind: "clean_handoff",
83
+ branch: "issue-1",
84
+ headSha: "abc123",
85
+ baseSha: "base1",
86
+ prNumber: 42,
87
+ prUrl: "https://gh/pr/42",
88
+ };
89
+ /** Developer handoff + approved review with autoMerge on (routes into finalize). */
90
+ async function approve(issueId, handoff = cleanHandoff) {
91
+ const dev = claim(issueId);
92
+ await applyCompletion(dev.id, dev.leaseToken, handoff);
93
+ const rev = claim(issueId);
94
+ return applyCompletion(rev.id, rev.leaseToken, { kind: "verdict", result: okReview });
95
+ }
96
+ function openOperatorAction(issueId) {
97
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "operator_verification" && a.status === "open");
98
+ assert.ok(action, "expected an open operator_verification action");
99
+ return action;
100
+ }
101
+ test("approved review with an [operator] AC raises operator_verification and never merges", async () => {
102
+ const calls = [];
103
+ setMergePrForTests(async (opts) => {
104
+ calls.push(opts);
105
+ return { ok: true };
106
+ });
107
+ const issueId = newIssue();
108
+ startWorkflow(issueId);
109
+ const result = await approve(issueId);
110
+ assert.equal(result.applied, true);
111
+ if (result.applied)
112
+ assert.equal(result.issueStatus, "needs_human");
113
+ assert.equal(calls.length, 0, "the merge adapter must not run behind the gate");
114
+ const issue = getIssue(issueId);
115
+ assert.equal(issue.status, "needs_human");
116
+ assert.equal(issue.currentOwner, "human");
117
+ assert.equal(issue.currentIntent, OPERATOR_VERIFICATION_INTENT);
118
+ assert.ok(getActiveWorkflowInstance(issueId), "workflow stays active for the human decision");
119
+ const action = openOperatorAction(issueId);
120
+ assert.match(action.question, /sign in with SSO/);
121
+ assert.match(action.question, /npm run probe:sso -- --env staging/);
122
+ const evidence = JSON.parse(action.evidenceJson);
123
+ assert.equal(evidence.operatorVerification.headSha, "abc123");
124
+ assert.equal(evidence.operatorVerification.criteria.length, 1);
125
+ assert.deepEqual(evidence.operatorVerification.criteria[0].commands, [
126
+ "`npm run probe:sso -- --env staging`",
127
+ ]);
128
+ const options = JSON.parse(action.responseOptionsJson);
129
+ assert.deepEqual(options.map((o) => o.choice), ["verified", "waive", "repair"]);
130
+ });
131
+ test("a second finalize for the same head reuses the open action — still no merge", async () => {
132
+ let merges = 0;
133
+ setMergePrForTests(async () => {
134
+ merges += 1;
135
+ return { ok: true };
136
+ });
137
+ const issueId = newIssue();
138
+ startWorkflow(issueId);
139
+ await approve(issueId);
140
+ const first = openOperatorAction(issueId).id;
141
+ const again = await finalizeAutoMerge(issueId);
142
+ assert.equal(again.humanActionId, first);
143
+ assert.equal(again.issueStatus, "needs_human");
144
+ assert.equal(merges, 0);
145
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.actionType === "operator_verification" && a.status === "open").length, 1);
146
+ });
147
+ test("operator criteria with no recorded head SHA escalate instead of merging", async () => {
148
+ let merges = 0;
149
+ setMergePrForTests(async () => {
150
+ merges += 1;
151
+ return { ok: true };
152
+ });
153
+ // Parked for auto-merge with operator criteria but no handoff ever recorded
154
+ // a head SHA — the gate cannot pin a verification, so it must hold the merge.
155
+ const issueId = newIssue();
156
+ startWorkflow(issueId);
157
+ transitionIssue(issueId, "reviewing", { currentOwner: "system", currentIntent: "test" });
158
+ transitionIssue(issueId, "final_review", { currentOwner: "system", currentIntent: "review-test" });
159
+ assert.equal(getIssue(issueId).headSha, null);
160
+ const blocked = gateOperatorVerification(issueId);
161
+ assert.ok(blocked, "the gate must hold the merge when no head can be pinned");
162
+ assert.equal(blocked.issueStatus, "needs_human");
163
+ assert.ok(blocked.humanActionId);
164
+ assert.equal(merges, 0);
165
+ assert.equal(getIssue(issueId).status, "needs_human");
166
+ const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
167
+ assert.equal(open.length, 1);
168
+ assert.equal(open[0].actionType, "policy_escalation");
169
+ assert.match(open[0].reason, /no head SHA/);
170
+ // A repeat finalize reuses the open escalation — still no merge, no duplicate.
171
+ const again = gateOperatorVerification(issueId);
172
+ assert.ok(again);
173
+ assert.equal(again.humanActionId, blocked.humanActionId);
174
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1);
175
+ assert.equal(merges, 0);
176
+ });
177
+ test("operator criteria at an unexpected status park the finalize without merging", async () => {
178
+ let merges = 0;
179
+ setMergePrForTests(async () => {
180
+ merges += 1;
181
+ return { ok: true };
182
+ });
183
+ // A racer's finalize while the issue is still reviewing (never parked): the
184
+ // gate invents no action there, but must not let the merge through either.
185
+ const issueId = newIssue();
186
+ startWorkflow(issueId);
187
+ transitionIssue(issueId, "reviewing", { currentOwner: "system", currentIntent: "test" });
188
+ const parked = gateOperatorVerification(issueId);
189
+ assert.ok(parked, "the gate must not let the merge through off-park");
190
+ assert.equal(parked.issueStatus, "reviewing");
191
+ assert.equal(parked.humanActionId, null);
192
+ assert.equal(getIssue(issueId).status, "reviewing");
193
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
194
+ assert.equal(merges, 0);
195
+ });
196
+ test("no [operator] AC: the existing auto-merge path is unchanged", async () => {
197
+ const calls = [];
198
+ setMergePrForTests(async (opts) => {
199
+ calls.push(opts);
200
+ return { ok: true };
201
+ });
202
+ const issueId = newIssue("It works");
203
+ startWorkflow(issueId);
204
+ const result = await approve(issueId);
205
+ assert.equal(result.applied, true);
206
+ if (result.applied) {
207
+ assert.equal(result.issueStatus, "done");
208
+ assert.equal(result.instanceCompleted, true);
209
+ }
210
+ assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
211
+ assert.equal(getIssue(issueId).status, "done");
212
+ });
213
+ test("the gate reads the frozen snapshot, not live edits after start", async () => {
214
+ const calls = [];
215
+ setMergePrForTests(async (opts) => {
216
+ calls.push(opts);
217
+ return { ok: true };
218
+ });
219
+ const issueId = newIssue("It works");
220
+ startWorkflow(issueId);
221
+ updateIssue(issueId, { acceptanceCriteria: OPERATOR_AC });
222
+ await approve(issueId);
223
+ assert.equal(getIssue(issueId).status, "done", "a tag added after freeze must not gate");
224
+ assert.equal(calls.length, 1);
225
+ });
226
+ test("verified with a note stores the artifact and merges", async () => {
227
+ const calls = [];
228
+ setMergePrForTests(async (opts) => {
229
+ calls.push(opts);
230
+ return { ok: true };
231
+ });
232
+ const issueId = newIssue();
233
+ startWorkflow(issueId);
234
+ await approve(issueId);
235
+ const action = openOperatorAction(issueId);
236
+ const resolved = await resolveHumanActionAndAdvanceAsync(action.id, "op", "verified", {
237
+ note: "SSO login OK — org dashboard listed 3 orgs (probe output pasted).",
238
+ });
239
+ assert.equal(resolved.ok, true);
240
+ if (resolved.ok) {
241
+ assert.equal(resolved.issueStatus, "done");
242
+ assert.equal(resolved.instanceCompleted, true);
243
+ assert.equal(resolved.triggerReflect, true);
244
+ }
245
+ assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
246
+ assert.equal(getIssue(issueId).status, "done");
247
+ const stored = listArtifactsForIssueByKind(issueId, "operator_verification");
248
+ assert.equal(stored.length, 1);
249
+ assert.equal(stored[0].author, "human");
250
+ const content = JSON.parse(stored[0].contentJson);
251
+ assert.equal(content.headSha, "abc123");
252
+ assert.match(content.note, /SSO login OK/);
253
+ assert.equal(content.verifiedBy, "op");
254
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
255
+ });
256
+ test("waive with a note records the waiver event and merges", async () => {
257
+ const calls = [];
258
+ setMergePrForTests(async (opts) => {
259
+ calls.push(opts);
260
+ return { ok: true };
261
+ });
262
+ const issueId = newIssue();
263
+ startWorkflow(issueId);
264
+ await approve(issueId);
265
+ const action = openOperatorAction(issueId);
266
+ const resolved = await resolveHumanActionAndAdvanceAsync(action.id, "op", "waive", {
267
+ note: "Staging SSO is down today; shipping behind the flag, will verify tomorrow.",
268
+ });
269
+ assert.equal(resolved.ok, true);
270
+ if (resolved.ok)
271
+ assert.equal(resolved.issueStatus, "done");
272
+ assert.equal(calls.length, 1);
273
+ assert.equal(getIssue(issueId).status, "done");
274
+ const waived = listWorkflowEventsForIssue(issueId).filter((e) => e.type === "operator_verification.waived");
275
+ assert.equal(waived.length, 1);
276
+ const payload = JSON.parse(waived[0].payloadJson);
277
+ assert.equal(payload.headSha, "abc123");
278
+ assert.match(payload.note, /Staging SSO is down/);
279
+ });
280
+ test("repair starts a repair round, threads the note, and a new head re-blocks the gate", async () => {
281
+ const calls = [];
282
+ setMergePrForTests(async (opts) => {
283
+ calls.push(opts);
284
+ return { ok: true };
285
+ });
286
+ const issueId = newIssue();
287
+ startWorkflow(issueId);
288
+ await approve(issueId);
289
+ const first = openOperatorAction(issueId);
290
+ const repaired = resolveHumanActionAndAdvance(first.id, "op", "repair", {
291
+ note: "The probe 404s — its path moved; fix the script and the doc.",
292
+ });
293
+ assert.equal(repaired.ok, true);
294
+ if (repaired.ok) {
295
+ assert.equal(repaired.issueStatus, "repairing");
296
+ assert.ok(repaired.nextWorkItemId, "a repair round must be queued");
297
+ }
298
+ assert.equal(calls.length, 0, "repair must not merge");
299
+ const next = getWorkItem(repaired.ok ? repaired.nextWorkItemId : "");
300
+ assert.ok(next, "expected the queued repair work item");
301
+ const repairPayload = JSON.parse(next.payloadJson);
302
+ assert.equal(repairPayload.operatorRepairNote, "The probe 404s — its path moved; fix the script and the doc.");
303
+ // The repair round ships a new head; approving it must gate again on the new SHA.
304
+ const secondHandoff = { ...cleanHandoff, headSha: "new999" };
305
+ await approve(issueId, secondHandoff);
306
+ assert.equal(calls.length, 0, "the new head must not merge unverified");
307
+ const second = openOperatorAction(issueId);
308
+ assert.notEqual(second.id, first.id, "a moved head gets a fresh gate action");
309
+ const evidence = JSON.parse(second.evidenceJson);
310
+ assert.equal(evidence.operatorVerification.headSha, "new999");
311
+ });
312
+ test("an empty note is rejected with 400 for every choice and leaves the action open", () => {
313
+ const issueId = newIssue();
314
+ startWorkflow(issueId);
315
+ return approve(issueId).then(() => {
316
+ const action = openOperatorAction(issueId);
317
+ for (const choice of ["verified", "waive", "repair"]) {
318
+ for (const note of [undefined, "", " "]) {
319
+ const result = resolveHumanActionAndAdvance(action.id, "op", choice, { note });
320
+ assert.equal(result.ok, false);
321
+ if (!result.ok)
322
+ assert.equal(result.code, 400, `${choice} with empty note must 400`);
323
+ }
324
+ }
325
+ assert.equal(openOperatorAction(issueId).id, action.id, "the action must stay open");
326
+ });
327
+ });
328
+ test("an unknown choice on operator_verification is rejected with 400", () => {
329
+ const issueId = newIssue();
330
+ startWorkflow(issueId);
331
+ return approve(issueId).then(() => {
332
+ const action = openOperatorAction(issueId);
333
+ const result = resolveHumanActionAndAdvance(action.id, "op", "merge", {
334
+ note: "has a note but wrong choice",
335
+ });
336
+ assert.equal(result.ok, false);
337
+ if (!result.ok)
338
+ assert.equal(result.code, 400);
339
+ });
340
+ });
341
+ test("responseOptionsFor(operator_verification) matches the gate's stored options", () => {
342
+ assert.deepEqual(responseOptionsFor("operator_verification"), [
343
+ { choice: "verified", label: "Verified — merge" },
344
+ { choice: "waive", label: "Waive — merge without it" },
345
+ { choice: "repair", label: "Another repair round" },
346
+ ]);
347
+ });
@@ -302,6 +302,9 @@ const CORRECTION_CHOICES = {
302
302
  policy_escalation: ["resume", "repair", "retry_merge"],
303
303
  product_scope_decision: ["resume"],
304
304
  deck_interaction_required: ["resume"],
305
+ // NOT-314: an operator_verification repair sends the work back like any
306
+ // other repair round; verified/waive accept the work (no correction signal).
307
+ operator_verification: ["repair"],
305
308
  };
306
309
  function resolutionChoice(resolutionJson) {
307
310
  if (!resolutionJson)
@@ -14,19 +14,25 @@ export function projectDeveloperRoute(route, currentStatus, round) {
14
14
  effect: { kind: "enqueue", workItem: "reviewer", atHeadSha: route.headSha },
15
15
  advance: "none",
16
16
  };
17
- case "retry_developer":
17
+ case "retry_developer": {
18
+ // NOT-313: a CI-repair retry spends the CI budget, never the infra one —
19
+ // the route's budget marker (set only by the checks_failed path) decides.
20
+ const isCiRepair = route.budget === "ci";
18
21
  return {
19
22
  projection: {
20
23
  // developing → developing and repairing → repairing are both self-loops;
21
- // an infra-retry developer failure stays in "repairing".
24
+ // a retried developer failure stays in "repairing".
22
25
  issueStatus: currentStatus === "repairing" ? "repairing" : "developing",
23
26
  currentOwner: "developer",
24
- currentIntent: `Developer retrying (infra attempt) — ${route.reason}`,
27
+ currentIntent: isCiRepair
28
+ ? `Developer retrying (CI repair attempt) — ${route.reason}`
29
+ : `Developer retrying (infra attempt) — ${route.reason}`,
25
30
  events: ["worker.failed"],
26
31
  },
27
32
  effect: { kind: "enqueue", workItem: "developer", retryReason: route.reason },
28
- advance: "infra",
33
+ advance: isCiRepair ? "ci" : "infra",
29
34
  };
35
+ }
30
36
  case "retry_publish":
31
37
  return {
32
38
  projection: {
@@ -119,6 +119,19 @@ test("infra-class retries — including a stale re-review — spend the infra bu
119
119
  const stale = projectReviewerRoute(routeReviewerOutcome({ kind: "stale", currentHeadSha: "new" }, REVIEW_ROUNDS_LEFT, PINNED_HEAD), 1, false);
120
120
  assert.equal(stale.advance, "infra");
121
121
  });
122
+ test("NOT-313: a checks_failed retry spends the CI budget, never the infra one", () => {
123
+ const ciRetry = projectDeveloperRoute(routeDeveloperOutcome({ kind: "checks_failed" }, REVIEW_ROUNDS_LEFT), "developing", 1);
124
+ assert.equal(ciRetry.advance, "ci");
125
+ assert.deepStrictEqual(ciRetry.effect, {
126
+ kind: "enqueue",
127
+ workItem: "developer",
128
+ retryReason: "CI repair attempt 1 of 3: Developer's PR checks failed.",
129
+ });
130
+ assert.match(ciRetry.projection.currentIntent, /CI repair attempt/);
131
+ // An infra-class retry keeps the exact pre-NOT-313 shape — no budget marker.
132
+ const infraRetry = projectDeveloperRoute(routeDeveloperOutcome({ kind: "session_failed" }, REVIEW_ROUNDS_LEFT), "developing", 1);
133
+ assert.equal(infraRetry.advance, "infra");
134
+ });
122
135
  test("NOT-147: empty-tip no-progress escalation projects to needs_human without spending infra budget", () => {
123
136
  const INFRA_ATTEMPTS_LEFT = {
124
137
  currentRound: 1,