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,108 @@
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-send-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, transitionRun, updateRunFields, } = await import("../repository/runs.js");
12
+ const { approveRunWithDeliver } = await import("./approve-deliver.js");
13
+ const { rejectPendingOutboundDrafts, pendingSendCount } = await import("../repository/outbound-drafts.js");
14
+ const { getSnapshot } = await import("./dispatcher.js");
15
+ const TOOL_CALL = {
16
+ serviceName: "34eb6c24-f151-4da2-8db8-d6996aa296be",
17
+ toolName: "chat_postMessage",
18
+ arguments: { channel: "C1", text: "Hello gate test" },
19
+ };
20
+ const DRAFT_CONTENT = {
21
+ draft: {
22
+ actionType: "slack_message",
23
+ summary: { target: "#test", body: "Hello gate test" },
24
+ toolCall: TOOL_CALL,
25
+ },
26
+ status: "pending",
27
+ };
28
+ before(() => {
29
+ migrate();
30
+ updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
31
+ });
32
+ function seedReviewRun(withDraft = true) {
33
+ const run = createRun({
34
+ title: "Send gate test",
35
+ taskCategory: "communication",
36
+ status: "plan_pending",
37
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
38
+ });
39
+ updateRunFields(run.id, { deck_id: "6e825b59-13de-4ddd-ab7e-55ab5a1c279a" });
40
+ transitionRun(run.id, "plan_approved");
41
+ transitionRun(run.id, "running");
42
+ transitionRun(run.id, "review");
43
+ if (withDraft) {
44
+ addArtifact(run.id, "slack_draft", DRAFT_CONTENT, "agent");
45
+ }
46
+ return getRun(run.id);
47
+ }
48
+ test("pendingSendCount is 1 when draft pending", () => {
49
+ const run = seedReviewRun(true);
50
+ assert.equal(pendingSendCount(run.id), 1);
51
+ });
52
+ test("approve without draft transitions to done", async () => {
53
+ const run = seedReviewRun(false);
54
+ const res = await approveRunWithDeliver(run.id);
55
+ assert.equal(res.ok, true);
56
+ assert.equal(res.ok && res.delivered, false);
57
+ assert.equal(getRun(run.id).status, "done");
58
+ });
59
+ test("approve with mock deliver sends byte-identical payload and creates receipt", async () => {
60
+ const run = seedReviewRun(true);
61
+ let captured = null;
62
+ const res = await approveRunWithDeliver(run.id, {
63
+ deliver: async (_deckId, toolCall) => {
64
+ captured = toolCall;
65
+ return { toolResult: { ok: true }, permalink: "https://slack.example/msg/1" };
66
+ },
67
+ });
68
+ assert.equal(res.ok, true);
69
+ assert.equal(res.ok && res.delivered, true);
70
+ assert.deepEqual(captured, TOOL_CALL);
71
+ assert.equal(getRun(run.id).status, "done");
72
+ assert.ok(getLatestArtifact(run.id, "send_receipt"));
73
+ const draftArt = getLatestArtifact(run.id, "slack_draft");
74
+ assert.match(draftArt.contentJson, /"status":"sent"/);
75
+ });
76
+ test("deliver failure keeps run in review with pending draft", async () => {
77
+ const run = seedReviewRun(true);
78
+ const res = await approveRunWithDeliver(run.id, {
79
+ deliver: async () => {
80
+ throw new Error("deck down");
81
+ },
82
+ });
83
+ assert.equal(res.ok, false);
84
+ assert.equal(!res.ok && res.code, 502);
85
+ assert.equal(getRun(run.id).status, "review");
86
+ assert.equal(pendingSendCount(run.id), 1);
87
+ });
88
+ test("proxy success:false from deck is treated as deliver failure", async () => {
89
+ const run = seedReviewRun(true);
90
+ const res = await approveRunWithDeliver(run.id, {
91
+ deliver: async () => {
92
+ throw new Error("Service not found");
93
+ },
94
+ });
95
+ assert.equal(res.ok, false);
96
+ assert.equal(getRun(run.id).status, "review");
97
+ });
98
+ test("retry rejects pending draft", () => {
99
+ const run = seedReviewRun(true);
100
+ rejectPendingOutboundDrafts(run.id);
101
+ assert.equal(pendingSendCount(run.id), 0);
102
+ const draftArt = getLatestArtifact(run.id, "slack_draft");
103
+ assert.match(draftArt.contentJson, /"status":"rejected"/);
104
+ });
105
+ test("snapshot exposes pendingSendCounts", () => {
106
+ const snap = getSnapshot();
107
+ assert.equal(typeof snap.pendingSendCounts, "object");
108
+ });
@@ -0,0 +1,62 @@
1
+ import { isOutboundDraftKind } from "@agent-dealer/shared";
2
+ import { getDb } from "../db/index.js";
3
+ import { listArtifacts } from "./runs.js";
4
+ export function getPendingOutboundDraft(runId) {
5
+ const arts = listArtifacts(runId).filter((a) => isOutboundDraftKind(a.kind) && a.contentJson);
6
+ for (let i = arts.length - 1; i >= 0; i--) {
7
+ const art = arts[i];
8
+ try {
9
+ const content = JSON.parse(art.contentJson);
10
+ if (content.status === "pending") {
11
+ return { artifact: art, content };
12
+ }
13
+ }
14
+ catch {
15
+ // skip
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+ export function pendingSendCount(runId) {
21
+ return getPendingOutboundDraft(runId) ? 1 : 0;
22
+ }
23
+ export function rejectPendingOutboundDrafts(runId) {
24
+ const now = new Date().toISOString();
25
+ const db = getDb();
26
+ for (const art of listArtifacts(runId)) {
27
+ if (!isOutboundDraftKind(art.kind) || !art.contentJson)
28
+ continue;
29
+ try {
30
+ const content = JSON.parse(art.contentJson);
31
+ if (content.status !== "pending")
32
+ continue;
33
+ const next = { ...content, status: "rejected", rejectedAt: now };
34
+ db.prepare("UPDATE artifacts SET content_json = ? WHERE id = ?").run(JSON.stringify(next), art.id);
35
+ }
36
+ catch {
37
+ // skip
38
+ }
39
+ }
40
+ }
41
+ /** Atomic pending → sent transition. Returns false if not pending. */
42
+ export function markOutboundDraftSent(artifactId) {
43
+ const db = getDb();
44
+ const row = db.prepare("SELECT content_json FROM artifacts WHERE id = ?").get(artifactId);
45
+ if (!row?.content_json)
46
+ return false;
47
+ let content;
48
+ try {
49
+ content = JSON.parse(row.content_json);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (content.status !== "pending")
55
+ return false;
56
+ const next = { ...content, status: "sent", sentAt: new Date().toISOString() };
57
+ const result = db
58
+ .prepare("UPDATE artifacts SET content_json = ? WHERE id = ? AND content_json = ?")
59
+ .run(JSON.stringify(next), artifactId, row.content_json);
60
+ return result.changes === 1;
61
+ }
62
+ export const deliverInFlight = new Set();
@@ -168,7 +168,8 @@ export function updateRunBudget(id, patch) {
168
168
  export function patchRunPhaseBudget(id, phase, budget) {
169
169
  if (budget === undefined)
170
170
  return getRun(id);
171
- return updateRunBudget(id, { [phase]: budget ?? undefined });
171
+ // null = clear run-level override for this phase (revert to agent defaults)
172
+ return updateRunBudget(id, { [phase]: budget });
172
173
  }
173
174
  export function resolveModelForPhase(run, phase) {
174
175
  const agent = run.agentId ? getAgent(run.agentId) : null;
@@ -387,6 +388,21 @@ export function getLatestArtifact(runId, kind) {
387
388
  createdAt: row.created_at,
388
389
  };
389
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
+ }
390
406
  const OPS_BOARD_STATUSES = [
391
407
  "queued",
392
408
  "plan_pending",
@@ -1,10 +1,12 @@
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";
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
3
  import { parseRunBudget } from "@agent-dealer/shared";
4
- import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
4
+ import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
5
5
  import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
6
6
  import { listAgentsWithHealth } from "../adapters/agent-health.js";
7
- 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";
8
+ import { approveRunWithDeliver } from "../queue/approve-deliver.js";
9
+ import { rejectPendingOutboundDrafts } from "../repository/outbound-drafts.js";
8
10
  import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
9
11
  import { testAgentDeckConnection } from "../adapters/agent-deck.js";
10
12
  import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
@@ -53,11 +55,12 @@ export async function registerRoutes(app) {
53
55
  const run = getRun(id);
54
56
  if (!run)
55
57
  return reply.status(404).send({ error: "Not found" });
58
+ const artifacts = listArtifacts(id);
56
59
  return {
57
60
  run,
58
- artifacts: listArtifacts(id),
61
+ artifacts,
59
62
  events: listEvents(id),
60
- usageSummary: buildLineageUsageSummary(run),
63
+ usageSummary: buildLineageUsageSummary(run, { artifactsByRunId: { [id]: artifacts } }),
61
64
  traceSummary: buildLineageTraceSummary(run),
62
65
  };
63
66
  });
@@ -272,8 +275,6 @@ export async function registerRoutes(app) {
272
275
  if (!run)
273
276
  return reply.status(404).send({ error: "Not found" });
274
277
  const input = UpdatePlanInput.parse(req.body);
275
- const kind = input.approve ? "approved_plan" : "draft_plan";
276
- addArtifact(id, kind, { markdown: input.planMarkdown }, "human");
277
278
  if (input.approve) {
278
279
  const hasPlan = listArtifacts(id).some((a) => a.kind === "draft_plan") ||
279
280
  input.planMarkdown.trim().length > 0;
@@ -301,16 +302,40 @@ export async function registerRoutes(app) {
301
302
  if (input.executeBudget !== undefined) {
302
303
  patchRunPhaseBudget(id, "execute", input.executeBudget);
303
304
  }
304
- const updated = transitionRun(id, "plan_approved");
305
+ markPlanTriageConsumed(id);
306
+ try {
307
+ transitionRun(id, "plan_approved");
308
+ }
309
+ catch (e) {
310
+ return reply.status(409).send({ error: String(e) });
311
+ }
312
+ addArtifact(id, "approved_plan", { markdown: input.planMarkdown }, "human");
313
+ const updated = getRun(id);
305
314
  syncLinearForRun(updated, "plan_approved").catch((e) => console.error("[linear-sync] plan_approved:", e));
306
315
  await forceDispatch();
307
316
  return updated;
308
317
  }
318
+ addArtifact(id, "draft_plan", { markdown: input.planMarkdown }, "human");
319
+ markPlanTriageConsumed(id);
309
320
  if (run.status === "queued") {
310
321
  return transitionRun(id, "plan_pending");
311
322
  }
312
323
  return getRun(id);
313
324
  });
325
+ app.post("/api/runs/:id/plan/answers", async (req, reply) => {
326
+ const { id } = req.params;
327
+ let input;
328
+ try {
329
+ input = PlanAnswersInput.parse(req.body);
330
+ }
331
+ catch (e) {
332
+ return reply.status(400).send({ error: String(e) });
333
+ }
334
+ const result = submitPlanAnswers(id, input);
335
+ if (!result.ok)
336
+ return reply.status(result.code).send({ error: result.error });
337
+ return { run: result.run, outcome: result.outcome };
338
+ });
314
339
  app.post("/api/runs/:id/kick", async (req, reply) => {
315
340
  const { id } = req.params;
316
341
  const run = getRun(id);
@@ -350,16 +375,11 @@ export async function registerRoutes(app) {
350
375
  });
351
376
  app.post("/api/runs/:id/approve", async (req, reply) => {
352
377
  const { id } = req.params;
353
- const run = getRun(id);
354
- if (!run)
355
- return reply.status(404).send({ error: "Not found" });
356
- if (run.status !== "review") {
357
- return reply.status(400).send({ error: "Run must be in review" });
378
+ const result = await approveRunWithDeliver(id);
379
+ if (!result.ok) {
380
+ return reply.status(result.code).send({ error: result.error });
358
381
  }
359
- const updated = transitionRun(id, "done");
360
- syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
361
- scheduleReflect(updated, { trigger: "approve" });
362
- return updated;
382
+ return result.run;
363
383
  });
364
384
  app.post("/api/runs/:id/retry", async (req, reply) => {
365
385
  const { id } = req.params;
@@ -375,6 +395,7 @@ export async function registerRoutes(app) {
375
395
  }
376
396
  const input = RetryRunInput.parse(req.body);
377
397
  addArtifact(id, "feedback", { markdown: input.feedback }, "human");
398
+ rejectPendingOutboundDrafts(id);
378
399
  const retry = createRun({
379
400
  title: run.title,
380
401
  description: run.description ?? undefined,
@@ -394,6 +415,9 @@ export async function registerRoutes(app) {
394
415
  runtime: run.runtime,
395
416
  execute_model: input.executeModel ?? input.planModel ?? run.executeModel,
396
417
  });
418
+ if (input.executeBudget !== undefined) {
419
+ patchRunPhaseBudget(retry.id, "execute", input.executeBudget);
420
+ }
397
421
  try {
398
422
  addArtifact(retry.id, "approved_plan", JSON.parse(approvedPlan.contentJson), approvedPlan.author);
399
423
  }
@@ -0,0 +1,24 @@
1
+ import { getTemporalOutputDir } from "../paths.js";
2
+ export const DENY_SEND_TOOL = "mcp__agent-deck__call_service_tool";
3
+ const DECK_READ_TOOLS = "mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace,mcp__agent-deck__list_service_tools";
4
+ const PLAN_REFLECT_TOOLS = `Read,Glob,Grep,Skill,${DECK_READ_TOOLS}`;
5
+ function executeToolsForCategory(category) {
6
+ const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill", DECK_READ_TOOLS];
7
+ if (category !== "communication" && category !== "email") {
8
+ base.splice(5, 0, "Bash");
9
+ }
10
+ return base.join(",");
11
+ }
12
+ /** Build claude -p CLI args for a phase (excluding prompt, model, resume, mcp-config, budget). */
13
+ export function buildClaudePhaseArgs(run, mode) {
14
+ const args = ["--output-format", "stream-json", "--verbose"];
15
+ if (mode === "plan" || mode === "reflect") {
16
+ args.push("--allowedTools", PLAN_REFLECT_TOOLS);
17
+ }
18
+ else {
19
+ args.push("--add-dir", getTemporalOutputDir());
20
+ args.push("--allowedTools", executeToolsForCategory(run.taskCategory));
21
+ }
22
+ args.push("--disallowedTools", DENY_SEND_TOOL);
23
+ return args;
24
+ }
@@ -3,10 +3,11 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { buildExecutionPrompt, buildExecutionContinuationPrompt, buildPlanPrompt, buildReflectPrompt, workspaceForRun } from "./prompts.js";
5
5
  import { humanFeedbackText, lineageParentExecuteSessionId } from "./run-context.js";
6
- import { getTemporalLogsDir, getTemporalOutputDir } from "../paths.js";
6
+ import { getTemporalLogsDir } from "../paths.js";
7
7
  import { resolveClaudeBin, resolveCursorBin } from "../cli-env.js";
8
8
  import { resolveBudgetForPhase, resolveModelForPhase, getRun } from "../repository/runs.js";
9
9
  import { budgetCliArgs } from "@agent-dealer/shared";
10
+ import { buildClaudePhaseArgs } from "./claude-args.js";
10
11
  async function spawnCli(cmd, args, cwd) {
11
12
  return new Promise((resolve, reject) => {
12
13
  const chunks = [];
@@ -25,14 +26,15 @@ function logPathFor(run, mode) {
25
26
  const logDir = getTemporalLogsDir();
26
27
  return path.join(logDir, `${run.id}-${mode}-${Date.now()}.ndjson`);
27
28
  }
28
- export async function runClaude(run, mode = "execute", model, promptOverride) {
29
+ export async function runClaude(run, mode = "execute", model, opts) {
29
30
  const mcpConfig = process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
30
31
  if (!fs.existsSync(mcpConfig)) {
31
32
  throw new Error(`MCP config not found: ${mcpConfig}. Set CLAUDE_MCP_CONFIG.`);
32
33
  }
33
34
  const phaseBudget = resolveBudgetForPhase(run, mode);
34
- const resumeSessionId = mode === "execute" && humanFeedbackText(run) ? lineageParentExecuteSessionId(run) : null;
35
- const prompt = promptOverride ??
35
+ const resumeSessionId = opts?.resumeSessionId ??
36
+ (mode === "execute" && humanFeedbackText(run) ? lineageParentExecuteSessionId(run) : null);
37
+ const prompt = opts?.promptOverride ??
36
38
  (mode === "plan"
37
39
  ? buildPlanPrompt(run)
38
40
  : mode === "reflect"
@@ -49,19 +51,8 @@ export async function runClaude(run, mode = "execute", model, promptOverride) {
49
51
  "--mcp-config",
50
52
  mcpConfig,
51
53
  ...budgetCliArgs(phaseBudget),
52
- "--output-format",
53
- "stream-json",
54
- "--verbose",
54
+ ...buildClaudePhaseArgs(run, mode),
55
55
  ];
56
- if (mode === "plan") {
57
- args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
58
- }
59
- else if (mode === "reflect") {
60
- args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
61
- }
62
- else {
63
- args.push("--add-dir", getTemporalOutputDir(), "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
64
- }
65
56
  const { exitCode, transcript } = await spawnCli(resolveClaudeBin(), args, workspaceForRun(run));
66
57
  if (transcript)
67
58
  fs.writeFileSync(logPath, transcript);
@@ -85,12 +76,12 @@ export async function runCursor(run, mode = "execute", model) {
85
76
  fs.writeFileSync(logPath, transcript);
86
77
  return { exitCode, transcript, logPath };
87
78
  }
88
- export async function runAgent(run, mode = "execute") {
79
+ export async function runAgent(run, mode = "execute", revise) {
89
80
  const fresh = getRun(run.id) ?? run;
90
81
  const model = resolveModelForPhase(fresh, mode);
91
82
  if (fresh.runtime === "cursor_local")
92
83
  return runCursor(fresh, mode, model);
93
- return runClaude(fresh, mode, model);
84
+ return runClaude(fresh, mode, model, revise ? { promptOverride: revise.prompt, resumeSessionId: revise.resumeSessionId } : undefined);
94
85
  }
95
86
  /** @deprecated use stream-json extractPlanMarkdown via persistRunOutput */
96
87
  export function extractPlanFromTranscript(transcript) {
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildClaudePhaseArgs, DENY_SEND_TOOL } from "./claude-args.js";
4
+ const baseRun = {
5
+ id: "00000000-0000-4000-a000-000000000099",
6
+ source: "manual",
7
+ externalId: null,
8
+ externalLabel: null,
9
+ title: "Test",
10
+ description: null,
11
+ repo: null,
12
+ artifactWorkspace: null,
13
+ agentId: null,
14
+ agentName: null,
15
+ deckId: null,
16
+ deckName: null,
17
+ playbookId: null,
18
+ runtime: "claude_code",
19
+ planModel: null,
20
+ executeModel: null,
21
+ status: "running",
22
+ lineageId: null,
23
+ acceptanceCriteria: null,
24
+ approvalGatesJson: null,
25
+ budgetJson: null,
26
+ createdAt: new Date().toISOString(),
27
+ updatedAt: new Date().toISOString(),
28
+ };
29
+ function runWithCategory(taskCategory) {
30
+ return { ...baseRun, taskCategory };
31
+ }
32
+ function hasDisallowSend(args) {
33
+ const i = args.indexOf("--disallowedTools");
34
+ return i >= 0 && args[i + 1] === DENY_SEND_TOOL;
35
+ }
36
+ test("all phases deny call_service_tool", () => {
37
+ for (const mode of ["plan", "execute", "reflect"]) {
38
+ const args = buildClaudePhaseArgs(runWithCategory("code"), mode);
39
+ assert.equal(hasDisallowSend(args), true, mode);
40
+ }
41
+ });
42
+ test("execute allowlist includes list_service_tools", () => {
43
+ const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
44
+ const i = args.indexOf("--allowedTools");
45
+ assert.match(args[i + 1], /list_service_tools/);
46
+ });
47
+ test("communication and email execute omit Bash", () => {
48
+ for (const cat of ["communication", "email"]) {
49
+ const args = buildClaudePhaseArgs(runWithCategory(cat), "execute");
50
+ const i = args.indexOf("--allowedTools");
51
+ assert.doesNotMatch(args[i + 1], /\bBash\b/);
52
+ }
53
+ });
54
+ test("code execute retains Bash", () => {
55
+ const args = buildClaudePhaseArgs(runWithCategory("code"), "execute");
56
+ const i = args.indexOf("--allowedTools");
57
+ assert.match(args[i + 1], /\bBash\b/);
58
+ });
@@ -1,9 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { detectExecutionBlocker } from "@agent-dealer/shared";
4
- import { addArtifact, getLatestArtifact, getLineageParentRun } from "../repository/runs.js";
3
+ import { detectExecutionBlocker, outboundDraftKind } from "@agent-dealer/shared";
4
+ import { addArtifact, appendEvent, getLatestArtifact, getLineageParentRun, resolveBudgetForPhase } from "../repository/runs.js";
5
5
  import { getTemporalOutputDir } from "../paths.js";
6
- import { buildStreamTrace, extractPlanMarkdown, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
6
+ import { buildStreamTrace, extractOutboundDraft, extractPlanMarkdown, extractPlanTriage, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
7
7
  import { humanFeedbackText } from "./run-context.js";
8
8
  export function seedDeliverableFromParent(run) {
9
9
  const parent = getLineageParentRun(run);
@@ -44,11 +44,20 @@ export function persistRunOutput(input) {
44
44
  const raw = input.rawTranscript ?? (fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "");
45
45
  const events = parseNdjson(raw);
46
46
  const sessionId = extractSessionId(events);
47
- const resultText = extractResultText(events);
47
+ let resultText = extractResultText(events);
48
+ const outboundExtract = phase === "execute" ? extractOutboundDraft(resultText ?? "") : null;
49
+ if (outboundExtract) {
50
+ resultText = outboundExtract.markdown || resultText;
51
+ }
48
52
  const streamError = extractResultIsError(events);
49
53
  const blocker = detectExecutionBlocker(resultText);
50
54
  const isError = exitCode !== 0 || streamError || blocker.detected;
51
55
  const usage = extractUsage(events, phase, runtime);
56
+ const caps = resolveBudgetForPhase(run, phase);
57
+ if (caps?.maxTurns != null)
58
+ usage.maxTurns = caps.maxTurns;
59
+ if (caps?.maxBudgetUsd != null)
60
+ usage.maxBudgetUsd = caps.maxBudgetUsd;
52
61
  const trace = buildStreamTrace(events);
53
62
  if (sessionId) {
54
63
  addArtifact(run.id, "agent_session", { phase, runtime, sessionId }, "agent");
@@ -63,15 +72,45 @@ export function persistRunOutput(input) {
63
72
  blocker: blocker.detected ? { summary: blocker.summary } : undefined,
64
73
  }, "agent");
65
74
  addArtifact(run.id, "transcript", { phase, exitCode, excerpt: raw.slice(-20000), resultText: resultText?.slice(0, 8000) }, "agent", logPath);
66
- if (phase === "execute") {
75
+ if (phase === "execute" && outboundExtract) {
67
76
  captureDocumentArtifact(run, resultText);
77
+ persistOutboundDraft(run, outboundExtract);
68
78
  }
69
79
  if (phase === "plan") {
70
- const planMarkdown = extractPlanMarkdown(events);
71
- return { planMarkdown, resultText, sessionId, blocked: blocker.detected, blockerSummary: blocker.summary };
80
+ const rawPlan = extractPlanMarkdown(events);
81
+ const planTriage = extractPlanTriage(rawPlan);
82
+ return {
83
+ planMarkdown: planTriage.markdown,
84
+ planTriage,
85
+ resultText,
86
+ sessionId,
87
+ blocked: blocker.detected,
88
+ blockerSummary: blocker.summary,
89
+ };
72
90
  }
73
91
  return { resultText, sessionId, blocked: blocker.detected, blockerSummary: blocker.summary };
74
92
  }
93
+ function persistOutboundDraft(run, outbound) {
94
+ if (!outbound.hadJsonBlock)
95
+ return;
96
+ if (outbound.invalid) {
97
+ appendEvent(run.id, "outbound_draft_invalid", { reason: "malformed block" });
98
+ return;
99
+ }
100
+ if (!outbound.draft)
101
+ return;
102
+ if (outbound.mismatch) {
103
+ appendEvent(run.id, "outbound_draft_mismatch", {
104
+ summaryBody: outbound.draft.summary.body,
105
+ toolCall: outbound.draft.toolCall,
106
+ });
107
+ }
108
+ addArtifact(run.id, outboundDraftKind(outbound.draft.actionType), {
109
+ draft: outbound.draft,
110
+ status: "pending",
111
+ bodyMismatch: outbound.mismatch || undefined,
112
+ }, "agent");
113
+ }
75
114
  function captureDocumentArtifact(run, resultText) {
76
115
  const docPath = expectedDocPath(run);
77
116
  if (fs.existsSync(docPath)) {