agent-dealer 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bundle/server/dist/db/index.js +5 -0
  2. package/bundle/server/dist/db/schema.sql +2 -0
  3. package/bundle/server/dist/queue/answers.test.js +117 -0
  4. package/bundle/server/dist/queue/dispatcher.js +164 -2
  5. package/bundle/server/dist/queue/plan-gate.test.js +55 -0
  6. package/bundle/server/dist/repository/agents.js +14 -5
  7. package/bundle/server/dist/repository/runs.js +46 -1
  8. package/bundle/server/dist/routes/index.js +51 -8
  9. package/bundle/server/dist/runners/claude.js +13 -18
  10. package/bundle/server/dist/runners/persist.js +17 -4
  11. package/bundle/server/dist/runners/prompts.js +52 -1
  12. package/bundle/server/dist/runners/prompts.test.js +63 -0
  13. package/bundle/server/dist/runners/reflect.js +1 -1
  14. package/bundle/server/dist/runners/stream-json.js +28 -0
  15. package/bundle/server/dist/runners/stream-json.test.js +57 -0
  16. package/bundle/server/dist/usage-summary.js +9 -4
  17. package/bundle/server/package.json +1 -1
  18. package/bundle/server/static-ui/assets/index-CH7u5bXC.js +78 -0
  19. package/bundle/server/static-ui/assets/index-CKuYOeDN.css +1 -0
  20. package/bundle/server/static-ui/index.html +2 -2
  21. package/bundle/shared/dist/agents.d.ts +100 -0
  22. package/bundle/shared/dist/agents.js +9 -0
  23. package/bundle/shared/dist/budget.d.ts +92 -0
  24. package/bundle/shared/dist/budget.js +108 -0
  25. package/bundle/shared/dist/index.d.ts +556 -86
  26. package/bundle/shared/dist/index.js +23 -0
  27. package/bundle/shared/dist/plan-triage.d.ts +317 -0
  28. package/bundle/shared/dist/plan-triage.js +65 -0
  29. package/bundle/shared/dist/plan-triage.test.d.ts +1 -0
  30. package/bundle/shared/dist/plan-triage.test.js +66 -0
  31. package/bundle/shared/package.json +1 -1
  32. package/package.json +1 -1
  33. package/bundle/server/static-ui/assets/index-BPfTEO0-.js +0 -77
  34. package/bundle/server/static-ui/assets/index-Blmq-P5I.css +0 -1
@@ -45,6 +45,11 @@ export function migrate() {
45
45
  db.exec("ALTER TABLE agents ADD COLUMN default_plan_model TEXT");
46
46
  db.exec("ALTER TABLE agents ADD COLUMN default_execute_model TEXT");
47
47
  }
48
+ const agentCols3 = db.prepare("PRAGMA table_info(agents)").all();
49
+ if (!agentCols3.some((c) => c.name === "default_plan_budget_json")) {
50
+ db.exec("ALTER TABLE agents ADD COLUMN default_plan_budget_json TEXT");
51
+ db.exec("ALTER TABLE agents ADD COLUMN default_execute_budget_json TEXT");
52
+ }
48
53
  const runCols3 = db.prepare("PRAGMA table_info(runs)").all();
49
54
  if (!runCols3.some((c) => c.name === "plan_model")) {
50
55
  db.exec("ALTER TABLE runs ADD COLUMN plan_model TEXT");
@@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS agents (
8
8
  workspace_root TEXT,
9
9
  default_plan_model TEXT,
10
10
  default_execute_model TEXT,
11
+ default_plan_budget_json TEXT,
12
+ default_execute_budget_json TEXT,
11
13
  is_builtin INTEGER NOT NULL DEFAULT 0,
12
14
  created_at TEXT NOT NULL,
13
15
  updated_at TEXT NOT NULL
@@ -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
+ });
@@ -1,4 +1,6 @@
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";
@@ -42,6 +44,24 @@ function isReadyForPlanReview(run) {
42
44
  !!getLatestArtifact(run.id, "draft_plan") &&
43
45
  !activePlanDrafts.has(run.id));
44
46
  }
47
+ /** Open (unanswered, unconsumed) question count for a plan_pending run. */
48
+ function openQuestionCount(runId) {
49
+ const tri = getLatestArtifact(runId, "plan_triage");
50
+ if (!tri?.contentJson)
51
+ return 0;
52
+ try {
53
+ const c = JSON.parse(tri.contentJson);
54
+ if (c.consumed || !c.questions?.length)
55
+ return 0;
56
+ const ans = getLatestArtifact(runId, "plan_answers");
57
+ if (ans && ans.createdAt > tri.createdAt)
58
+ return 0;
59
+ return c.questions.length;
60
+ }
61
+ catch {
62
+ return 0;
63
+ }
64
+ }
45
65
  export function getSnapshot() {
46
66
  const all = listRuns();
47
67
  const runningRuns = all.filter((r) => r.status === "running");
@@ -57,6 +77,16 @@ export function getSnapshot() {
57
77
  const awaitingPlanReview = planningPhase.filter(isReadyForPlanReview);
58
78
  const planningActiveRuns = planningPhase.filter((r) => activePlanDrafts.has(r.id));
59
79
  const planningQueuedRuns = planningPhase.filter((r) => !activePlanDrafts.has(r.id) && !isReadyForPlanReview(r));
80
+ const openQuestionCounts = {};
81
+ for (const run of awaitingPlanReview) {
82
+ const n = openQuestionCount(run.id);
83
+ if (n > 0)
84
+ openQuestionCounts[run.id] = n;
85
+ }
86
+ const awaitingAnswerRuns = awaitingPlanReview.filter((r) => openQuestionCounts[r.id]);
87
+ const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
88
+ .filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
89
+ .map((r) => r.id);
60
90
  return {
61
91
  planReviewCount: awaitingPlanReview.length,
62
92
  resultReviewCount: resultReviewRuns.length,
@@ -68,6 +98,9 @@ export function getSnapshot() {
68
98
  resultReviewRuns,
69
99
  recentDone,
70
100
  awaitingPlanReview,
101
+ awaitingAnswerRuns,
102
+ openQuestionCounts,
103
+ autoApprovedRunIds,
71
104
  runs: all,
72
105
  agentDeckOnline: false,
73
106
  agents: [],
@@ -195,6 +228,49 @@ export async function kickRun(run) {
195
228
  }
196
229
  await dispatch();
197
230
  }
231
+ function priorQuestionRounds(runId, currentTriageArtifactId) {
232
+ return listArtifacts(runId).filter((a) => {
233
+ if (a.kind !== "plan_triage" || a.id === currentTriageArtifactId || !a.contentJson)
234
+ return false;
235
+ try {
236
+ const c = JSON.parse(a.contentJson);
237
+ return (c.questions?.length ?? 0) > 0;
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }).length;
243
+ }
244
+ /** Self-triage gate after a plan draft persists (PRD F2). Exported for tests. */
245
+ export function applyPlanGate(run) {
246
+ const art = getLatestArtifact(run.id, "plan_triage");
247
+ if (!art?.contentJson)
248
+ return "await_review";
249
+ let triage;
250
+ try {
251
+ triage = JSON.parse(art.contentJson);
252
+ }
253
+ catch {
254
+ return "await_review";
255
+ }
256
+ const decision = planGateDecision({
257
+ triage,
258
+ priorQuestionRounds: priorQuestionRounds(run.id, art.id),
259
+ });
260
+ if (decision !== "auto_approve")
261
+ return decision;
262
+ const fresh = getRun(run.id);
263
+ if (!fresh || fresh.status !== "plan_pending")
264
+ return "await_review";
265
+ const draft = getLatestArtifact(run.id, "draft_plan");
266
+ if (!draft?.contentJson)
267
+ return "await_review";
268
+ const plan = JSON.parse(draft.contentJson);
269
+ addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
270
+ transitionRun(run.id, "plan_approved");
271
+ void forceDispatch();
272
+ return "auto_approve";
273
+ }
198
274
  export async function draftPlan(run, opts) {
199
275
  if (run.status === "queued") {
200
276
  transitionRun(run.id, "plan_pending");
@@ -208,7 +284,7 @@ export async function draftPlan(run, opts) {
208
284
  }
209
285
  const rt = runtimeFor(updated);
210
286
  try {
211
- const result = await runAgent(updated, "plan");
287
+ const result = await runAgent(updated, "plan", opts?.revise);
212
288
  const persisted = persistRunOutput({
213
289
  run: updated,
214
290
  phase: "plan",
@@ -225,6 +301,18 @@ export async function draftPlan(run, opts) {
225
301
  }
226
302
  if (persisted.planMarkdown) {
227
303
  addArtifact(updated.id, "draft_plan", { markdown: persisted.planMarkdown, sessionId: persisted.sessionId }, "agent", result.logPath);
304
+ const triage = persisted.planTriage;
305
+ if (triage) {
306
+ addArtifact(updated.id, "plan_triage", {
307
+ verdict: triage.verdict,
308
+ rationale: triage.rationale,
309
+ questions: triage.questions,
310
+ sessionId: persisted.sessionId,
311
+ parseFallback: triage.parseFallback,
312
+ consumed: false,
313
+ }, "agent");
314
+ applyPlanGate(getRun(run.id));
315
+ }
228
316
  }
229
317
  else if (!opts?.replace) {
230
318
  addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
@@ -245,6 +333,80 @@ export async function draftPlan(run, opts) {
245
333
  await notify();
246
334
  return getRun(run.id);
247
335
  }
336
+ /** Answer open plan questions (PRD F3). Structured answers approve + dispatch; free-form revises the plan. */
337
+ export function submitPlanAnswers(runId, input, opts) {
338
+ const run = getRun(runId);
339
+ if (!run)
340
+ return { ok: false, code: 404, error: "Not found" };
341
+ if (run.status !== "plan_pending") {
342
+ return { ok: false, code: 409, error: `No open questions — run is ${run.status}` };
343
+ }
344
+ const triageArt = getLatestArtifact(runId, "plan_triage");
345
+ if (!triageArt?.contentJson)
346
+ return { ok: false, code: 409, error: "No open questions" };
347
+ let triage;
348
+ try {
349
+ triage = JSON.parse(triageArt.contentJson);
350
+ }
351
+ catch {
352
+ return { ok: false, code: 409, error: "No open questions" };
353
+ }
354
+ if (triage.consumed || triage.questions.length === 0) {
355
+ return { ok: false, code: 409, error: "No open questions" };
356
+ }
357
+ const priorAnswers = getLatestArtifact(runId, "plan_answers");
358
+ if (priorAnswers && priorAnswers.createdAt > triageArt.createdAt) {
359
+ return { ok: false, code: 409, error: "Questions already answered" };
360
+ }
361
+ const expected = triage.questions.map((q) => q.id).sort().join(",");
362
+ const got = input.answers.map((a) => a.questionId).sort().join(",");
363
+ if (expected !== got) {
364
+ return { ok: false, code: 400, error: "Answers must cover every open question exactly once" };
365
+ }
366
+ const freeForm = input.answers.some((a) => a.freeText);
367
+ const outcome = freeForm ? "redraft" : "approved";
368
+ addArtifact(runId, "plan_answers", { answers: input.answers, outcome, answeredAt: new Date().toISOString() }, "human");
369
+ if (!freeForm) {
370
+ // null/empty = "Default" — do not wipe a seeded execute_model; only set explicit picks
371
+ if (input.executeModel?.trim()) {
372
+ updateRunFields(runId, { execute_model: input.executeModel.trim() });
373
+ }
374
+ if (input.executeBudget !== undefined) {
375
+ patchRunPhaseBudget(runId, "execute", input.executeBudget);
376
+ }
377
+ markPlanTriageConsumed(runId);
378
+ try {
379
+ transitionRun(runId, "plan_approved");
380
+ }
381
+ catch (e) {
382
+ return { ok: false, code: 409, error: String(e) };
383
+ }
384
+ const draft = getLatestArtifact(runId, "draft_plan");
385
+ const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
386
+ addArtifact(runId, "approved_plan", plan, "human");
387
+ void forceDispatch();
388
+ return { ok: true, outcome, run: getRun(runId) };
389
+ }
390
+ (opts?.onRedraft ?? scheduleRevisePlan)(getRun(runId), triage, input.answers);
391
+ return { ok: true, outcome, run: getRun(runId) };
392
+ }
393
+ function scheduleRevisePlan(run, triage, answers) {
394
+ if (activePlanDrafts.has(run.id))
395
+ return;
396
+ activePlanDrafts.add(run.id);
397
+ void draftPlan(run, {
398
+ replace: true,
399
+ revise: {
400
+ resumeSessionId: triage.sessionId,
401
+ prompt: buildPlanRevisePrompt(run, triage.questions, answers),
402
+ },
403
+ })
404
+ .catch((e) => console.error("[plan-revise]", run.id, e))
405
+ .finally(() => {
406
+ activePlanDrafts.delete(run.id);
407
+ notify().catch(console.error);
408
+ });
409
+ }
248
410
  /** Fire-and-forget: human requested a new agent plan (replaces draft). */
249
411
  export function scheduleRedraft(run) {
250
412
  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
+ });
@@ -1,3 +1,4 @@
1
+ import { serializePhaseBudget } from "@agent-dealer/shared";
1
2
  import { v4 as uuid } from "uuid";
2
3
  import { getDb } from "../db/index.js";
3
4
  function rowToAgent(row) {
@@ -11,6 +12,8 @@ function rowToAgent(row) {
11
12
  playbookId: row.playbook_id,
12
13
  defaultPlanModel: row.default_plan_model,
13
14
  defaultExecuteModel: row.default_execute_model,
15
+ defaultPlanBudgetJson: row.default_plan_budget_json,
16
+ defaultExecuteBudgetJson: row.default_execute_budget_json,
14
17
  isBuiltin: row.is_builtin === 1,
15
18
  createdAt: row.created_at,
16
19
  updatedAt: row.updated_at,
@@ -31,9 +34,9 @@ export function createAgent(input, deckName) {
31
34
  const now = new Date().toISOString();
32
35
  const id = uuid();
33
36
  db.prepare(`
34
- INSERT INTO agents (id, name, runtime, deck_id, deck_name, playbook_id, workspace_root, default_plan_model, default_execute_model, is_builtin, created_at, updated_at)
35
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
36
- `).run(id, input.name.trim(), input.runtime, input.deckId ?? null, deckName ?? null, input.playbookId ?? null, input.workspaceRoot.trim(), input.defaultPlanModel ?? null, input.defaultExecuteModel ?? null, now, now);
37
+ INSERT INTO agents (id, name, runtime, deck_id, deck_name, playbook_id, workspace_root, default_plan_model, default_execute_model, default_plan_budget_json, default_execute_budget_json, is_builtin, created_at, updated_at)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
39
+ `).run(id, input.name.trim(), input.runtime, input.deckId ?? null, deckName ?? null, input.playbookId ?? null, input.workspaceRoot.trim(), input.defaultPlanModel ?? null, input.defaultExecuteModel ?? null, serializePhaseBudget(input.defaultPlanBudget), serializePhaseBudget(input.defaultExecuteBudget), now, now);
37
40
  return getAgent(id);
38
41
  }
39
42
  export function updateAgent(id, input, deckName) {
@@ -48,14 +51,20 @@ export function updateAgent(id, input, deckName) {
48
51
  const playbookId = input.playbookId !== undefined ? input.playbookId : existing.playbookId;
49
52
  const defaultPlanModel = input.defaultPlanModel !== undefined ? input.defaultPlanModel : existing.defaultPlanModel;
50
53
  const defaultExecuteModel = input.defaultExecuteModel !== undefined ? input.defaultExecuteModel : existing.defaultExecuteModel;
54
+ const defaultPlanBudgetJson = input.defaultPlanBudget !== undefined
55
+ ? serializePhaseBudget(input.defaultPlanBudget)
56
+ : existing.defaultPlanBudgetJson;
57
+ const defaultExecuteBudgetJson = input.defaultExecuteBudget !== undefined
58
+ ? serializePhaseBudget(input.defaultExecuteBudget)
59
+ : existing.defaultExecuteBudgetJson;
51
60
  const resolvedDeckName = input.deckId !== undefined ? (input.deckId ? (deckName ?? null) : null) : existing.deckName;
52
61
  getDb()
53
62
  .prepare(`
54
63
  UPDATE agents SET name = ?, runtime = ?, deck_id = ?, deck_name = ?, playbook_id = ?, workspace_root = ?,
55
- default_plan_model = ?, default_execute_model = ?, updated_at = ?
64
+ default_plan_model = ?, default_execute_model = ?, default_plan_budget_json = ?, default_execute_budget_json = ?, updated_at = ?
56
65
  WHERE id = ?
57
66
  `)
58
- .run(name, runtime, deckId, resolvedDeckName, playbookId, workspaceRoot, defaultPlanModel, defaultExecuteModel, now, id);
67
+ .run(name, runtime, deckId, resolvedDeckName, playbookId, workspaceRoot, defaultPlanModel, defaultExecuteModel, defaultPlanBudgetJson, defaultExecuteBudgetJson, now, id);
59
68
  return getAgent(id);
60
69
  }
61
70
  export function deleteAgent(id) {
@@ -1,4 +1,5 @@
1
1
  import { CURSOR_DEFAULT_MODEL } from "@agent-dealer/shared";
2
+ import { mergeRunBudget, parsePhaseBudget, parseRunBudget, resolvePhaseBudget, serializeRunBudget, } from "@agent-dealer/shared";
2
3
  import { canTransition } from "@agent-dealer/shared";
3
4
  import { v4 as uuid } from "uuid";
4
5
  import { getDb } from "../db/index.js";
@@ -74,7 +75,7 @@ export function createRun(input, opts) {
74
75
  update_ticket_status: "require_approval",
75
76
  publish_external: "require_approval",
76
77
  }),
77
- budget_json: JSON.stringify({ maxTurns: 30, maxBudgetUsd: 5 }),
78
+ budget_json: serializeRunBudget(input.budget ?? {}),
78
79
  created_at: now,
79
80
  updated_at: now,
80
81
  };
@@ -141,6 +142,35 @@ export function listRunsReadyForPlanReview() {
141
142
  .all();
142
143
  return rows.map(rowToRun);
143
144
  }
145
+ export function resolveBudgetForPhase(run, phase) {
146
+ const agent = run.agentId ? getAgent(run.agentId) : null;
147
+ return resolvePhaseBudget({
148
+ phase,
149
+ runBudget: parseRunBudget(run.budgetJson),
150
+ agentPlanBudget: parsePhaseBudget(agent?.defaultPlanBudgetJson),
151
+ agentExecuteBudget: parsePhaseBudget(agent?.defaultExecuteBudgetJson),
152
+ testMode: process.env.AGENT_DEALER_TEST_BUDGET === "1",
153
+ });
154
+ }
155
+ export function updateRunBudget(id, patch) {
156
+ const run = getRun(id);
157
+ if (!run)
158
+ throw new Error(`Run not found: ${id}`);
159
+ const merged = mergeRunBudget(parseRunBudget(run.budgetJson), patch);
160
+ const budgetJson = serializeRunBudget(merged);
161
+ const now = new Date().toISOString();
162
+ getDb().prepare("UPDATE runs SET budget_json = ?, updated_at = ? WHERE id = ?").run(budgetJson, now, id);
163
+ const updated = getRun(id);
164
+ if (!updated)
165
+ throw new Error(`Run vanished: ${id}`);
166
+ return updated;
167
+ }
168
+ export function patchRunPhaseBudget(id, phase, budget) {
169
+ if (budget === undefined)
170
+ return getRun(id);
171
+ // null = clear run-level override for this phase (revert to agent defaults)
172
+ return updateRunBudget(id, { [phase]: budget });
173
+ }
144
174
  export function resolveModelForPhase(run, phase) {
145
175
  const agent = run.agentId ? getAgent(run.agentId) : null;
146
176
  const isCursor = run.runtime === "cursor_local";
@@ -358,6 +388,21 @@ export function getLatestArtifact(runId, kind) {
358
388
  createdAt: row.created_at,
359
389
  };
360
390
  }
391
+ /** Latest plan_triage stops auto-approving / accepting answers once a human takes over the plan. */
392
+ export function markPlanTriageConsumed(runId) {
393
+ const art = getLatestArtifact(runId, "plan_triage");
394
+ if (!art?.contentJson)
395
+ return;
396
+ try {
397
+ const content = JSON.parse(art.contentJson);
398
+ if (content.consumed === true)
399
+ return;
400
+ updateArtifactContent(art.id, { ...content, consumed: true });
401
+ }
402
+ catch {
403
+ /* leave malformed triage untouched */
404
+ }
405
+ }
361
406
  const OPS_BOARD_STATUSES = [
362
407
  "queued",
363
408
  "plan_pending",
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
- import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
3
- import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, } from "../repository/runs.js";
2
+ import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
3
+ import { parseRunBudget } from "@agent-dealer/shared";
4
+ import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
4
5
  import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
5
6
  import { listAgentsWithHealth } from "../adapters/agent-health.js";
6
- import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, subscribe, } from "../queue/dispatcher.js";
7
+ import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
7
8
  import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
8
9
  import { testAgentDeckConnection } from "../adapters/agent-deck.js";
9
10
  import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
@@ -52,11 +53,12 @@ export async function registerRoutes(app) {
52
53
  const run = getRun(id);
53
54
  if (!run)
54
55
  return reply.status(404).send({ error: "Not found" });
56
+ const artifacts = listArtifacts(id);
55
57
  return {
56
58
  run,
57
- artifacts: listArtifacts(id),
59
+ artifacts,
58
60
  events: listEvents(id),
59
- usageSummary: buildLineageUsageSummary(run),
61
+ usageSummary: buildLineageUsageSummary(run, { artifactsByRunId: { [id]: artifacts } }),
60
62
  traceSummary: buildLineageTraceSummary(run),
61
63
  };
62
64
  });
@@ -233,6 +235,9 @@ export async function registerRoutes(app) {
233
235
  plan_model: input.planModel !== undefined ? input.planModel : undefined,
234
236
  execute_model: input.executeModel !== undefined ? input.executeModel : undefined,
235
237
  });
238
+ if (input.budget !== undefined) {
239
+ return updateRunBudget(id, input.budget);
240
+ }
236
241
  return updated;
237
242
  });
238
243
  app.post("/api/runs/:id/draft-plan", async (req, reply) => {
@@ -247,6 +252,9 @@ export async function registerRoutes(app) {
247
252
  if (input.planModel !== undefined) {
248
253
  updateRunFields(id, { plan_model: input.planModel });
249
254
  }
255
+ if (input.planBudget !== undefined) {
256
+ patchRunPhaseBudget(id, "plan", input.planBudget);
257
+ }
250
258
  try {
251
259
  assertAgentConfigured(getRun(id));
252
260
  const scheduled = scheduleRedraft(getRun(id));
@@ -265,8 +273,6 @@ export async function registerRoutes(app) {
265
273
  if (!run)
266
274
  return reply.status(404).send({ error: "Not found" });
267
275
  const input = UpdatePlanInput.parse(req.body);
268
- const kind = input.approve ? "approved_plan" : "draft_plan";
269
- addArtifact(id, kind, { markdown: input.planMarkdown }, "human");
270
276
  if (input.approve) {
271
277
  const hasPlan = listArtifacts(id).some((a) => a.kind === "draft_plan") ||
272
278
  input.planMarkdown.trim().length > 0;
@@ -288,16 +294,46 @@ export async function registerRoutes(app) {
288
294
  if (Object.keys(patch).length > 0) {
289
295
  updateRunFields(id, patch);
290
296
  }
291
- const updated = transitionRun(id, "plan_approved");
297
+ if (input.planBudget !== undefined) {
298
+ patchRunPhaseBudget(id, "plan", input.planBudget);
299
+ }
300
+ if (input.executeBudget !== undefined) {
301
+ patchRunPhaseBudget(id, "execute", input.executeBudget);
302
+ }
303
+ markPlanTriageConsumed(id);
304
+ try {
305
+ transitionRun(id, "plan_approved");
306
+ }
307
+ catch (e) {
308
+ return reply.status(409).send({ error: String(e) });
309
+ }
310
+ addArtifact(id, "approved_plan", { markdown: input.planMarkdown }, "human");
311
+ const updated = getRun(id);
292
312
  syncLinearForRun(updated, "plan_approved").catch((e) => console.error("[linear-sync] plan_approved:", e));
293
313
  await forceDispatch();
294
314
  return updated;
295
315
  }
316
+ addArtifact(id, "draft_plan", { markdown: input.planMarkdown }, "human");
317
+ markPlanTriageConsumed(id);
296
318
  if (run.status === "queued") {
297
319
  return transitionRun(id, "plan_pending");
298
320
  }
299
321
  return getRun(id);
300
322
  });
323
+ app.post("/api/runs/:id/plan/answers", async (req, reply) => {
324
+ const { id } = req.params;
325
+ let input;
326
+ try {
327
+ input = PlanAnswersInput.parse(req.body);
328
+ }
329
+ catch (e) {
330
+ return reply.status(400).send({ error: String(e) });
331
+ }
332
+ const result = submitPlanAnswers(id, input);
333
+ if (!result.ok)
334
+ return reply.status(result.code).send({ error: result.error });
335
+ return { run: result.run, outcome: result.outcome };
336
+ });
301
337
  app.post("/api/runs/:id/kick", async (req, reply) => {
302
338
  const { id } = req.params;
303
339
  const run = getRun(id);
@@ -327,6 +363,9 @@ export async function registerRoutes(app) {
327
363
  return reply.status(400).send({ error: `Cannot kick from status ${run.status}` });
328
364
  }
329
365
  updateRunFields(id, patch);
366
+ if (input.executeBudget !== undefined) {
367
+ patchRunPhaseBudget(id, "execute", input.executeBudget);
368
+ }
330
369
  const updated = getRun(id);
331
370
  await kickRun(updated);
332
371
  await forceDispatch();
@@ -369,6 +408,7 @@ export async function registerRoutes(app) {
369
408
  status: "plan_approved",
370
409
  agentId: run.agentId ?? BUILTIN_AGENT_CLAUDE_ID,
371
410
  planModel: run.planModel ?? undefined,
411
+ budget: parseRunBudget(run.budgetJson),
372
412
  }, { source: run.source, externalId: run.externalId ?? undefined, externalLabel: run.externalLabel ?? undefined, lineageId: run.lineageId ?? run.id });
373
413
  updateRunFields(retry.id, {
374
414
  deck_id: run.deckId,
@@ -377,6 +417,9 @@ export async function registerRoutes(app) {
377
417
  runtime: run.runtime,
378
418
  execute_model: input.executeModel ?? input.planModel ?? run.executeModel,
379
419
  });
420
+ if (input.executeBudget !== undefined) {
421
+ patchRunPhaseBudget(retry.id, "execute", input.executeBudget);
422
+ }
380
423
  try {
381
424
  addArtifact(retry.id, "approved_plan", JSON.parse(approvedPlan.contentJson), approvedPlan.author);
382
425
  }