agent-dealer 0.1.2 → 0.1.4

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 (40) hide show
  1. package/bundle/server/dist/adapters/agent-deck-deliver.test.js +52 -0
  2. package/bundle/server/dist/adapters/agent-deck.js +140 -0
  3. package/bundle/server/dist/queue/answers.test.js +117 -0
  4. package/bundle/server/dist/queue/approve-deliver.js +62 -0
  5. package/bundle/server/dist/queue/dispatcher.js +176 -2
  6. package/bundle/server/dist/queue/plan-gate.test.js +55 -0
  7. package/bundle/server/dist/queue/send-gate.test.js +108 -0
  8. package/bundle/server/dist/repository/outbound-drafts.js +62 -0
  9. package/bundle/server/dist/repository/runs.js +17 -1
  10. package/bundle/server/dist/routes/index.js +41 -17
  11. package/bundle/server/dist/runners/claude-args.js +24 -0
  12. package/bundle/server/dist/runners/claude.js +9 -18
  13. package/bundle/server/dist/runners/claude.test.js +58 -0
  14. package/bundle/server/dist/runners/persist.js +46 -7
  15. package/bundle/server/dist/runners/prompts.js +87 -1
  16. package/bundle/server/dist/runners/prompts.test.js +92 -0
  17. package/bundle/server/dist/runners/reflect.js +1 -1
  18. package/bundle/server/dist/runners/stream-json.js +51 -0
  19. package/bundle/server/dist/runners/stream-json.test.js +95 -0
  20. package/bundle/server/dist/usage-summary.js +9 -4
  21. package/bundle/server/package.json +3 -2
  22. package/bundle/server/static-ui/assets/index-Cc-h06U6.js +78 -0
  23. package/bundle/server/static-ui/assets/index-DKnME33I.css +1 -0
  24. package/bundle/server/static-ui/index.html +2 -2
  25. package/bundle/shared/dist/budget.d.ts +1 -1
  26. package/bundle/shared/dist/budget.js +3 -3
  27. package/bundle/shared/dist/index.d.ts +351 -129
  28. package/bundle/shared/dist/index.js +19 -0
  29. package/bundle/shared/dist/outbound-draft.d.ts +192 -0
  30. package/bundle/shared/dist/outbound-draft.js +46 -0
  31. package/bundle/shared/dist/outbound-draft.test.d.ts +1 -0
  32. package/bundle/shared/dist/outbound-draft.test.js +34 -0
  33. package/bundle/shared/dist/plan-triage.d.ts +317 -0
  34. package/bundle/shared/dist/plan-triage.js +65 -0
  35. package/bundle/shared/dist/plan-triage.test.d.ts +1 -0
  36. package/bundle/shared/dist/plan-triage.test.js +66 -0
  37. package/bundle/shared/package.json +1 -1
  38. package/package.json +3 -2
  39. package/bundle/server/static-ui/assets/index-ChdPKYDd.css +0 -1
  40. package/bundle/server/static-ui/assets/index-hIzZ8TM8.js +0 -77
@@ -0,0 +1,52 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { assertCallServiceToolSuccess, toCallServiceToolPayload } from "./agent-deck.js";
4
+ test("toCallServiceToolPayload maps serviceName to serviceId verbatim args", () => {
5
+ const toolCall = {
6
+ serviceName: "34eb6c24-f151-4da2-8db8-d6996aa296be",
7
+ toolName: "chat_postMessage",
8
+ arguments: { channel: "C123", text: "Hello world" },
9
+ };
10
+ const payload = toCallServiceToolPayload(toolCall);
11
+ assert.equal(payload.serviceId, toolCall.serviceName);
12
+ assert.equal(payload.toolName, toolCall.toolName);
13
+ assert.deepEqual(payload.arguments, toolCall.arguments);
14
+ });
15
+ test("assertCallServiceToolSuccess throws on deck proxy failure JSON", () => {
16
+ assert.throws(() => assertCallServiceToolSuccess({
17
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: "Service not found" }) }],
18
+ }), /Service not found/);
19
+ });
20
+ test("assertCallServiceToolSuccess throws on nested Slack channel_not_found", () => {
21
+ const receipt = {
22
+ content: [
23
+ {
24
+ type: "text",
25
+ text: JSON.stringify({
26
+ content: [{ type: "text", text: "channel_not_found\n\nThe channel could not be found." }],
27
+ isError: true,
28
+ }),
29
+ },
30
+ ],
31
+ };
32
+ assert.throws(() => assertCallServiceToolSuccess(receipt), /channel_not_found/);
33
+ });
34
+ test("assertCallServiceToolSuccess throws on deck-wrapped Slack JSON (live MCP shape)", () => {
35
+ const receipt = {
36
+ content: [
37
+ {
38
+ type: "text",
39
+ text: JSON.stringify({
40
+ content: [
41
+ {
42
+ type: "text",
43
+ text: "channel_not_found\n\nThe channel could not be found. It may not exist, may be in a different workspace than your app is installed on, or your app may lack permission to access it.",
44
+ },
45
+ ],
46
+ isError: true,
47
+ }),
48
+ },
49
+ ],
50
+ };
51
+ assert.throws(() => assertCallServiceToolSuccess(receipt), /channel_not_found/);
52
+ });
@@ -1,5 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
5
  import { getAgentDeckConfig } from "../repository/intake-settings.js";
4
6
  export function getAgentDeckApiUrl() {
5
7
  if (process.env.AGENT_DECK_API_URL) {
@@ -134,3 +136,141 @@ export function isAgentDeckMcpRegistered() {
134
136
  return false;
135
137
  }
136
138
  }
139
+ /** Map PRD toolCall to deck MCP call_service_tool args (serviceName → serviceId). */
140
+ export function toCallServiceToolPayload(toolCall) {
141
+ return {
142
+ serviceId: toolCall.serviceName,
143
+ toolName: toolCall.toolName,
144
+ arguments: toolCall.arguments,
145
+ };
146
+ }
147
+ function extractPermalink(toolResult) {
148
+ if (!toolResult || typeof toolResult !== "object")
149
+ return undefined;
150
+ const content = toolResult.content;
151
+ if (!Array.isArray(content))
152
+ return undefined;
153
+ for (const block of content) {
154
+ if (block.type !== "text" || !block.text)
155
+ continue;
156
+ try {
157
+ const parsed = JSON.parse(block.text);
158
+ if (parsed.permalink)
159
+ return parsed.permalink;
160
+ }
161
+ catch {
162
+ const m = block.text.match(/https?:\/\/\S+/);
163
+ if (m)
164
+ return m[0];
165
+ }
166
+ }
167
+ return undefined;
168
+ }
169
+ /** Parse Agent Deck call_service_tool MCP result — throws on proxy- or Slack-reported failure. */
170
+ export function assertCallServiceToolSuccess(toolResult) {
171
+ const err = findCallServiceToolError(toolResult);
172
+ if (err)
173
+ throw new Error(err);
174
+ }
175
+ function findCallServiceToolError(toolResult, depth = 0) {
176
+ if (!toolResult || typeof toolResult !== "object" || depth > 4)
177
+ return undefined;
178
+ const tr = toolResult;
179
+ if (tr.isError) {
180
+ const text = formatToolResultText(tr) ?? "call_service_tool failed";
181
+ const nested = parseNestedCallServiceToolError(text, depth);
182
+ if (nested)
183
+ return nested;
184
+ return summarizeToolErrorText(text) ?? text.slice(0, 500);
185
+ }
186
+ if (tr.success === false) {
187
+ return tr.error ?? "call_service_tool failed";
188
+ }
189
+ const text = formatToolResultText(tr);
190
+ if (text) {
191
+ const nested = parseNestedCallServiceToolError(text, depth);
192
+ if (nested)
193
+ return nested;
194
+ const plain = summarizeToolErrorText(text);
195
+ if (plain)
196
+ return plain;
197
+ }
198
+ return undefined;
199
+ }
200
+ function parseNestedCallServiceToolError(text, depth) {
201
+ try {
202
+ const parsed = JSON.parse(text);
203
+ return findCallServiceToolError(parsed, depth + 1);
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
209
+ const SLACK_ERROR_RE = /channel_not_found|not_in_channel|missing_scope|invalid_auth|user_not_found|cannot_dm|is_archived/i;
210
+ function summarizeToolErrorText(text) {
211
+ const t = text.trim();
212
+ if (!t)
213
+ return undefined;
214
+ try {
215
+ const parsed = JSON.parse(t);
216
+ if (typeof parsed.error === "string" && parsed.error.trim())
217
+ return parsed.error.trim();
218
+ if (typeof parsed.message === "string" && parsed.message.trim())
219
+ return parsed.message.trim();
220
+ }
221
+ catch {
222
+ // not top-level JSON — fall through
223
+ }
224
+ if (SLACK_ERROR_RE.test(t)) {
225
+ const line = t.split("\n").find((l) => SLACK_ERROR_RE.test(l));
226
+ if (line?.trim())
227
+ return line.trim().slice(0, 500);
228
+ const match = t.match(SLACK_ERROR_RE);
229
+ if (match)
230
+ return match[0];
231
+ }
232
+ if (/"success"\s*:\s*false/i.test(t)) {
233
+ try {
234
+ const parsed = JSON.parse(t);
235
+ if (typeof parsed.error === "string" && parsed.error.trim())
236
+ return parsed.error.trim();
237
+ }
238
+ catch {
239
+ // ignore
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+ function formatToolResultText(tr) {
245
+ if (!Array.isArray(tr.content))
246
+ return undefined;
247
+ return tr.content
248
+ .filter((b) => b.type === "text" && b.text)
249
+ .map((b) => b.text)
250
+ .join("\n")
251
+ .trim() || undefined;
252
+ }
253
+ export async function deliverOutboundDraft(deckId, toolCall, opts) {
254
+ const payload = toCallServiceToolPayload(toolCall);
255
+ const workspaceRoot = opts?.workspaceRoot ?? process.cwd();
256
+ const callTool = opts?.callTool ??
257
+ (async (p) => {
258
+ const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
259
+ const transport = new StreamableHTTPClientTransport(new URL(`${mcpBase}/mcp`));
260
+ const client = new Client({ name: "agent-dealer-deliver", version: "0.0.1" });
261
+ await client.connect(transport);
262
+ try {
263
+ await client.callTool({
264
+ name: "bind_workspace",
265
+ arguments: { deckId, workspaceRoot },
266
+ });
267
+ return await client.callTool({ name: "call_service_tool", arguments: p });
268
+ }
269
+ finally {
270
+ await client.close();
271
+ }
272
+ });
273
+ const toolResult = await callTool(payload);
274
+ assertCallServiceToolSuccess(toolResult);
275
+ return { toolResult, permalink: extractPermalink(toolResult) };
276
+ }
@@ -0,0 +1,117 @@
1
+ import { test, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-answers-"));
7
+ process.env.MAX_CONCURRENT_RUNS = "0";
8
+ const { migrate } = await import("../db/index.js");
9
+ const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
10
+ const { updateAgent } = await import("../repository/agents.js");
11
+ const { addArtifact, createRun, getLatestArtifact, getRun, markPlanTriageConsumed, updateRunFields } = await import("../repository/runs.js");
12
+ const { submitPlanAnswers } = await import("./dispatcher.js");
13
+ const QUESTIONS = [
14
+ { id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
15
+ { id: "q2", question: "Ship docs too?", options: [{ label: "Yes" }, { label: "No" }] },
16
+ ];
17
+ before(() => {
18
+ migrate();
19
+ updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
20
+ });
21
+ function seedQuestionRun() {
22
+ const run = createRun({
23
+ title: "Answers test",
24
+ taskCategory: "other",
25
+ status: "plan_pending",
26
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
27
+ });
28
+ addArtifact(run.id, "draft_plan", { markdown: "# Plan", sessionId: "sess-1" }, "agent");
29
+ addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, sessionId: "sess-1", parseFallback: false, consumed: false }, "agent");
30
+ return getRun(run.id);
31
+ }
32
+ test("all-structured answers approve and mark plan_answers approved", () => {
33
+ const run = seedQuestionRun();
34
+ const res = submitPlanAnswers(run.id, {
35
+ answers: [
36
+ { questionId: "q1", selectedLabel: "A" },
37
+ { questionId: "q2", selectedLabel: "No" },
38
+ ],
39
+ });
40
+ assert.equal(res.ok, true);
41
+ assert.equal(res.ok && res.outcome, "approved");
42
+ assert.equal(getRun(run.id).status, "plan_approved");
43
+ const ans = getLatestArtifact(run.id, "plan_answers");
44
+ assert.match(ans.contentJson, /"outcome":"approved"/);
45
+ assert.equal(getLatestArtifact(run.id, "approved_plan").author, "human");
46
+ const triage = getLatestArtifact(run.id, "plan_triage");
47
+ assert.match(triage.contentJson, /"consumed":true/);
48
+ });
49
+ test("executeModel null does not wipe seeded execute_model", () => {
50
+ const run = seedQuestionRun();
51
+ updateRunFields(run.id, { execute_model: "claude-sonnet-4" });
52
+ const res = submitPlanAnswers(run.id, {
53
+ answers: [
54
+ { questionId: "q1", selectedLabel: "A" },
55
+ { questionId: "q2", selectedLabel: "No" },
56
+ ],
57
+ executeModel: null,
58
+ });
59
+ assert.equal(res.ok, true);
60
+ assert.equal(getRun(run.id).executeModel, "claude-sonnet-4");
61
+ });
62
+ test("free-form answer schedules a redraft instead of approving", () => {
63
+ const run = seedQuestionRun();
64
+ let redrafted = false;
65
+ const res = submitPlanAnswers(run.id, {
66
+ answers: [
67
+ { questionId: "q1", freeText: "Do it a third way" },
68
+ { questionId: "q2", selectedLabel: "Yes" },
69
+ ],
70
+ }, { onRedraft: () => { redrafted = true; } });
71
+ assert.equal(res.ok && res.outcome, "redraft");
72
+ assert.equal(redrafted, true);
73
+ assert.equal(getRun(run.id).status, "plan_pending");
74
+ });
75
+ test("answers must cover every open question exactly once", () => {
76
+ const run = seedQuestionRun();
77
+ const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
78
+ assert.equal(res.ok, false);
79
+ assert.equal(!res.ok && res.code, 400);
80
+ });
81
+ test("double submit returns 409", () => {
82
+ const run = seedQuestionRun();
83
+ submitPlanAnswers(run.id, {
84
+ answers: [
85
+ { questionId: "q1", selectedLabel: "A" },
86
+ { questionId: "q2", selectedLabel: "Yes" },
87
+ ],
88
+ });
89
+ const again = submitPlanAnswers(run.id, {
90
+ answers: [
91
+ { questionId: "q1", selectedLabel: "B" },
92
+ { questionId: "q2", selectedLabel: "No" },
93
+ ],
94
+ });
95
+ assert.equal(!again.ok && again.code, 409);
96
+ });
97
+ test("run without open questions returns 409", () => {
98
+ const run = createRun({
99
+ title: "No questions",
100
+ taskCategory: "other",
101
+ status: "plan_pending",
102
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
103
+ });
104
+ const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
105
+ assert.equal(!res.ok && res.code, 409);
106
+ });
107
+ test("consumed triage rejects answers", () => {
108
+ const run = seedQuestionRun();
109
+ markPlanTriageConsumed(run.id);
110
+ const res = submitPlanAnswers(run.id, {
111
+ answers: [
112
+ { questionId: "q1", selectedLabel: "A" },
113
+ { questionId: "q2", selectedLabel: "Yes" },
114
+ ],
115
+ });
116
+ assert.equal(!res.ok && res.code, 409);
117
+ });
@@ -0,0 +1,62 @@
1
+ import { deliverOutboundDraft } from "../adapters/agent-deck.js";
2
+ import { syncLinearForRun } from "../adapters/linear-sync.js";
3
+ import { workspaceForRun } from "../runners/prompts.js";
4
+ import { addArtifact, appendEvent, getRun, transitionRun, } from "../repository/runs.js";
5
+ import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, } from "../repository/outbound-drafts.js";
6
+ import { scheduleReflect } from "./dispatcher.js";
7
+ export async function approveRunWithDeliver(runId, deps) {
8
+ const run = getRun(runId);
9
+ if (!run)
10
+ return { ok: false, code: 404, error: "Not found" };
11
+ if (run.status !== "review") {
12
+ return { ok: false, code: 400, error: "Run must be in review" };
13
+ }
14
+ if (deliverInFlight.has(runId)) {
15
+ return { ok: false, code: 409, error: "Deliver already in progress" };
16
+ }
17
+ const pending = getPendingOutboundDraft(runId);
18
+ if (!pending) {
19
+ const updated = transitionRun(runId, "done");
20
+ syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
21
+ scheduleReflect(updated, { trigger: "approve" });
22
+ return { ok: true, run: updated, delivered: false };
23
+ }
24
+ if (!run.deckId) {
25
+ return { ok: false, code: 502, error: "No deck bound — cannot deliver outbound draft" };
26
+ }
27
+ const deliver = deps?.deliver ?? deliverOutboundDraft;
28
+ deliverInFlight.add(runId);
29
+ try {
30
+ let toolResult;
31
+ let permalink;
32
+ try {
33
+ const result = await deliver(run.deckId, pending.content.draft.toolCall, {
34
+ workspaceRoot: workspaceForRun(run),
35
+ });
36
+ toolResult = result.toolResult;
37
+ permalink = result.permalink;
38
+ }
39
+ catch (err) {
40
+ const message = err instanceof Error ? err.message : String(err);
41
+ appendEvent(runId, "deliver_failed", { error: message });
42
+ return { ok: false, code: 502, error: message };
43
+ }
44
+ const sent = markOutboundDraftSent(pending.artifact.id);
45
+ if (!sent) {
46
+ return { ok: false, code: 409, error: "Draft already sent or rejected" };
47
+ }
48
+ addArtifact(runId, "send_receipt", {
49
+ draftArtifactId: pending.artifact.id,
50
+ sentAt: new Date().toISOString(),
51
+ toolResult: toolResult,
52
+ permalink,
53
+ }, "system");
54
+ const updated = transitionRun(runId, "done");
55
+ syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
56
+ scheduleReflect(updated, { trigger: "approve" });
57
+ return { ok: true, run: updated, delivered: true };
58
+ }
59
+ finally {
60
+ deliverInFlight.delete(runId);
61
+ }
62
+ }
@@ -1,9 +1,12 @@
1
- import { addArtifact, countByStatus, getLatestArtifact, getRun, listRuns, transitionRun, } from "../repository/runs.js";
1
+ import { planGateDecision } from "@agent-dealer/shared";
2
+ import { buildPlanRevisePrompt } from "../runners/prompts.js";
3
+ import { addArtifact, countByStatus, getLatestArtifact, getRun, listArtifacts, listRuns, markPlanTriageConsumed, patchRunPhaseBudget, transitionRun, updateRunFields, } from "../repository/runs.js";
2
4
  import { runAgent } from "../runners/claude.js";
3
5
  import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.js";
4
6
  import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
5
7
  import { pollLinearIssues } from "../adapters/external.js";
6
8
  import { syncLinearForRun } from "../adapters/linear-sync.js";
9
+ import { pendingSendCount } from "../repository/outbound-drafts.js";
7
10
  import { listAgentsWithHealth } from "../adapters/agent-health.js";
8
11
  import { listAgents } from "../repository/agents.js";
9
12
  import { runReflect } from "../runners/reflect.js";
@@ -42,6 +45,24 @@ function isReadyForPlanReview(run) {
42
45
  !!getLatestArtifact(run.id, "draft_plan") &&
43
46
  !activePlanDrafts.has(run.id));
44
47
  }
48
+ /** Open (unanswered, unconsumed) question count for a plan_pending run. */
49
+ function openQuestionCount(runId) {
50
+ const tri = getLatestArtifact(runId, "plan_triage");
51
+ if (!tri?.contentJson)
52
+ return 0;
53
+ try {
54
+ const c = JSON.parse(tri.contentJson);
55
+ if (c.consumed || !c.questions?.length)
56
+ return 0;
57
+ const ans = getLatestArtifact(runId, "plan_answers");
58
+ if (ans && ans.createdAt > tri.createdAt)
59
+ return 0;
60
+ return c.questions.length;
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ }
45
66
  export function getSnapshot() {
46
67
  const all = listRuns();
47
68
  const runningRuns = all.filter((r) => r.status === "running");
@@ -57,6 +78,25 @@ export function getSnapshot() {
57
78
  const awaitingPlanReview = planningPhase.filter(isReadyForPlanReview);
58
79
  const planningActiveRuns = planningPhase.filter((r) => activePlanDrafts.has(r.id));
59
80
  const planningQueuedRuns = planningPhase.filter((r) => !activePlanDrafts.has(r.id) && !isReadyForPlanReview(r));
81
+ const openQuestionCounts = {};
82
+ for (const run of awaitingPlanReview) {
83
+ const n = openQuestionCount(run.id);
84
+ if (n > 0)
85
+ openQuestionCounts[run.id] = n;
86
+ }
87
+ const awaitingAnswerRuns = awaitingPlanReview.filter((r) => openQuestionCounts[r.id]);
88
+ const pendingSendCounts = {};
89
+ for (const run of resultReviewRuns) {
90
+ const n = pendingSendCount(run.id);
91
+ if (n > 0)
92
+ pendingSendCounts[run.id] = n;
93
+ }
94
+ const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
95
+ .filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
96
+ .map((r) => r.id);
97
+ const sentRunIds = recentDone
98
+ .filter((r) => getLatestArtifact(r.id, "send_receipt"))
99
+ .map((r) => r.id);
60
100
  return {
61
101
  planReviewCount: awaitingPlanReview.length,
62
102
  resultReviewCount: resultReviewRuns.length,
@@ -68,6 +108,11 @@ export function getSnapshot() {
68
108
  resultReviewRuns,
69
109
  recentDone,
70
110
  awaitingPlanReview,
111
+ awaitingAnswerRuns,
112
+ openQuestionCounts,
113
+ pendingSendCounts,
114
+ sentRunIds,
115
+ autoApprovedRunIds,
71
116
  runs: all,
72
117
  agentDeckOnline: false,
73
118
  agents: [],
@@ -195,6 +240,49 @@ export async function kickRun(run) {
195
240
  }
196
241
  await dispatch();
197
242
  }
243
+ function priorQuestionRounds(runId, currentTriageArtifactId) {
244
+ return listArtifacts(runId).filter((a) => {
245
+ if (a.kind !== "plan_triage" || a.id === currentTriageArtifactId || !a.contentJson)
246
+ return false;
247
+ try {
248
+ const c = JSON.parse(a.contentJson);
249
+ return (c.questions?.length ?? 0) > 0;
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ }).length;
255
+ }
256
+ /** Self-triage gate after a plan draft persists (PRD F2). Exported for tests. */
257
+ export function applyPlanGate(run) {
258
+ const art = getLatestArtifact(run.id, "plan_triage");
259
+ if (!art?.contentJson)
260
+ return "await_review";
261
+ let triage;
262
+ try {
263
+ triage = JSON.parse(art.contentJson);
264
+ }
265
+ catch {
266
+ return "await_review";
267
+ }
268
+ const decision = planGateDecision({
269
+ triage,
270
+ priorQuestionRounds: priorQuestionRounds(run.id, art.id),
271
+ });
272
+ if (decision !== "auto_approve")
273
+ return decision;
274
+ const fresh = getRun(run.id);
275
+ if (!fresh || fresh.status !== "plan_pending")
276
+ return "await_review";
277
+ const draft = getLatestArtifact(run.id, "draft_plan");
278
+ if (!draft?.contentJson)
279
+ return "await_review";
280
+ const plan = JSON.parse(draft.contentJson);
281
+ addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
282
+ transitionRun(run.id, "plan_approved");
283
+ void forceDispatch();
284
+ return "auto_approve";
285
+ }
198
286
  export async function draftPlan(run, opts) {
199
287
  if (run.status === "queued") {
200
288
  transitionRun(run.id, "plan_pending");
@@ -208,7 +296,7 @@ export async function draftPlan(run, opts) {
208
296
  }
209
297
  const rt = runtimeFor(updated);
210
298
  try {
211
- const result = await runAgent(updated, "plan");
299
+ const result = await runAgent(updated, "plan", opts?.revise);
212
300
  const persisted = persistRunOutput({
213
301
  run: updated,
214
302
  phase: "plan",
@@ -225,6 +313,18 @@ export async function draftPlan(run, opts) {
225
313
  }
226
314
  if (persisted.planMarkdown) {
227
315
  addArtifact(updated.id, "draft_plan", { markdown: persisted.planMarkdown, sessionId: persisted.sessionId }, "agent", result.logPath);
316
+ const triage = persisted.planTriage;
317
+ if (triage) {
318
+ addArtifact(updated.id, "plan_triage", {
319
+ verdict: triage.verdict,
320
+ rationale: triage.rationale,
321
+ questions: triage.questions,
322
+ sessionId: persisted.sessionId,
323
+ parseFallback: triage.parseFallback,
324
+ consumed: false,
325
+ }, "agent");
326
+ applyPlanGate(getRun(run.id));
327
+ }
228
328
  }
229
329
  else if (!opts?.replace) {
230
330
  addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
@@ -245,6 +345,80 @@ export async function draftPlan(run, opts) {
245
345
  await notify();
246
346
  return getRun(run.id);
247
347
  }
348
+ /** Answer open plan questions (PRD F3). Structured answers approve + dispatch; free-form revises the plan. */
349
+ export function submitPlanAnswers(runId, input, opts) {
350
+ const run = getRun(runId);
351
+ if (!run)
352
+ return { ok: false, code: 404, error: "Not found" };
353
+ if (run.status !== "plan_pending") {
354
+ return { ok: false, code: 409, error: `No open questions — run is ${run.status}` };
355
+ }
356
+ const triageArt = getLatestArtifact(runId, "plan_triage");
357
+ if (!triageArt?.contentJson)
358
+ return { ok: false, code: 409, error: "No open questions" };
359
+ let triage;
360
+ try {
361
+ triage = JSON.parse(triageArt.contentJson);
362
+ }
363
+ catch {
364
+ return { ok: false, code: 409, error: "No open questions" };
365
+ }
366
+ if (triage.consumed || triage.questions.length === 0) {
367
+ return { ok: false, code: 409, error: "No open questions" };
368
+ }
369
+ const priorAnswers = getLatestArtifact(runId, "plan_answers");
370
+ if (priorAnswers && priorAnswers.createdAt > triageArt.createdAt) {
371
+ return { ok: false, code: 409, error: "Questions already answered" };
372
+ }
373
+ const expected = triage.questions.map((q) => q.id).sort().join(",");
374
+ const got = input.answers.map((a) => a.questionId).sort().join(",");
375
+ if (expected !== got) {
376
+ return { ok: false, code: 400, error: "Answers must cover every open question exactly once" };
377
+ }
378
+ const freeForm = input.answers.some((a) => a.freeText);
379
+ const outcome = freeForm ? "redraft" : "approved";
380
+ addArtifact(runId, "plan_answers", { answers: input.answers, outcome, answeredAt: new Date().toISOString() }, "human");
381
+ if (!freeForm) {
382
+ // null/empty = "Default" — do not wipe a seeded execute_model; only set explicit picks
383
+ if (input.executeModel?.trim()) {
384
+ updateRunFields(runId, { execute_model: input.executeModel.trim() });
385
+ }
386
+ if (input.executeBudget !== undefined) {
387
+ patchRunPhaseBudget(runId, "execute", input.executeBudget);
388
+ }
389
+ markPlanTriageConsumed(runId);
390
+ try {
391
+ transitionRun(runId, "plan_approved");
392
+ }
393
+ catch (e) {
394
+ return { ok: false, code: 409, error: String(e) };
395
+ }
396
+ const draft = getLatestArtifact(runId, "draft_plan");
397
+ const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
398
+ addArtifact(runId, "approved_plan", plan, "human");
399
+ void forceDispatch();
400
+ return { ok: true, outcome, run: getRun(runId) };
401
+ }
402
+ (opts?.onRedraft ?? scheduleRevisePlan)(getRun(runId), triage, input.answers);
403
+ return { ok: true, outcome, run: getRun(runId) };
404
+ }
405
+ function scheduleRevisePlan(run, triage, answers) {
406
+ if (activePlanDrafts.has(run.id))
407
+ return;
408
+ activePlanDrafts.add(run.id);
409
+ void draftPlan(run, {
410
+ replace: true,
411
+ revise: {
412
+ resumeSessionId: triage.sessionId,
413
+ prompt: buildPlanRevisePrompt(run, triage.questions, answers),
414
+ },
415
+ })
416
+ .catch((e) => console.error("[plan-revise]", run.id, e))
417
+ .finally(() => {
418
+ activePlanDrafts.delete(run.id);
419
+ notify().catch(console.error);
420
+ });
421
+ }
248
422
  /** Fire-and-forget: human requested a new agent plan (replaces draft). */
249
423
  export function scheduleRedraft(run) {
250
424
  if (activePlanDrafts.has(run.id))
@@ -0,0 +1,55 @@
1
+ import { test, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-gate-"));
7
+ process.env.MAX_CONCURRENT_RUNS = "0";
8
+ const { migrate } = await import("../db/index.js");
9
+ const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
10
+ const { updateAgent } = await import("../repository/agents.js");
11
+ const { addArtifact, createRun, getLatestArtifact, getRun } = await import("../repository/runs.js");
12
+ const { applyPlanGate, getSnapshot } = await import("./dispatcher.js");
13
+ const QUESTIONS = [
14
+ { id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
15
+ ];
16
+ before(() => {
17
+ migrate();
18
+ updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
19
+ });
20
+ function seedRun(triage) {
21
+ const run = createRun({
22
+ title: "Gate test",
23
+ taskCategory: "other",
24
+ status: "plan_pending",
25
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
26
+ });
27
+ addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
28
+ addArtifact(run.id, "plan_triage", triage, "agent");
29
+ return getRun(run.id);
30
+ }
31
+ test("trivial triage auto-approves: system approved_plan + plan_approved status", () => {
32
+ const run = seedRun({ verdict: "trivial", rationale: "tiny", questions: [], parseFallback: false, consumed: false });
33
+ assert.equal(applyPlanGate(run), "auto_approve");
34
+ assert.equal(getRun(run.id).status, "plan_approved");
35
+ const approved = getLatestArtifact(run.id, "approved_plan");
36
+ assert.equal(approved.author, "system");
37
+ assert.match(approved.contentJson, /tiny/);
38
+ });
39
+ test("questions await answers and leave status untouched", () => {
40
+ const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
41
+ assert.equal(applyPlanGate(run), "await_answers");
42
+ assert.equal(getRun(run.id).status, "plan_pending");
43
+ });
44
+ test("third question round goes to manual review", () => {
45
+ const run = seedRun({ verdict: "needs_review", rationale: "r1", questions: QUESTIONS, parseFallback: false, consumed: false });
46
+ addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r2", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
47
+ addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r3", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
48
+ assert.equal(applyPlanGate(getRun(run.id)), "await_review");
49
+ });
50
+ test("snapshot exposes awaitingAnswerRuns, openQuestionCounts, autoApprovedRunIds", () => {
51
+ const snap = getSnapshot();
52
+ assert.equal(Array.isArray(snap.awaitingAnswerRuns), true);
53
+ assert.equal(typeof snap.openQuestionCounts, "object");
54
+ assert.equal(Array.isArray(snap.autoApprovedRunIds), true);
55
+ });