agent-dealer 1.0.0 → 1.0.1

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 (27) hide show
  1. package/bundle/server/dist/adapters/managed-repo.js +11 -0
  2. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +55 -4
  3. package/bundle/server/dist/coordinator/auto-merge.js +72 -4
  4. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +45 -3
  5. package/bundle/server/dist/coordinator/commands.js +17 -11
  6. package/bundle/server/dist/coordinator/commands.test.js +21 -4
  7. package/bundle/server/dist/coordinator/human-resolution.js +5 -2
  8. package/bundle/server/dist/coordinator/human-resolution.test.js +13 -1
  9. package/bundle/server/dist/coordinator/prompts.js +21 -13
  10. package/bundle/server/dist/coordinator/prompts.test.js +21 -0
  11. package/bundle/server/dist/coordinator/reviewer-effect.js +56 -11
  12. package/bundle/server/dist/coordinator/reviewer-effect.test.js +67 -9
  13. package/bundle/server/dist/coordinator/reviewer-result.js +49 -1
  14. package/bundle/server/dist/coordinator/reviewer-result.test.js +38 -0
  15. package/bundle/server/dist/coordinator/routing.js +15 -4
  16. package/bundle/server/dist/coordinator/routing.test.js +17 -2
  17. package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
  18. package/bundle/server/dist/db/migrate-to-issues.test.js +1 -0
  19. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +3 -2
  20. package/bundle/server/dist/routes/human-actions.test.js +2 -0
  21. package/bundle/server/package.json +2 -2
  22. package/bundle/shared/package.json +1 -1
  23. package/dist/action.test.js +2 -2
  24. package/dist/install.js +1 -1
  25. package/dist/lifecycle.contract.test.js +2 -2
  26. package/dist/setup.js +5 -1
  27. package/package.json +1 -1
@@ -26,6 +26,17 @@ async function git(cwd, args) {
26
26
  export function managedRepoPath(identity) {
27
27
  return path.join(getExecutionRoot(), "repos", identity);
28
28
  }
29
+ /**
30
+ * Test helper: mark a managed identity as having a local clone so merge cwd resolution
31
+ * (and similar) does not fail closed. Does not create a real git repo.
32
+ */
33
+ export function stubManagedCloneForTests(repoInput) {
34
+ const classified = classifyIssueRepo(repoInput);
35
+ if (classified.kind === "managed") {
36
+ fs.mkdirSync(path.join(classified.repoPath, ".git"), { recursive: true });
37
+ }
38
+ return classified.repoPath;
39
+ }
29
40
  export function managedWorktreePath(identity, sessionId, role) {
30
41
  return path.join(getExecutionRoot(), "worktrees", identity, `${sessionId}-${role}`);
31
42
  }
@@ -1,9 +1,11 @@
1
1
  // NOT-102 acceptance: auto-merge on/off, merge failure escalation, recent repos.
2
+ // NOT-151: portable github.com/… issue.repo must resolve to managed clone cwd, not the identity.
2
3
  import { test, before, beforeEach, afterEach } from "node:test";
3
4
  import assert from "node:assert/strict";
4
5
  import fs from "node:fs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
8
+ import { execFileSync } from "node:child_process";
7
9
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-"));
8
10
  const { migrate, getDb } = await import("../db/index.js");
9
11
  const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
@@ -14,7 +16,23 @@ const { claimWorkItem, listWorkItemsForIssue } = await import("../repository/wor
14
16
  const { startWorkflow, applyCompletion } = await import("./commands.js");
15
17
  const { ReviewerResult } = await import("./reviewer-result.js");
16
18
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
17
- before(() => migrate());
19
+ const { managedRepoPath } = await import("../adapters/managed-repo.js");
20
+ /** Real local checkout so resolveAutoMergeCwd accepts the default legacy repo. */
21
+ let fixtureRepo = "";
22
+ function initFixtureRepo() {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-repo-"));
24
+ execFileSync("git", ["init", "-b", "main"], { cwd: dir });
25
+ execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
26
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
27
+ fs.writeFileSync(path.join(dir, "README.md"), "hi\n");
28
+ execFileSync("git", ["add", "."], { cwd: dir });
29
+ execFileSync("git", ["commit", "-m", "init"], { cwd: dir });
30
+ return dir;
31
+ }
32
+ before(() => {
33
+ migrate();
34
+ fixtureRepo = initFixtureRepo();
35
+ });
18
36
  beforeEach(() => {
19
37
  getDb().exec(`
20
38
  DELETE FROM work_items;
@@ -40,7 +58,7 @@ function newIssue(opts = {}) {
40
58
  title: "Coordinate me",
41
59
  description: "d",
42
60
  acceptanceCriteria: "It works",
43
- repo: opts.repo ?? "/repo/a",
61
+ repo: opts.repo ?? fixtureRepo,
44
62
  developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
45
63
  reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
46
64
  baseBranch: "main",
@@ -110,7 +128,7 @@ test("autoMerge off: final_review complete undrafts+merges then marks done", asy
110
128
  assert.equal(resolved.instanceCompleted, true);
111
129
  assert.equal(resolved.triggerReflect, true);
112
130
  }
113
- assert.deepEqual(calls, [{ cwd: "/repo/a", number: 42 }]);
131
+ assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
114
132
  assert.equal(getIssue(issueId).status, "done");
115
133
  assert.equal(getActiveWorkflowInstance(issueId), null);
116
134
  assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
@@ -153,9 +171,42 @@ test("autoMerge on: reviewer approve merges PR, marks done, skips final_review h
153
171
  assert.equal(issue.status, "done");
154
172
  assert.equal(getActiveWorkflowInstance(issueId), null);
155
173
  assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.actionType === "final_review").length, 0);
156
- assert.deepEqual(calls, [{ cwd: "/repo/a", number: 42 }]);
174
+ assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
157
175
  assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "issue.completed"));
158
176
  });
177
+ test("NOT-151: portable github.com repo merges via managed clone path, not identity string", async () => {
178
+ const identity = "github.com/not-so-fat/agent-dealer";
179
+ const managed = managedRepoPath(identity);
180
+ fs.mkdirSync(path.join(managed, ".git"), { recursive: true });
181
+ const calls = [];
182
+ setMergePrForTests(async (opts) => {
183
+ calls.push(opts);
184
+ return { ok: true };
185
+ });
186
+ const issueId = newIssue({ autoMerge: true, repo: identity });
187
+ startWorkflow(issueId);
188
+ await complete(issueId, cleanHandoff);
189
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
190
+ assert.equal(getIssue(issueId).status, "done");
191
+ assert.deepEqual(calls, [{ cwd: managed, number: 42 }]);
192
+ assert.notEqual(calls[0]?.cwd, identity);
193
+ });
194
+ test("NOT-151: missing managed clone escalates clearly without spawn gh ENOENT", async () => {
195
+ let mergeCalled = false;
196
+ setMergePrForTests(async () => {
197
+ mergeCalled = true;
198
+ return { ok: true };
199
+ });
200
+ const issueId = newIssue({ autoMerge: true, repo: "github.com/missing/no-clone" });
201
+ startWorkflow(issueId);
202
+ await complete(issueId, cleanHandoff);
203
+ await complete(issueId, { kind: "verdict", result: okReview("approved") });
204
+ assert.equal(mergeCalled, false);
205
+ const issue = getIssue(issueId);
206
+ assert.equal(issue.status, "needs_human");
207
+ assert.match(issue.currentIntent ?? "", /Managed clone missing/);
208
+ assert.doesNotMatch(issue.currentIntent ?? "", /ENOENT/);
209
+ });
159
210
  test("autoMerge on: merge failure escalates to policy_escalation; issue not left half-done as final_review", async () => {
160
211
  setMergePrForTests(async () => ({ ok: false, reason: "required status checks failed" }));
161
212
  const issueId = newIssue({ autoMerge: true });
@@ -15,8 +15,15 @@
15
15
  //
16
16
  // Concurrency: finalizeAutoMerge is single-flight per issueId (in-process). Success and
17
17
  // escalate txns are also defensive if a racer already wrote done / needs_human.
18
+ //
19
+ // NOT-151: issue.repo is a portable GitHub identity after NOT-149 — never pass it to
20
+ // execFile as cwd (Node reports that as misleading `spawn gh ENOENT`). Resolve via
21
+ // classifyIssueRepo → managed/legacy local path first.
18
22
  import { execFile } from "node:child_process";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
19
25
  import { promisify } from "node:util";
26
+ import { classifyIssueRepo } from "../adapters/managed-repo.js";
20
27
  import { getDb } from "../db/index.js";
21
28
  import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
22
29
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
@@ -27,17 +34,69 @@ export const GH_MERGE_TIMEOUT_MS = 20_000;
27
34
  /** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
28
35
  export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
29
36
  const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
37
+ /**
38
+ * Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
39
+ * `gh pr merge`. Missing managed clones fail closed with a clear reason — never hand the
40
+ * identity string to execFile.
41
+ */
42
+ export function resolveAutoMergeCwd(repoField) {
43
+ try {
44
+ const classified = classifyIssueRepo(repoField);
45
+ const cwd = classified.repoPath;
46
+ if (classified.kind === "managed") {
47
+ const hasGit = fs.existsSync(path.join(cwd, ".git")) || fs.existsSync(path.join(cwd, "HEAD"));
48
+ if (!hasGit) {
49
+ return {
50
+ ok: false,
51
+ reason: `Managed clone missing for ${classified.identity} (${cwd}). Re-run the developer step so Dealer can clone it, or restore the checkout under execution/repos.`,
52
+ };
53
+ }
54
+ }
55
+ return { ok: true, cwd };
56
+ }
57
+ catch (err) {
58
+ return {
59
+ ok: false,
60
+ reason: err instanceof Error ? err.message : String(err),
61
+ };
62
+ }
63
+ }
30
64
  /** True when Node killed the child for exceeding `timeout` (promisify(execFile)). */
31
65
  export function isGhTimeoutError(err) {
32
66
  const e = err;
33
67
  return Boolean(e.killed || e.signal === "SIGTERM");
34
68
  }
69
+ /** True when Node failed to spawn (missing binary *or* missing cwd — both surface ENOENT). */
70
+ export function isGhSpawnEnoent(err) {
71
+ const e = err;
72
+ if (e.code === "ENOENT")
73
+ return true;
74
+ const msg = (e.message ?? "").toLowerCase();
75
+ return msg.includes("spawn") && msg.includes("enoent");
76
+ }
77
+ /**
78
+ * Distinguish "cwd is not a real directory" from "gh missing on PATH" — both look like
79
+ * `spawn gh ENOENT` from Node. Prefer checking the cwd on disk over trusting the message.
80
+ */
81
+ export function ghSpawnEnoentReason(err, cwd) {
82
+ if (!isGhSpawnEnoent(err))
83
+ return null;
84
+ if (!fs.existsSync(cwd)) {
85
+ return `invalid merge cwd (${cwd}) — path does not exist (portable issue.repo must not be used as cwd)`;
86
+ }
87
+ return "gh not on PATH — install GitHub CLI (`gh`) and ensure the daemon can see it";
88
+ }
35
89
  /** Map an execFile failure to a stable reason string (timeout vs stderr/stdout). */
36
- export function ghErrorReason(err, fallback) {
90
+ export function ghErrorReason(err, fallback, cwd) {
37
91
  const e = err;
38
92
  if (isGhTimeoutError(err)) {
39
93
  return `gh timed out after ${GH_MERGE_TIMEOUT_MS}ms`;
40
94
  }
95
+ if (cwd) {
96
+ const spawnReason = ghSpawnEnoentReason(err, cwd);
97
+ if (spawnReason)
98
+ return spawnReason;
99
+ }
41
100
  return (e.stderr || e.stdout || e.message || fallback).trim() || fallback;
42
101
  }
43
102
  /** Production: mark draft ready (ignore if already), then squash-merge — async + timed. */
@@ -52,7 +111,12 @@ export const realMergePr = async ({ cwd, number }) => {
52
111
  catch (err) {
53
112
  // Timeout is a hang, not "already ready" — fail closed so we do not burn another 20s on merge.
54
113
  if (isGhTimeoutError(err)) {
55
- return { ok: false, reason: ghErrorReason(err, "gh pr ready failed") };
114
+ return { ok: false, reason: ghErrorReason(err, "gh pr ready failed", cwd) };
115
+ }
116
+ // Bad cwd / missing gh on the ready step would also fail merge — surface now.
117
+ const spawnReason = ghSpawnEnoentReason(err, cwd);
118
+ if (spawnReason) {
119
+ return { ok: false, reason: spawnReason };
56
120
  }
57
121
  // Already ready / not a draft — ignore; merge is the authority.
58
122
  }
@@ -65,7 +129,7 @@ export const realMergePr = async ({ cwd, number }) => {
65
129
  return { ok: true };
66
130
  }
67
131
  catch (err) {
68
- const reason = ghErrorReason(err, "gh pr merge failed");
132
+ const reason = ghErrorReason(err, "gh pr merge failed", cwd);
69
133
  // Crash between a successful merge and the done-transition: retry must not escalate.
70
134
  if (ALREADY_MERGED.test(reason))
71
135
  return { ok: true };
@@ -118,7 +182,11 @@ async function finalizeAutoMergeOnce(issueId) {
118
182
  if (issue.prNumber == null) {
119
183
  return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
120
184
  }
121
- const merge = await mergePrImpl({ cwd: issue.repo, number: issue.prNumber });
185
+ const resolvedCwd = resolveAutoMergeCwd(issue.repo);
186
+ if (!resolvedCwd.ok) {
187
+ return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
188
+ }
189
+ const merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
122
190
  if (!merge.ok) {
123
191
  return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
124
192
  }
@@ -1,7 +1,17 @@
1
- // Unit coverage for bounded gh timeout classification (NOT-102 review finding).
2
- import { test } from "node:test";
1
+ // Unit coverage for bounded gh timeout classification (NOT-102) and NOT-151 spawn ENOENT mapping.
2
+ import { test, before } from "node:test";
3
3
  import assert from "node:assert/strict";
4
- import { GH_MERGE_TIMEOUT_MS, ghErrorReason, isGhTimeoutError, } from "./auto-merge.js";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { execFileSync } from "node:child_process";
8
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not151-unit-"));
9
+ const { GH_MERGE_TIMEOUT_MS, ghErrorReason, ghSpawnEnoentReason, isGhTimeoutError, resolveAutoMergeCwd, } = await import("./auto-merge.js");
10
+ const { managedRepoPath } = await import("../adapters/managed-repo.js");
11
+ before(async () => {
12
+ const { migrate } = await import("../db/index.js");
13
+ migrate();
14
+ });
5
15
  test("isGhTimeoutError detects killed / SIGTERM from execFile timeout", () => {
6
16
  assert.equal(isGhTimeoutError({ killed: true }), true);
7
17
  assert.equal(isGhTimeoutError({ signal: "SIGTERM" }), true);
@@ -12,3 +22,35 @@ test("ghErrorReason maps timeout before stderr", () => {
12
22
  assert.equal(ghErrorReason({ stderr: " checks failed \n" }, "fallback"), "checks failed");
13
23
  assert.equal(ghErrorReason({}, "gh pr merge failed"), "gh pr merge failed");
14
24
  });
25
+ test("NOT-151: ghSpawnEnoentReason distinguishes bad cwd from missing gh", () => {
26
+ const missing = path.join(os.tmpdir(), "dealer-missing-merge-cwd-xyz");
27
+ assert.match(ghSpawnEnoentReason({ code: "ENOENT", message: "spawn gh ENOENT" }, missing) ?? "", /invalid merge cwd/);
28
+ const existing = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-merge-cwd-"));
29
+ assert.match(ghSpawnEnoentReason({ code: "ENOENT", message: "spawn gh ENOENT" }, existing) ?? "", /gh not on PATH/);
30
+ assert.equal(ghSpawnEnoentReason({ stderr: "checks failed" }, existing), null);
31
+ });
32
+ test("NOT-151: resolveAutoMergeCwd maps portable identity to managed path when clone exists", () => {
33
+ const identity = "github.com/not-so-fat/agent-dealer";
34
+ const managed = managedRepoPath(identity);
35
+ fs.mkdirSync(path.join(managed, ".git"), { recursive: true });
36
+ const ok = resolveAutoMergeCwd(identity);
37
+ assert.equal(ok.ok, true);
38
+ if (ok.ok)
39
+ assert.equal(ok.cwd, managed);
40
+ });
41
+ test("NOT-151: resolveAutoMergeCwd fails closed when managed clone is missing", () => {
42
+ const missing = resolveAutoMergeCwd("github.com/missing/not-cloned");
43
+ assert.equal(missing.ok, false);
44
+ if (!missing.ok) {
45
+ assert.match(missing.reason, /Managed clone missing/);
46
+ assert.doesNotMatch(missing.reason, /ENOENT/);
47
+ }
48
+ });
49
+ test("NOT-151: resolveAutoMergeCwd accepts a real legacy local checkout", () => {
50
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "legacy-merge-cwd-"));
51
+ execFileSync("git", ["init", "-b", "main"], { cwd: dir });
52
+ const ok = resolveAutoMergeCwd(dir);
53
+ assert.equal(ok.ok, true);
54
+ if (ok.ok)
55
+ assert.equal(ok.cwd, dir);
56
+ });
@@ -3,6 +3,7 @@ import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueI
3
3
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
4
4
  import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, resolveHumanAction, } from "../repository/human-actions.js";
5
5
  import { reconcileFinding } from "../repository/findings.js";
6
+ import { normalizeReviewerResult } from "./reviewer-result.js";
6
7
  import { getAgent } from "../repository/agents.js";
7
8
  import { githubIssuesSync } from "../adapters/agent-health.js";
8
9
  import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
@@ -499,12 +500,14 @@ function applyReviewer(issue, instance, item, outcome) {
499
500
  autoMerge: issue.autoMerge,
500
501
  }, issue.headSha);
501
502
  const hasVerdict = outcome.kind === "verdict";
503
+ // Normalize before emit/finding reconcile so remapped blocking findings (NOT-150) persist.
504
+ const verdictResult = outcome.kind === "verdict" ? normalizeReviewerResult(outcome.result) : null;
502
505
  const { projection, effect, advance } = projectReviewerRoute(route, issue.currentRound, hasVerdict);
503
506
  const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
504
507
  const patch = {};
505
508
  for (const type of projection.events) {
506
- if (type === "review.submitted" && outcome.kind === "verdict") {
507
- ev.emit("review.submitted", { actorType: "reviewer", payload: outcome.result });
509
+ if (type === "review.submitted" && verdictResult) {
510
+ ev.emit("review.submitted", { actorType: "reviewer", payload: verdictResult });
508
511
  }
509
512
  else if (type === "worker.completed" || type === "worker.failed") {
510
513
  const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
@@ -544,8 +547,8 @@ function applyReviewer(issue, instance, item, outcome) {
544
547
  patch.headSha = outcome.currentHeadSha;
545
548
  }
546
549
  // Thread reviewer findings across rounds (PRD §6.4) — every blocking/non-blocking finding.
547
- if (outcome.kind === "verdict") {
548
- for (const f of outcome.result.findings) {
550
+ if (verdictResult) {
551
+ for (const f of verdictResult.findings) {
549
552
  reconcileFinding({
550
553
  issueId: issue.id,
551
554
  fingerprint: f.fingerprint,
@@ -651,7 +654,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
651
654
  function questionFor(actionType, reason, resumeAsReviewer = false) {
652
655
  switch (actionType) {
653
656
  case "final_review":
654
- return "Accept and merge this work, send it back for another repair round, or close it?";
657
+ return "Merge this work, send it back for another repair round, or close it?";
655
658
  case "attempts_exhausted":
656
659
  return "The review-round limit is reached. Retry with a fresh round, or close the issue?";
657
660
  case "policy_escalation":
@@ -684,9 +687,9 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false) {
684
687
  switch (actionType) {
685
688
  case "final_review":
686
689
  return [
687
- { choice: "complete", label: "Accept — merge & mark done" },
690
+ { choice: "merge", label: "Merge" },
688
691
  { choice: "repair", label: "Another repair round" },
689
- { choice: "close", label: "Close without accepting" },
692
+ { choice: "close", label: "Close" },
690
693
  ];
691
694
  case "attempts_exhausted":
692
695
  return [
@@ -748,9 +751,11 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
748
751
  if (!issue)
749
752
  return { ok: false, code: 404, error: "Issue not found" };
750
753
  const instance = getActiveWorkflowInstance(action.issueId);
751
- // NOT-102: human accept must undraft+merge (never mark done while leaving a draft PR).
754
+ // NOT-102 / NOT-150: human Merge (or legacy "complete") must undraft+merge.
752
755
  // Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
753
- if (instance && resolution.actionType === "final_review" && resolution.choice === "complete") {
756
+ if (instance &&
757
+ resolution.actionType === "final_review" &&
758
+ (resolution.choice === "merge" || resolution.choice === "complete")) {
754
759
  return getDb().transaction(() => {
755
760
  resolveHumanAction(actionId, resolvedBy, { choice });
756
761
  appendWorkflowEvent({
@@ -761,7 +766,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
761
766
  actorRef: resolvedBy,
762
767
  stage: "final_review",
763
768
  round: issue.currentRound,
764
- payload: { actionType: "final_review", choice: "complete", pendingMerge: true },
769
+ payload: { actionType: "final_review", choice: resolution.choice, pendingMerge: true },
765
770
  });
766
771
  transitionIssue(issue.id, "final_review", {
767
772
  currentOwner: "system",
@@ -966,7 +971,8 @@ function resolveLegacyTerminalAction(action, issue, resolvedBy, resolution) {
966
971
  if (resolution.choice === "close") {
967
972
  nextStatus = "closed";
968
973
  }
969
- else if (resolution.actionType === "final_review" && resolution.choice === "complete") {
974
+ else if (resolution.actionType === "final_review" &&
975
+ (resolution.choice === "merge" || resolution.choice === "complete")) {
970
976
  nextStatus = "done";
971
977
  }
972
978
  else if (resolution.actionType === "final_review" && resolution.choice === "repair") {
@@ -17,11 +17,13 @@ const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHum
17
17
  const { ReviewerResult } = await import("./reviewer-result.js");
18
18
  const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
19
19
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
20
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
20
21
  before(() => migrate());
21
22
  beforeEach(() => {
22
23
  getDb().exec("DELETE FROM work_items");
23
24
  clearFinalizeInflightForTests();
24
25
  setMergePrForTests(async () => ({ ok: true }));
26
+ stubManagedCloneForTests("acme/app");
25
27
  });
26
28
  function newIssue(opts = {}) {
27
29
  return createIssue({
@@ -400,13 +402,16 @@ test("fail → retry → exhaust → resume → fail again does not collide with
400
402
  assert.equal(pending.length, 1, "a fresh item must be enqueued — the old (round, infraAttempts)-keyed row must not be silently reused");
401
403
  assert.notEqual(pending[0].id, firstRetryItem.id, "must be a NEW work item, not the pre-escalation retry's now-terminal row");
402
404
  });
403
- test("resolving policy_escalation:resume after a reviewer's escalated verdict (a real code-level question) still resumes as the developer", async () => {
405
+ test("resolving product_scope_decision:resume after a reviewer's escalated+question resumes as the developer", async () => {
404
406
  const issueId = newIssue();
405
407
  startWorkflow(issueId);
406
408
  await complete(issueId, cleanHandoff);
407
- await complete(issueId, { kind: "verdict", result: okReview("escalated") });
408
- const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
409
- assert.equal(action.continuationPreviewJson, null, "an escalated-verdict policy_escalation carries no reviewer-resume continuation");
409
+ await complete(issueId, {
410
+ kind: "verdict",
411
+ result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
412
+ });
413
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
414
+ assert.ok(action, "true product escalate opens product_scope_decision, not policy_escalation");
410
415
  const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
411
416
  assert.equal(resolved.ok, true);
412
417
  const issue = getIssue(issueId);
@@ -414,6 +419,18 @@ test("resolving policy_escalation:resume after a reviewer's escalated verdict (a
414
419
  const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
415
420
  assert.deepEqual(pending.map((i) => i.kind), ["developer"]);
416
421
  });
422
+ test("NOT-150: bare escalated verdict remaps to automatic repair, not policy_escalation", async () => {
423
+ const issueId = newIssue();
424
+ startWorkflow(issueId);
425
+ await complete(issueId, cleanHandoff);
426
+ await complete(issueId, { kind: "verdict", result: okReview("escalated") });
427
+ const issue = getIssue(issueId);
428
+ assert.equal(issue.status, "repairing");
429
+ assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
430
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer" && i.status === "pending").length, 1);
431
+ const findings = listFindingsForIssue(issueId);
432
+ assert.ok(findings.some((f) => f.severity === "blocking"), "bare escalate remap must thread a blocking finding into repair");
433
+ });
417
434
  test("a stale review re-queues a reviewer at the new head without consuming a round", async () => {
418
435
  const issueId = newIssue();
419
436
  startWorkflow(issueId);
@@ -8,7 +8,8 @@
8
8
  * reopen an issue or enqueue developer/reviewer work).
9
9
  */
10
10
  const VALID_CHOICES = {
11
- final_review: ["complete", "repair", "close"],
11
+ // "complete" kept as a synonym for "merge" so older open actions / CLI callers still resolve.
12
+ final_review: ["merge", "complete", "repair", "close"],
12
13
  attempts_exhausted: ["retry", "close"],
13
14
  policy_escalation: ["resume", "close"],
14
15
  product_scope_decision: ["resume"],
@@ -52,8 +53,10 @@ export function parseHumanResolution(actionType, choice) {
52
53
  export function resolveHumanActionOutcome(resolution) {
53
54
  switch (resolution.actionType) {
54
55
  case "final_review":
55
- if (resolution.choice === "complete")
56
+ // Merge (and legacy "complete") undraft+merge via commands.ts, then mark done.
57
+ if (resolution.choice === "merge" || resolution.choice === "complete") {
56
58
  return { issueStatus: "done", workflowOutcome: "done", triggerReflect: true };
59
+ }
57
60
  if (resolution.choice === "repair")
58
61
  return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
59
62
  if (resolution.choice === "close")
@@ -23,12 +23,24 @@ test("parseHumanResolution rejects reflection_interaction_required even though V
23
23
  test("resolveHumanActionOutcome throws rather than silently closing on an invalid choice reaching it directly", () => {
24
24
  assert.throws(() => resolveHumanActionOutcome({ actionType: "final_review", choice: "bogus" }), /Unrecognized final_review choice/);
25
25
  });
26
- test("final_review complete marks the issue done and triggers reflect", () => {
26
+ test("final_review merge marks the issue done and triggers reflect", () => {
27
+ const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "merge" });
28
+ assert.equal(result.issueStatus, "done");
29
+ assert.equal(result.workflowOutcome, "done");
30
+ assert.equal(result.triggerReflect, true);
31
+ });
32
+ test("final_review complete (legacy synonym) still marks done", () => {
27
33
  const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "complete" });
28
34
  assert.equal(result.issueStatus, "done");
29
35
  assert.equal(result.workflowOutcome, "done");
30
36
  assert.equal(result.triggerReflect, true);
31
37
  });
38
+ test("parseHumanResolution accepts merge for final_review", () => {
39
+ assert.deepStrictEqual(parseHumanResolution("final_review", "merge"), {
40
+ actionType: "final_review",
41
+ choice: "merge",
42
+ });
43
+ });
32
44
  test("final_review repair sends the issue back for another round without reflect", () => {
33
45
  const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "repair" });
34
46
  assert.equal(result.issueStatus, "repairing");
@@ -73,16 +73,18 @@ export function buildDeveloperPrompt(input) {
73
73
  }
74
74
  const REVIEWER_RESULT_SHAPE = '{"verdict":"approved"|"changes_requested"|"escalated","baseSha":"...","headSha":"...","acceptanceCriteriaAssessment":"...","evidenceAssessment":"...","findings":[{"fingerprint":"stable-slug","severity":"blocking"|"non_blocking","title":"...","rationale":"...","file":"...","line":0}],"risks":["..."],"productScopeQuestion":"..."}';
75
75
  function reviewerContractSection(baseSha, headSha) {
76
+ // Verdict table matches docs/PRD_ISSUE_COORDINATION.md §6.4 / design NOT-150 — do not drift.
76
77
  return [
77
78
  `## Required final JSON block`,
78
79
  `End your reply with exactly one fenced \`\`\`json block shaped like:`,
79
80
  REVIEWER_RESULT_SHAPE,
80
- `Rules:`,
81
+ `Rules (verdict contract):`,
81
82
  `- Set "baseSha" to exactly "${baseSha}" and "headSha" to exactly "${headSha}" — these are the coordinator-verified SHAs you were checked out at, not values you compute.`,
82
83
  `- "fingerprint" must be a short, stable slug for the finding (e.g. "missing-null-check-args-ts") so the same issue re-found next round is recognized as recurring, not duplicated.`,
83
84
  `- "findings" holds every blocking AND non-blocking observation; "risks" is uncertainties that are not findings tied to a location.`,
84
- `- Use "changes_requested" whenever any finding is "blocking". Use "approved" only when there are none.`,
85
- `- Use "escalated" only when the acceptance criteria themselves are ambiguous, contradictory, or the diff reveals a missing product decision not for ordinary code problems. Set "productScopeQuestion" to that question; omit it otherwise.`,
85
+ `- "approved": AC met for this tip and no finding is "blocking" (non_blocking nits allowed).`,
86
+ `- "changes_requested": any "blocking" finding a coding pass can address including incomplete review because AC-critical files were omitted/truncated from the diff. List omitted paths in a blocking finding.`,
87
+ `- "escalated": only when acceptance criteria / product scope are ambiguous, contradictory, or need a human product call — not ordinary code defects, not "diff too large". You MUST set non-empty "productScopeQuestion"; omit the field otherwise.`,
86
88
  `- You cannot edit files, push, or publish anything — you only return this JSON. The coordinator publishes it to GitHub on your behalf.`,
87
89
  ];
88
90
  }
@@ -91,37 +93,43 @@ function reviewerContractSection(baseSha, headSha) {
91
93
  * earlier, much smaller per-file cap still truncated mid-file on a genuinely large PR,
92
94
  * cutting off before the code under review). Whole files only — never a mid-hunk cut,
93
95
  * which would be actively misleading — so a file either fits completely or is entirely
94
- * omitted and counted in `truncated`. `runReviewerEffect` treats `truncated: true` as
95
- * grounds to override whatever verdict the reviewer reports: the reviewer cannot see the
96
- * full revision, so no verdict against it can be trusted, and this must be enforced in
97
- * code — a prompt instruction alone is not a structural guarantee (the same reasoning
98
- * `args.ts`/`permissions.ts` already apply to enforcement in general).
96
+ * omitted and listed in `omittedPaths`. Truncation policy (PRD §6.4 / NOT-150): coordinator
97
+ * remaps illegal escalate / approved+blocking; never blind escalate Resume|Close.
99
98
  */
100
99
  export const TOTAL_DIFF_LIMIT = 300_000;
100
+ function pathFromDiffGitHeader(headerLine) {
101
+ // `diff --git a/path b/path` — prefer the b/ side; fall back to a/.
102
+ const m = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/);
103
+ if (!m)
104
+ return null;
105
+ return m[2] || m[1] || null;
106
+ }
101
107
  export function formatDiffForPrompt(diff) {
102
108
  const trimmed = diff.trim();
103
109
  if (!trimmed)
104
- return { text: "(empty diff)", truncated: false };
110
+ return { text: "(empty diff)", truncated: false, omittedPaths: [] };
105
111
  const blocks = trimmed.split(/(?=^diff --git )/m).filter(Boolean);
106
112
  const manifest = blocks.map((b) => b.slice(0, b.indexOf("\n"))).join("\n");
107
113
  const pieces = [];
114
+ const omittedPaths = [];
108
115
  let total = 0;
109
- let omittedFiles = 0;
110
116
  for (const block of blocks) {
111
117
  if (total + block.length > TOTAL_DIFF_LIMIT) {
112
- omittedFiles++;
118
+ const header = block.slice(0, block.indexOf("\n"));
119
+ omittedPaths.push(pathFromDiffGitHeader(header) ?? (header || "unknown"));
113
120
  continue;
114
121
  }
115
122
  pieces.push(block);
116
123
  total += block.length;
117
124
  }
118
- const truncated = omittedFiles > 0;
125
+ const truncated = omittedPaths.length > 0;
119
126
  const footer = truncated
120
- ? `\n... [${omittedFiles} changed file(s) omitted — this diff exceeds ${TOTAL_DIFF_LIMIT} characters. You cannot examine the complete revision; the coordinator will not accept "approved" or "changes_requested" from this session regardless of what you report.]`
127
+ ? `\n... [${omittedPaths.length} changed file(s) omitted — diff exceeds ${TOTAL_DIFF_LIMIT} characters. Omitted: ${omittedPaths.join(", ")}. If any omitted path is AC-critical, verdict MUST be "changes_requested" with a blocking finding listing those paths. If AC is still certifiable from the visible tip with only non_blocking findings, "approved" is allowed. Do NOT use "escalated" for truncation escalate only with productScopeQuestion for a true product gap.]`
121
128
  : "";
122
129
  return {
123
130
  text: `Changed files (${blocks.length}):\n${manifest}\n\n${pieces.join("\n")}${footer}`,
124
131
  truncated,
132
+ omittedPaths,
125
133
  };
126
134
  }
127
135
  export function buildReviewerPrompt(input) {
@@ -145,6 +145,27 @@ test("reviewer prompt embeds the diff, echoes the exact SHAs to report, and forb
145
145
  assert.match(prompt, /"headSha" to exactly "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"/);
146
146
  assert.match(prompt, /You cannot edit files, push, or publish anything/);
147
147
  });
148
+ test("NOT-150: reviewer verdict rules match the design table (blocking ⇒ changes_requested; escalate needs productScopeQuestion)", () => {
149
+ const prompt = buildReviewerPrompt(reviewerBase);
150
+ assert.match(prompt, /"approved": AC met/);
151
+ assert.match(prompt, /no finding is "blocking"/);
152
+ assert.match(prompt, /"changes_requested": any "blocking" finding/);
153
+ assert.match(prompt, /"escalated": only when acceptance criteria/);
154
+ assert.match(prompt, /MUST set non-empty "productScopeQuestion"/);
155
+ assert.match(prompt, /not ordinary code defects/);
156
+ assert.match(prompt, /not "diff too large"/);
157
+ });
158
+ test("NOT-150: truncated-diff footer does not reject approved/changes_requested; forbids escalate-for-truncation", async () => {
159
+ const { formatDiffForPrompt, TOTAL_DIFF_LIMIT } = await import("./prompts.js");
160
+ const big = "x".repeat(TOTAL_DIFF_LIMIT + 1);
161
+ const diff = `diff --git a/small.ts b/small.ts\n+ok\n\ndiff --git a/huge.ts b/huge.ts\n+${big}\n`;
162
+ const formatted = formatDiffForPrompt(diff);
163
+ assert.equal(formatted.truncated, true);
164
+ assert.ok(formatted.omittedPaths.some((p) => p.includes("huge.ts")));
165
+ assert.match(formatted.text, /changes_requested/);
166
+ assert.match(formatted.text, /Do NOT use "escalated" for truncation/);
167
+ assert.doesNotMatch(formatted.text, /will not accept "approved" or "changes_requested"/);
168
+ });
148
169
  test("reviewer prompt includes the developer's conclusion, checks summary, and prior findings when given", () => {
149
170
  const prompt = buildReviewerPrompt({
150
171
  ...reviewerBase,
@@ -29,7 +29,7 @@ import { getTaskSnapshot } from "./commands.js";
29
29
  import { buildReviewerPrompt, formatDiffForPrompt, TOTAL_DIFF_LIMIT } from "./prompts.js";
30
30
  import { guidanceForNextSession } from "./guidance.js";
31
31
  import { realReviewerSpawn, reviewerSessionLogPath } from "./spawn.js";
32
- import { parseReviewerResult, ReviewerResult as ReviewerResultSchema } from "./reviewer-result.js";
32
+ import { parseReviewerResult, normalizeReviewerResult, INCOMPLETE_REVIEW_FINGERPRINT, ReviewerResult as ReviewerResultSchema, } from "./reviewer-result.js";
33
33
  import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fetchRef, diffShas, } from "../adapters/git-worktree.js";
34
34
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
35
35
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
@@ -108,6 +108,47 @@ async function bestEffortRemove(repo, worktreePath) {
108
108
  // leave it for crash-recovery inspection — cleanup is a courtesy, not part of the contract
109
109
  }
110
110
  }
111
+ /** Inject a stable blocking incomplete-review finding listing omitted paths (NOT-150). */
112
+ function withIncompleteReviewFinding(result, omittedPaths) {
113
+ if (result.findings.some((f) => f.fingerprint === INCOMPLETE_REVIEW_FINGERPRINT)) {
114
+ const { productScopeQuestion: _drop, ...rest } = result;
115
+ return { ...rest, verdict: "changes_requested" };
116
+ }
117
+ const paths = omittedPaths.length > 0 ? omittedPaths.join(", ") : "(unlisted omitted files)";
118
+ const { productScopeQuestion: _drop, ...rest } = result;
119
+ return {
120
+ ...rest,
121
+ verdict: "changes_requested",
122
+ findings: [
123
+ ...result.findings,
124
+ {
125
+ fingerprint: INCOMPLETE_REVIEW_FINGERPRINT,
126
+ severity: "blocking",
127
+ title: "Diff truncated — incomplete review",
128
+ rationale: `Reviewer prompt omitted path(s): ${paths}. Cannot fully certify acceptance criteria without them; shrink the change set or split the PR so the next review sees the full AC-critical surface.`,
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ /**
134
+ * Truncation remap (PRD §6.4 / design NOT-150): never blind escalate → Resume|Close.
135
+ * Shippable approved (no blocking) stays approved; otherwise ensure changes_requested with
136
+ * the incomplete-review finding listing omitted paths (in addition to any other blocking).
137
+ */
138
+ function applyTruncationVerdictPolicy(result, omittedPaths) {
139
+ const normalized = normalizeReviewerResult(result);
140
+ const hasProductQ = !!normalized.productScopeQuestion?.trim();
141
+ const hasBlocking = normalized.findings.some((f) => f.severity === "blocking");
142
+ if (normalized.verdict === "escalated" && hasProductQ) {
143
+ return normalized;
144
+ }
145
+ if (normalized.verdict === "approved" && !hasBlocking) {
146
+ return normalized;
147
+ }
148
+ // Truncated + not shippable-approved → changes_requested with incomplete-review (omitted paths).
149
+ const { productScopeQuestion: _drop, ...rest } = normalized;
150
+ return withIncompleteReviewFinding({ ...rest, verdict: "changes_requested" }, omittedPaths);
151
+ }
111
152
  function readImplementationConclusion(issueId) {
112
153
  const artifact = latestIssueArtifact(issueId, "implementation_conclusion");
113
154
  if (!artifact?.contentJson)
@@ -281,7 +322,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
281
322
  await fetchRef(worktreePath, baseBranch);
282
323
  const baseSha = await mergeBase({ repo: worktreePath, base: `origin/${baseBranch}`, head: headSha });
283
324
  const diff = await diffShas({ worktreePath, baseSha, headSha });
284
- const { truncated: diffTruncated } = formatDiffForPrompt(diff);
325
+ const { truncated: diffTruncated, omittedPaths } = formatDiffForPrompt(diff);
285
326
  const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
286
327
  const guidance = guidanceForNextSession(issue.id, sessionId);
287
328
  const prompt = buildReviewerPrompt({
@@ -402,20 +443,23 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
402
443
  return { kind: "session_failed" };
403
444
  }
404
445
  result = parsed;
405
- // A prompt instruction alone ("don't approve an incomplete diff") is not a structural
406
- // guarantee the same reasoning this codebase already applies to tool permissions
407
- // (args.ts/permissions.ts). If the diff had to be truncated, the reviewer's verdict is
408
- // overridden to "escalated" in code, regardless of what it actually reported, so a
409
- // truncated review can never reach final_review or an automatic repair loop.
410
- if (diffTruncated && result.verdict !== "escalated") {
446
+ // Truncation policy (PRD §6.4 / design NOT-150): never blind-escalate to Resume|Close.
447
+ // Persist evidence; remap bare escalate / approved+blocking; keep shippable approved.
448
+ if (diffTruncated) {
449
+ const reportedVerdict = result.verdict;
450
+ result = applyTruncationVerdictPolicy(result, omittedPaths);
411
451
  createIssueArtifact({
412
452
  issueId: issue.id,
413
453
  workerSessionId: sessionId,
414
454
  kind: "diff_truncated_evidence",
415
455
  author: "system",
416
- content: { reportedVerdict: result.verdict, overriddenTo: "escalated", diffCharLimit: TOTAL_DIFF_LIMIT },
456
+ content: {
457
+ reportedVerdict,
458
+ overriddenTo: result.verdict,
459
+ diffCharLimit: TOTAL_DIFF_LIMIT,
460
+ omittedPaths,
461
+ },
417
462
  });
418
- result = { ...result, verdict: "escalated" };
419
463
  }
420
464
  }
421
465
  catch {
@@ -456,7 +500,8 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
456
500
  // diff). A `row` with no recorded result (still `claimed` after every wait
457
501
  // attempt, or the winner itself failed) has nothing safe to report — escalate.
458
502
  if (claim.row?.state === "published" && claim.row.resultJson) {
459
- const winnerResult = ReviewerResultSchema.parse(JSON.parse(claim.row.resultJson));
503
+ const winnerParsed = ReviewerResultSchema.parse(JSON.parse(claim.row.resultJson));
504
+ const winnerResult = normalizeReviewerResult(winnerParsed);
460
505
  return { kind: "verdict", result: winnerResult };
461
506
  }
462
507
  return { kind: "publish_failed" };
@@ -447,7 +447,7 @@ test("publish claim: a prior claimant that recorded failure is safely reclaimed
447
447
  assert.equal(outcome.kind, "verdict", "reclaiming a failed prior claim must let publication proceed normally");
448
448
  assert.equal(github.publishCallCount(), 1);
449
449
  });
450
- test("diff truncated: an oversized diff forces the verdict to escalate in code, regardless of what the reviewer reports", async () => {
450
+ test("diff truncated: AC-met approved (no blocking) stays on the approve path never bare escalate", async () => {
451
451
  const issueId = await makeIssue();
452
452
  const github = fakeGithub();
453
453
  // A single file whose diff alone exceeds prompts.ts's TOTAL_DIFF_LIMIT (300,000 chars).
@@ -465,12 +465,70 @@ test("diff truncated: an oversized diff forces the verdict to escalate in code,
465
465
  registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
466
466
  await pump(1);
467
467
  const issue = getIssue(issueId);
468
- assert.equal(issue.status, "needs_human", "an oversized diff must never reach final_review, even though the reviewer reported approved");
469
- assert.ok(listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence"));
470
- // The override still publishes — as a COMMENT (escalated's mapped event), never as the
471
- // reviewer's actually-reported APPROVE.
468
+ assert.equal(issue.status, "final_review", "Fixture A: truncated + approved + no blocking final_review, not opaque escalate");
469
+ const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
470
+ assert.ok(truncateEvidence);
471
+ const truncateBody = JSON.parse(truncateEvidence.contentJson);
472
+ assert.equal(truncateBody.overriddenTo, "approved");
472
473
  const published = listArtifactsForIssue(issueId).find((a) => a.kind === "review_published");
473
- assert.equal(JSON.parse(published.contentJson).event, "COMMENT");
474
+ assert.equal(JSON.parse(published.contentJson).event, "APPROVE");
475
+ });
476
+ test("diff truncated: blocking incomplete-review finding remaps to changes_requested repair, not escalate", async () => {
477
+ const issueId = await makeIssue();
478
+ const github = fakeGithub();
479
+ const bigFileSpawn = async (input) => {
480
+ const lines = Array.from({ length: 20_000 }, (_, i) => `line ${i} of a very large generated file`);
481
+ fs.writeFileSync(path.join(input.cwd, "big.txt"), `${lines.join("\n")}\n`);
482
+ git(input.cwd, "add", ".");
483
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add a big file");
484
+ return { exitCode: 0, transcript: "Implementation conclusion: added a big file.", logPath: "/dev/null", timedOut: false };
485
+ };
486
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: bigFileSpawn, github }));
487
+ startWorkflow(issueId);
488
+ await pump(1);
489
+ const finding = {
490
+ fingerprint: "diff-omits-shared-schema-files",
491
+ severity: "blocking",
492
+ title: "Shared schema omitted",
493
+ rationale: "AC-critical shared schema files cannot be certified without seeing them.",
494
+ };
495
+ // Fixture B: model reported approved despite blocking (footer used to discourage changes_requested).
496
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, {
497
+ deckCallTool: okDeckCallTool,
498
+ spawn: verdictSpawn({ verdict: "approved", findings: [finding] }),
499
+ github,
500
+ }));
501
+ await pump(1);
502
+ const issue = getIssue(issueId);
503
+ assert.equal(issue.status, "repairing", "Fixture B: blocking incomplete review → automatic repair");
504
+ assert.equal(issue.currentRound, 2);
505
+ const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
506
+ assert.ok(truncateEvidence);
507
+ assert.equal(JSON.parse(truncateEvidence.contentJson).overriddenTo, "changes_requested");
508
+ assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
509
+ });
510
+ test("diff truncated: bare escalated remaps to changes_requested with incomplete-review finding", async () => {
511
+ const issueId = await makeIssue();
512
+ const github = fakeGithub();
513
+ const bigFileSpawn = async (input) => {
514
+ const lines = Array.from({ length: 20_000 }, (_, i) => `line ${i} of a very large generated file`);
515
+ fs.writeFileSync(path.join(input.cwd, "big.txt"), `${lines.join("\n")}\n`);
516
+ git(input.cwd, "add", ".");
517
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add a big file");
518
+ return { exitCode: 0, transcript: "Implementation conclusion: added a big file.", logPath: "/dev/null", timedOut: false };
519
+ };
520
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: bigFileSpawn, github }));
521
+ startWorkflow(issueId);
522
+ await pump(1);
523
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated" }), github }));
524
+ await pump(1);
525
+ const issue = getIssue(issueId);
526
+ assert.equal(issue.status, "repairing");
527
+ const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
528
+ assert.equal(JSON.parse(truncateEvidence.contentJson).overriddenTo, "changes_requested");
529
+ assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
530
+ const findings = listFindingsForIssue(issueId);
531
+ assert.ok(findings.some((f) => f.fingerprint === "diff-truncated-incomplete-review"), "truncated bare escalate must keep incomplete-review with omitted paths");
474
532
  });
475
533
  test("changes_requested: findings thread onto the issue and a fresh developer repair round is queued", async () => {
476
534
  const issueId = await makeIssue();
@@ -500,15 +558,15 @@ test("escalated with a product scope question opens product_scope_decision", asy
500
558
  assert.ok(action);
501
559
  assert.equal(action.reason, "Should this support X?");
502
560
  });
503
- test("escalated with no product scope question opens policy_escalation", async () => {
561
+ test("escalated with no product scope question remaps to automatic repair (NOT-150)", async () => {
504
562
  const issueId = await makeIssue();
505
563
  const github = fakeGithub();
506
564
  await advanceToReviewing(issueId, github);
507
565
  registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated" }), github }));
508
566
  await pump(1);
509
567
  const issue = getIssue(issueId);
510
- assert.equal(issue.status, "needs_human");
511
- assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
568
+ assert.equal(issue.status, "repairing");
569
+ assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
512
570
  });
513
571
  test("stale: a head that moved since the reviewer was queued is re-reviewed at the new head, not silently published against the old one", async () => {
514
572
  const issueId = await makeIssue();
@@ -19,6 +19,54 @@ export const ReviewerResult = z.object({
19
19
  /** Present only when verdict is "escalated" and the reviewer identifies a missing product call. */
20
20
  productScopeQuestion: z.string().optional(),
21
21
  });
22
+ /** Stable fingerprint for coordinator-injected incomplete-review findings (truncated diff). */
23
+ export const INCOMPLETE_REVIEW_FINGERPRINT = "diff-truncated-incomplete-review";
24
+ /** Stable fingerprint when bare escalate is remapped without a product question (NOT-150). */
25
+ export const BARE_ESCALATE_REMAP_FINGERPRINT = "escalated-without-product-scope-question";
26
+ /**
27
+ * Enforce PRD §6.4 / design NOT-150 invariants:
28
+ * - `escalated` requires a non-empty `productScopeQuestion` (else remap to `changes_requested`
29
+ * with a blocking finding — never empty-findings repair)
30
+ * - any `blocking` finding forbids `approved` (remap to `changes_requested`)
31
+ * - bare escalate is never a valid routing input (avoids Resume|Close `policy_escalation`)
32
+ */
33
+ export function normalizeReviewerResult(raw) {
34
+ const hasBlocking = raw.findings.some((f) => f.severity === "blocking");
35
+ const question = raw.productScopeQuestion?.trim() || undefined;
36
+ if (raw.verdict === "escalated") {
37
+ if (question) {
38
+ return { ...raw, productScopeQuestion: question };
39
+ }
40
+ const { productScopeQuestion: _drop, ...rest } = raw;
41
+ if (hasBlocking) {
42
+ return { ...rest, verdict: "changes_requested" };
43
+ }
44
+ return {
45
+ ...rest,
46
+ verdict: "changes_requested",
47
+ findings: [
48
+ ...raw.findings,
49
+ {
50
+ fingerprint: BARE_ESCALATE_REMAP_FINGERPRINT,
51
+ severity: "blocking",
52
+ title: "Escalated without a product scope question",
53
+ rationale: "Reviewer returned escalated without productScopeQuestion. Remapped to changes_requested so a coding repair can proceed; escalate only for true product ambiguity with a concrete question.",
54
+ },
55
+ ],
56
+ };
57
+ }
58
+ // Drop a stray productScopeQuestion on non-escalate verdicts.
59
+ const withoutQuestion = question
60
+ ? (() => {
61
+ const { productScopeQuestion: _drop, ...rest } = raw;
62
+ return rest;
63
+ })()
64
+ : raw;
65
+ if (withoutQuestion.verdict === "approved" && hasBlocking) {
66
+ return { ...withoutQuestion, verdict: "changes_requested" };
67
+ }
68
+ return withoutQuestion;
69
+ }
22
70
  /** Mirrors the plan-triage/reflect JSON-fence parsing pattern already used elsewhere. */
23
71
  export function parseReviewerResult(text) {
24
72
  const trimmed = text.trim();
@@ -28,7 +76,7 @@ export function parseReviewerResult(text) {
28
76
  try {
29
77
  const parsed = ReviewerResult.safeParse(JSON.parse(candidate));
30
78
  if (parsed.success)
31
- return parsed.data;
79
+ return normalizeReviewerResult(parsed.data);
32
80
  }
33
81
  catch {
34
82
  // try next candidate
@@ -35,11 +35,49 @@ test("accepts an escalated verdict carrying a productScopeQuestion", () => {
35
35
  const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(escalated)}\n\`\`\``);
36
36
  assert.deepEqual(result, escalated);
37
37
  });
38
+ test("NOT-150: escalated without productScopeQuestion remaps to changes_requested", () => {
39
+ const bare = { ...valid, verdict: "escalated" };
40
+ const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bare)}\n\`\`\``);
41
+ assert.equal(result?.verdict, "changes_requested");
42
+ assert.equal(result?.productScopeQuestion, undefined);
43
+ assert.ok(result?.findings.some((f) => f.severity === "blocking"));
44
+ assert.ok(result?.findings.some((f) => f.fingerprint === "escalated-without-product-scope-question"));
45
+ });
46
+ test("NOT-150: bare escalate with existing blocking findings keeps those findings", () => {
47
+ const bare = {
48
+ ...valid,
49
+ verdict: "escalated",
50
+ findings: [{ fingerprint: "real-bug", severity: "blocking", title: "Bug", rationale: "breaks" }],
51
+ };
52
+ const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bare)}\n\`\`\``);
53
+ assert.equal(result?.verdict, "changes_requested");
54
+ assert.equal(result?.findings.length, 1);
55
+ assert.equal(result?.findings[0]?.fingerprint, "real-bug");
56
+ });
57
+ test("NOT-150: approved with a blocking finding remaps to changes_requested", () => {
58
+ const bad = {
59
+ ...valid,
60
+ verdict: "approved",
61
+ findings: [{ fingerprint: "diff-omits-shared-schema-files", severity: "blocking", title: "Omitted", rationale: "AC-critical" }],
62
+ };
63
+ const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bad)}\n\`\`\``);
64
+ assert.equal(result?.verdict, "changes_requested");
65
+ assert.equal(result?.findings[0]?.fingerprint, "diff-omits-shared-schema-files");
66
+ });
38
67
  test("findings carry file/line and severity through unchanged", () => {
39
68
  const withFindings = {
40
69
  ...valid,
70
+ verdict: "changes_requested",
41
71
  findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "It breaks", file: "a.ts", line: 10 }],
42
72
  };
43
73
  const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(withFindings)}\n\`\`\``);
44
74
  assert.deepEqual(result, withFindings);
45
75
  });
76
+ test("non_blocking findings on approved pass through without remapping", () => {
77
+ const withNits = {
78
+ ...valid,
79
+ findings: [{ fingerprint: "nit", severity: "non_blocking", title: "Nit", rationale: "Style" }],
80
+ };
81
+ const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(withNits)}\n\`\`\``);
82
+ assert.deepEqual(result, withNits);
83
+ });
@@ -1,3 +1,5 @@
1
+ // packages/server/src/coordinator/routing.ts
2
+ import { normalizeReviewerResult } from "./reviewer-result.js";
1
3
  function roundsRemain(limits) {
2
4
  return limits.currentRound < limits.maxReviewRounds;
3
5
  }
@@ -145,7 +147,9 @@ export function routeReviewerOutcome(outcome, limits, pinnedHeadSha) {
145
147
  }
146
148
  }
147
149
  function routeVerdict(result, limits) {
148
- switch (result.verdict) {
150
+ // Defense in depth: same invariants as parseReviewerResult / PRD §6.4 (NOT-150).
151
+ const normalized = normalizeReviewerResult(result);
152
+ switch (normalized.verdict) {
149
153
  case "approved":
150
154
  return limits.autoMerge ? { next: "auto_merge" } : { next: "final_review" };
151
155
  case "changes_requested":
@@ -153,8 +157,15 @@ function routeVerdict(result, limits) {
153
157
  ? { next: "retry_developer_with_findings" }
154
158
  : { next: "human_action", actionType: "attempts_exhausted", reason: "Reviewer requested changes and the review-round limit is reached." };
155
159
  case "escalated":
156
- return result.productScopeQuestion
157
- ? { next: "human_action", actionType: "product_scope_decision", reason: result.productScopeQuestion }
158
- : { next: "human_action", actionType: "policy_escalation", reason: "Reviewer escalated without a resolvable code change." };
160
+ return normalized.productScopeQuestion
161
+ ? { next: "human_action", actionType: "product_scope_decision", reason: normalized.productScopeQuestion }
162
+ : // Illegal bare escalate treat as changes_requested so repair can run (NOT-150).
163
+ roundsRemain(limits)
164
+ ? { next: "retry_developer_with_findings" }
165
+ : {
166
+ next: "human_action",
167
+ actionType: "attempts_exhausted",
168
+ reason: "Reviewer escalated without a product scope question and the review-round limit is reached.",
169
+ };
159
170
  }
160
171
  }
@@ -253,13 +253,28 @@ test("escalated with a product scope question routes to product_scope_decision",
253
253
  const result = routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD);
254
254
  assert.equal(result.actionType, "product_scope_decision");
255
255
  });
256
- test("escalated without a product scope question routes to policy_escalation", () => {
256
+ test("escalated without a product scope question remaps to automatic repair (NOT-150)", () => {
257
257
  const outcome = {
258
258
  kind: "verdict",
259
259
  result: { verdict: "escalated", baseSha: "b", headSha: "h", acceptanceCriteriaAssessment: "unclear", evidenceAssessment: "ok", findings: [], risks: [] },
260
260
  };
261
261
  const result = routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD);
262
- assert.equal(result.actionType, "policy_escalation");
262
+ assert.deepStrictEqual(result, { next: "retry_developer_with_findings" });
263
+ });
264
+ test("approved with blocking finding remaps to changes_requested repair (NOT-150)", () => {
265
+ const outcome = {
266
+ kind: "verdict",
267
+ result: {
268
+ verdict: "approved",
269
+ baseSha: "b",
270
+ headSha: "h",
271
+ acceptanceCriteriaAssessment: "ok",
272
+ evidenceAssessment: "ok",
273
+ findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "breaks" }],
274
+ risks: [],
275
+ },
276
+ };
277
+ assert.deepStrictEqual(routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD), { next: "retry_developer_with_findings" });
263
278
  });
264
279
  test("stale review retries the reviewer at the freshly verified head while infra attempts remain", () => {
265
280
  const outcome = { kind: "stale", currentHeadSha: "new-head" };
@@ -25,6 +25,7 @@ const { runCoordinatorTick, drainCoordinator, activeAttemptCount, startCoordinat
25
25
  const { recoverCoordinator } = await import("./recovery.js");
26
26
  const { ReviewerResult } = await import("./reviewer-result.js");
27
27
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
28
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
28
29
  before(() => migrate());
29
30
  // claimWorkItem / recovery scan the whole table (one loop in production); start each
30
31
  // case from an empty queue so a prior test's un-processed item is never claimed here.
@@ -32,6 +33,7 @@ beforeEach(() => {
32
33
  getDb().exec("DELETE FROM work_items");
33
34
  clearFinalizeInflightForTests();
34
35
  setMergePrForTests(async () => ({ ok: true }));
36
+ stubManagedCloneForTests("acme/app");
35
37
  });
36
38
  afterEach(() => resetEffectHandlers());
37
39
  function newIssue(maxReviewRounds = 3) {
@@ -135,6 +135,7 @@ test("a legacy run left in 'review' seeds an open final_review human action", ()
135
135
  });
136
136
  test("a migrated final_review action is resolvable end to end through the real coordinator function, for every choice", () => {
137
137
  for (const [choice, expectedStatus] of [
138
+ ["merge", "done"],
138
139
  ["complete", "done"],
139
140
  ["repair", "needs_human"],
140
141
  ["close", "closed"],
@@ -304,8 +304,9 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
304
304
  const actions = cliJson(await runCli(["action", "list"]));
305
305
  const finalReviewAction = actions.find((a) => a.issueId === issueId && a.actionType === "final_review" && a.status === "open");
306
306
  assert.ok(finalReviewAction, "the open final_review human action must be listable via `action list`");
307
- assert.ok(finalReviewAction.choices.some((c) => c.choice === "complete"), "the CLI must expose the action's valid choices, including `complete`");
308
- const resolved = cliJson(await runCli(["action", "resolve", finalReviewAction.id, "--choice", "complete", "--by", "cli-agent"]));
307
+ assert.ok(finalReviewAction.choices.some((c) => c.choice === "merge" || c.choice === "complete"), "the CLI must expose the action's valid choices, including Merge");
308
+ const mergeChoice = finalReviewAction.choices.find((c) => c.choice === "merge")?.choice ?? "complete";
309
+ const resolved = cliJson(await runCli(["action", "resolve", finalReviewAction.id, "--choice", mergeChoice, "--by", "cli-agent"]));
309
310
  assert.equal(resolved.issueStatus, "done");
310
311
  // 8. Final state, including sessions/artifacts/usage/evidence, all via the CLI.
311
312
  const final = cliJson(await runCli(["issue", "show", issueId, "--include", "evidence"]));
@@ -17,6 +17,7 @@ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issu
17
17
  const { createRun, getRun, transitionRun, addArtifact, updateRunFields } = await import("../repository/runs.js");
18
18
  const { pendingSendCount, getPendingOutboundDraft } = await import("../repository/outbound-drafts.js");
19
19
  const { setMergePrForTests, clearFinalizeInflightForTests } = await import("../coordinator/auto-merge.js");
20
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
20
21
  before(() => {
21
22
  migrate();
22
23
  });
@@ -27,6 +28,7 @@ beforeEach(() => {
27
28
  clearFinalizeInflightForTests();
28
29
  // final_review:complete now undrafts+merges — never hit real `gh` from route tests.
29
30
  setMergePrForTests(async () => ({ ok: true }));
31
+ stubManagedCloneForTests("acme/app");
30
32
  });
31
33
  async function buildApp() {
32
34
  const app = Fastify();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.0.0",
25
+ "@agent-dealer/shared": "1.0.1",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/shared",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -12,7 +12,7 @@ test("action list calls GET /api/human-actions and surfaces parsed choices", asy
12
12
  reason: "done",
13
13
  question: "Accept?",
14
14
  evidenceJson: null,
15
- responseOptionsJson: JSON.stringify([{ choice: "complete", label: "Accept — merge & mark done" }]),
15
+ responseOptionsJson: JSON.stringify([{ choice: "merge", label: "Merge" }]),
16
16
  continuationPreviewJson: null,
17
17
  status: "open",
18
18
  resolutionJson: null,
@@ -31,7 +31,7 @@ test("action list calls GET /api/human-actions and surfaces parsed choices", asy
31
31
  assert.equal(code, 0);
32
32
  stub.assertCalled();
33
33
  const [action] = JSON.parse(printed);
34
- assert.deepEqual(action.choices, [{ choice: "complete", label: "Accept — merge & mark done" }]);
34
+ assert.deepEqual(action.choices, [{ choice: "merge", label: "Merge" }]);
35
35
  }
36
36
  finally {
37
37
  console.log = originalLog;
package/dist/install.js CHANGED
@@ -53,7 +53,7 @@ export async function runInstall(args, deps = {}) {
53
53
  if (migrateCli) {
54
54
  console.log("Prefer ~/.local/bin ahead of any npm global agent-dealer on PATH.");
55
55
  }
56
- console.log("Next: agent-dealer doctor && agent-dealer start --daemon --open");
56
+ console.log("Next: gh auth login -h github.com (if needed) && agent-dealer doctor && agent-dealer start --daemon --open");
57
57
  if (purgeGlobal) {
58
58
  const code = deps.purgeGlobal
59
59
  ? await deps.purgeGlobal()
@@ -38,12 +38,12 @@ test("agent-operated CLI contract: discover profiles/issues, start, inspect choi
38
38
  { choice: "close", label: "Close" },
39
39
  ]),
40
40
  openAction("00000000-0000-4000-a000-000000000024", "final_review", [
41
- { choice: "complete", label: "Accept — merge & mark done" },
41
+ { choice: "merge", label: "Merge" },
42
42
  { choice: "repair", label: "Request repair" },
43
43
  { choice: "close", label: "Close" },
44
44
  ]),
45
45
  ];
46
- const resolutions = ["resume", "resume", "retry", "complete"];
46
+ const resolutions = ["resume", "resume", "retry", "merge"];
47
47
  const expected = [
48
48
  {
49
49
  path: "/api/agents",
package/dist/setup.js CHANGED
@@ -18,7 +18,11 @@ export async function runSetup(options = {}) {
18
18
  fs.copyFileSync(template, envFile);
19
19
  console.log(`Created ${envFile}`);
20
20
  console.log(`Data directory: ${home}`);
21
- console.log("Next: agent-dealer start --daemon");
21
+ console.log("Next:");
22
+ console.log(" 1. brew install gh && gh auth login -h github.com");
23
+ console.log(" 2. Prefer ~/.local/bin on PATH (managed install); drop shadowed npm -g binaries");
24
+ console.log(" 3. agent-dealer doctor # GitHub must be green before kicking issues");
25
+ console.log(" 4. agent-dealer start --daemon");
22
26
  return 0;
23
27
  }
24
28
  export function printSetupHelp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dealer",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Human control plane for agent execution — queue, plan approval, audit",
5
5
  "license": "MIT",
6
6
  "type": "module",