@kendoo.agentdesk/agentdesk 0.27.0 → 0.28.0

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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/agentdesk.mjs +35 -45
  3. package/cli/agents.mjs +4 -256
  4. package/cli/bootstrap.mjs +40 -59
  5. package/cli/config.mjs +29 -4
  6. package/cli/daemon.mjs +72 -44
  7. package/cli/dotenv.mjs +96 -13
  8. package/cli/engine/agents/index.mjs +151 -0
  9. package/cli/engine/claude-auth.mjs +72 -0
  10. package/cli/engine/env.mjs +56 -0
  11. package/cli/engine/events.mjs +214 -0
  12. package/cli/engine/hooks.mjs +112 -0
  13. package/cli/engine/phases/EXECUTION.md +45 -0
  14. package/cli/engine/phases/INTAKE.md +34 -0
  15. package/cli/engine/phases/PLAN.md +26 -0
  16. package/cli/engine/phases/REVIEW.md +21 -0
  17. package/cli/engine/phases/SOLO.md +115 -0
  18. package/cli/engine/phases/SUMMARY.md +23 -0
  19. package/cli/engine/prompts.mjs +181 -0
  20. package/cli/engine/query.mjs +63 -0
  21. package/cli/engine/schemas.mjs +180 -0
  22. package/cli/engine/session.mjs +285 -0
  23. package/cli/engine/spawn.mjs +83 -0
  24. package/cli/engine/tracker/github.md +19 -0
  25. package/cli/engine/tracker/jira.md +23 -0
  26. package/cli/engine/tracker/linear.md +24 -0
  27. package/cli/engine/verdict.mjs +83 -0
  28. package/cli/init.mjs +290 -147
  29. package/cli/login.mjs +11 -3
  30. package/cli/phase-loop.mjs +78 -0
  31. package/cli/proc.mjs +131 -0
  32. package/cli/project-key.mjs +56 -0
  33. package/cli/prompt.mjs +9 -503
  34. package/cli/prompts.mjs +20 -1
  35. package/cli/security-check.mjs +1 -1
  36. package/cli/session-isolation.mjs +65 -9
  37. package/cli/session-sandbox.mjs +13 -1
  38. package/cli/setup-helpers.mjs +83 -36
  39. package/cli/team.mjs +41 -34
  40. package/cli/tracker-check.mjs +12 -2
  41. package/cli/tracker-project.mjs +93 -0
  42. package/cli/update-check.mjs +62 -0
  43. package/package.json +12 -3
  44. package/cli/orchestrator.mjs +0 -461
  45. package/cli/stream-parser.mjs +0 -216
  46. package/prompts/phased.md +0 -549
  47. package/prompts/team.md +0 -505
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "cli/",
12
- "prompts/",
13
12
  "README.md",
14
13
  "CHANGELOG.md"
15
14
  ],
@@ -22,11 +21,15 @@
22
21
  "server": "node server/index.mjs",
23
22
  "build": "vite build",
24
23
  "preview": "vite preview",
25
- "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs",
24
+ "test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs",
25
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='cli/**' --test-coverage-include='server/**' --test-coverage-lines=60 --test-coverage-branches=62 tests/*.test.mjs",
26
+ "lint": "eslint .",
27
+ "lint:fix": "eslint . --fix",
26
28
  "lint:changelog": "node scripts/lint-changelog.mjs",
27
29
  "prepublishOnly": "node scripts/lint-changelog.mjs"
28
30
  },
29
31
  "dependencies": {
32
+ "@anthropic-ai/claude-agent-sdk": "0.3.266",
30
33
  "@inquirer/prompts": "^7.10.1",
31
34
  "@radix-ui/react-avatar": "^1.1.11",
32
35
  "@radix-ui/react-dialog": "^1.1.15",
@@ -51,9 +54,15 @@
51
54
  "ws": "^8.18.0"
52
55
  },
53
56
  "devDependencies": {
57
+ "@eslint/js": "^9.39.5",
54
58
  "@vitejs/plugin-react": "^4.5.2",
55
59
  "autoprefixer": "^10.4.21",
56
60
  "concurrently": "^9.2.0",
61
+ "eslint": "^9.39.5",
62
+ "eslint-plugin-import": "^2.32.0",
63
+ "eslint-plugin-react": "^7.37.5",
64
+ "eslint-plugin-react-hooks": "^7.1.1",
65
+ "globals": "^17.12.0",
57
66
  "lucide-react": "^0.577.0",
58
67
  "postcss": "^8.5.4",
59
68
  "react": "^19.1.0",
@@ -1,461 +0,0 @@
1
- // Orchestrator — runs a single Claude process with all agents as personas
2
-
3
- import { spawn, execSync } from "child_process";
4
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
5
- import { createInterface } from "readline";
6
- import { join, dirname } from "path";
7
- import { fileURLToPath } from "url";
8
- import { buildPrompt, buildSoloPrompt, buildPhasedPrompt } from "./prompt.mjs";
9
- import { createStreamParser } from "./stream-parser.mjs";
10
- import { createScratchHome } from "./session-sandbox.mjs";
11
- import { wrapIsolatedSpawn, probeIsolation } from "./session-isolation.mjs";
12
- import { buildTrackerUrl } from "./tracker-url.mjs";
13
- import { resolveGitHubCreds, assertPushable, PreflightError } from "./session-preflight.mjs";
14
- import { loadDotEnv } from "./dotenv.mjs";
15
-
16
- const __dirname = dirname(fileURLToPath(import.meta.url));
17
- const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")).version;
18
-
19
- // Fetch decrypted tracker credentials from server
20
- async function fetchTrackerCredentials(projectName, apiKey, serverUrl) {
21
- if (!apiKey || !serverUrl || !projectName) return {};
22
- try {
23
- const res = await fetch(`${serverUrl}/api/projects/${projectName}/settings/credentials`, {
24
- headers: { "x-api-key": apiKey },
25
- signal: AbortSignal.timeout(5000),
26
- });
27
- if (res.ok) return await res.json();
28
- } catch {}
29
- return {};
30
- }
31
-
32
- function timestamp() {
33
- const d = new Date();
34
- return [d.getHours(), d.getMinutes(), d.getSeconds()]
35
- .map(n => String(n).padStart(2, "0")).join(":");
36
- }
37
-
38
- // Phase-specific fallback when the user hasn't set a model override.
39
- // REVIEW and SUMMARY default to haiku (fast + cheap — no heavy reasoning needed).
40
- // INTAKE/PLAN/EXECUTION default to Claude Code's default (sonnet).
41
- function defaultForPhase(phase) {
42
- if (phase === "REVIEW" || phase === "SUMMARY") return "haiku";
43
- return null;
44
- }
45
-
46
- // Resolve --model args for a given phase based on project settings.
47
- // phaseModels is { INTAKE?, PLAN?, EXECUTION?, REVIEW?, SUMMARY? } with values "opus"|"sonnet"|"haiku"|"default".
48
- // Returns [] when no override (so Claude Code picks its default).
49
- function modelArgsForPhase(phase, phaseModels) {
50
- let choice = phaseModels?.[phase];
51
- if (!choice || choice === "default") choice = defaultForPhase(phase);
52
- if (!choice) return [];
53
- return ["--model", choice];
54
- }
55
-
56
- // Display label for a phase's model.
57
- function modelLabelForPhase(phase, phaseModels) {
58
- const override = phaseModels?.[phase];
59
- if (override && override !== "default") return override;
60
- return defaultForPhase(phase) || "default";
61
- }
62
-
63
- export async function runOrchestrator({
64
- taskId, taskLink, description, createTask, tracker, config,
65
- project, team, teamSections, sessionUrl, cwd,
66
- onEvent, apiKey, serverUrl, soloAgent, childStrategy, sessionId,
67
- }) {
68
- const trackerCreds = await fetchTrackerCredentials(project?.name, apiKey, serverUrl);
69
-
70
- // Resolve creds from server + project .env BEFORE building the scratch
71
- // HOME, so the sandbox helper writes the right token (or we fail fast
72
- // when none is available — see assertPushable below).
73
- const resolvedToken = resolveGitHubCreds({ cwd, trackerCreds }).GITHUB_TOKEN;
74
- const resolvedCreds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
75
-
76
- const startTime = Date.now();
77
- let totalInputTokens = 0;
78
- let totalOutputTokens = 0;
79
- let totalSteps = 0;
80
-
81
- let lastPhase = null;
82
-
83
- function emit(event) {
84
- if (event.type === "phase:change") lastPhase = event.phase;
85
- onEvent?.({ ...event, timestamp: timestamp() });
86
- }
87
-
88
- const fullPrompt = soloAgent
89
- ? buildSoloPrompt({ agentName: soloAgent, taskId, description, tracker, config, project, sessionUrl, childStrategy, cwd })
90
- : buildPrompt({ taskId, taskLink, description, createTask, tracker, config, project, teamSections, sessionUrl, cwd });
91
-
92
- emit({
93
- type: "session:start",
94
- taskId, taskLink,
95
- title: description || taskId,
96
- project: project?.name || null,
97
- sessionNumber: 1,
98
- agents: soloAgent ? [soloAgent] : teamSections.names,
99
- cliVersion: CLI_VERSION,
100
- });
101
-
102
- // Preflight: must run AFTER session:start (so the UI has a session to
103
- // bind the error to) but BEFORE we touch the scratch sandbox or spawn
104
- // the child. Surfaces missing creds / SSH-only remotes as a clean
105
- // session error instead of letting the agent silently fail to push.
106
- try {
107
- assertPushable({ cwd, creds: resolvedCreds, projectName: project?.name });
108
- } catch (err) {
109
- if (err instanceof PreflightError) {
110
- emit({ type: "session:error", code: err.code, message: err.message });
111
- emit({ type: "session:end", duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`, steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
112
- return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: err.message };
113
- }
114
- throw err;
115
- }
116
-
117
- // Build a per-session scratch HOME that isolates THIS project's identity
118
- // from the user's global git/gh/ssh state and from other projects'
119
- // credentials. Cleanup runs at session end regardless of outcome.
120
- const sandbox = createScratchHome({
121
- projectId: project?.name,
122
- sessionId,
123
- creds: resolvedCreds,
124
- commitIdentity: {
125
- name: config?.identityBadge || "AgentDesk",
126
- email: resolvedCreds.JIRA_EMAIL || `agentdesk@local`,
127
- },
128
- });
129
- const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
130
-
131
- const initialPhase = soloAgent ? "EXECUTION" : "INTAKE";
132
- // Non-phased single run spans all phases in one process — use EXECUTION's model
133
- // as the representative choice (matches where the heavy lifting happens).
134
- const representativeModel = modelLabelForPhase("EXECUTION", config?.phaseModels);
135
- emit({ type: "phase:change", phase: initialPhase, model: representativeModel });
136
-
137
- const modelArgs = modelArgsForPhase("EXECUTION", config?.phaseModels);
138
- const phaseModelsResolved = config?.phaseModels && Object.keys(config.phaseModels).length
139
- ? JSON.stringify(config.phaseModels)
140
- : "(none — using Claude Code defaults)";
141
- console.error(`[agentdesk] phaseModels: ${phaseModelsResolved}`);
142
- console.error(`[agentdesk] EXECUTION model: ${modelArgs.length ? modelArgs[1] : "default (no --model flag)"}`);
143
- const wrapped = wrapIsolatedSpawn({
144
- cmd: "claude",
145
- args: ["-p", fullPrompt, ...modelArgs, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--verbose", "--output-format", "stream-json"],
146
- cwd,
147
- scratchHome: sandbox.home,
148
- sessionId,
149
- });
150
- if (wrapped.isolation.kind !== "none") {
151
- console.error(`[agentdesk] session isolation: ${wrapped.isolation.kind}`);
152
- } else if (wrapped.isolation.reason) {
153
- console.error(`[agentdesk] hard isolation unavailable (${wrapped.isolation.reason}) — scoped-env only`);
154
- }
155
- const child = spawn(
156
- wrapped.cmd,
157
- wrapped.args,
158
- { stdio: ["pipe", "pipe", "inherit"], shell: false, env, cwd }
159
- );
160
- child.stdin.end();
161
-
162
- const { parseLine } = createStreamParser({
163
- teamNames: soloAgent ? [soloAgent] : teamSections.names,
164
- callbacks: {
165
- onPhaseChange({ phase }) { emit({ type: "phase:change", phase, model: representativeModel }); },
166
- onModel({ model }) { emit({ type: "session:model", model }); },
167
- onAgentMessage({ agent, tag, message }) { emit({ type: "agent:message", agent, tag, message }); },
168
- onToolUse({ agent, tool, description }) { totalSteps++; emit({ type: "tool:use", agent, tool, description }); },
169
- onToolResult({ success, summary }) { emit({ type: "tool:result", success, summary }); },
170
- onSessionUpdate({ taskId: newTaskId, title }) {
171
- if (newTaskId) {
172
- const update = { type: "session:update", taskId: newTaskId };
173
- const taskLink = buildTrackerUrl({ tracker, config, taskId: newTaskId });
174
- if (taskLink) update.taskLink = taskLink;
175
- emit(update);
176
- }
177
- if (title) emit({ type: "session:update", title });
178
- },
179
- onSessionEnd({ duration, steps, inputTokens, outputTokens }) {
180
- totalInputTokens = inputTokens;
181
- totalOutputTokens = outputTokens;
182
- totalSteps = steps;
183
- },
184
- },
185
- });
186
-
187
- const rl = createInterface({ input: child.stdout });
188
- for await (const line of rl) {
189
- parseLine(line);
190
- }
191
-
192
- const exitCode = await new Promise(resolve => child.on("close", resolve));
193
-
194
- const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
195
-
196
- // Detect limit/crash — non-zero exit without a clean session end
197
- const isHandoff = exitCode !== 0;
198
-
199
- if (isHandoff) {
200
- // Write mechanical resume snapshot
201
- let branch = "";
202
- let diffStat = "";
203
- try { branch = execSync("git branch --show-current", { cwd, encoding: "utf-8" }).trim(); } catch {}
204
- try { diffStat = execSync("git diff --stat HEAD", { cwd, encoding: "utf-8" }).trim(); } catch {}
205
-
206
- const resumePath = join(cwd, ".agentdesk-resume.md");
207
- const resumeContent = [
208
- `# AgentDesk Resume — ${taskId}`,
209
- ``,
210
- `Session: ${sessionUrl}`,
211
- `Date: ${new Date().toISOString()}`,
212
- `Phase: ${lastPhase || "UNKNOWN"}`,
213
- `Duration: ${duration}`,
214
- `Steps: ${totalSteps}`,
215
- `Exit code: ${exitCode}`,
216
- ``,
217
- `## Branch`,
218
- branch || "(no branch)",
219
- ``,
220
- `## Uncommitted changes`,
221
- diffStat || "(none)",
222
- ``,
223
- `## Notes`,
224
- `Session ended unexpectedly (likely Claude rate/context limit).`,
225
- `Resume with: agentdesk team ${taskId}`,
226
- ``,
227
- ].join("\n");
228
- try { writeFileSync(resumePath, resumeContent); } catch {}
229
-
230
- emit({ type: "session:end", duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, status: "handoff" });
231
- } else {
232
- // Clean exit — remove stale resume file if present
233
- const resumePath = join(cwd, ".agentdesk-resume.md");
234
- try { if (existsSync(resumePath)) unlinkSync(resumePath); } catch {}
235
-
236
- emit({ type: "session:end", duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens });
237
- }
238
-
239
- sandbox.cleanup();
240
-
241
- return { duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, handoff: isHandoff };
242
- }
243
-
244
- // --- Phased orchestrator: runs 3 sequential Claude processes ---
245
-
246
- async function runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs = [], scratchHome, sessionId, tracker, config }) {
247
- const wrapped = wrapIsolatedSpawn({
248
- cmd: "claude",
249
- args: ["-p", prompt, ...modelArgs, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--verbose", "--output-format", "stream-json"],
250
- cwd,
251
- scratchHome,
252
- sessionId,
253
- });
254
- const child = spawn(
255
- wrapped.cmd,
256
- wrapped.args,
257
- { stdio: ["pipe", "pipe", "inherit"], shell: false, env, cwd }
258
- );
259
- child.stdin.end();
260
-
261
- let inputTokens = 0, outputTokens = 0, steps = 0, lastPhase = null;
262
-
263
- const { parseLine } = createStreamParser({
264
- teamNames,
265
- callbacks: {
266
- onPhaseChange({ phase }) { lastPhase = phase; },
267
- onModel({ model }) { emit({ type: "session:model", model }); },
268
- onAgentMessage({ agent, tag, message }) { emit({ type: "agent:message", agent, tag, message }); },
269
- onToolUse({ agent, tool, description }) { steps++; emit({ type: "tool:use", agent, tool, description }); },
270
- onToolResult({ success, summary }) { emit({ type: "tool:result", success, summary }); },
271
- onSessionUpdate({ taskId: newTaskId, title }) {
272
- if (newTaskId) {
273
- const update = { type: "session:update", taskId: newTaskId };
274
- const taskLink = buildTrackerUrl({ tracker, config, taskId: newTaskId });
275
- if (taskLink) update.taskLink = taskLink;
276
- emit(update);
277
- }
278
- if (title) emit({ type: "session:update", title });
279
- },
280
- onSessionEnd({ inputTokens: iT, outputTokens: oT, steps: s }) {
281
- inputTokens = iT; outputTokens = oT; steps = s;
282
- },
283
- },
284
- });
285
-
286
- const rl = createInterface({ input: child.stdout });
287
- for await (const line of rl) {
288
- parseLine(line);
289
- }
290
-
291
- const exitCode = await new Promise(resolve => child.on("close", resolve));
292
- return { exitCode, inputTokens, outputTokens, steps, child };
293
- }
294
-
295
- export async function runPhasedOrchestrator({
296
- taskId, taskLink, description, createTask, tracker, config,
297
- project, team, teamSections, sessionUrl, cwd,
298
- onEvent, apiKey, serverUrl, onChild, sessionId,
299
- }) {
300
- const trackerCreds = await fetchTrackerCredentials(project?.name, apiKey, serverUrl);
301
-
302
- // Resolve creds (server + project .env) before any scratch HOME setup
303
- // so preflight can fail fast and the sandbox helper bakes the right
304
- // token. Server creds win over .env when both are present.
305
- const resolvedToken = resolveGitHubCreds({ cwd, trackerCreds }).GITHUB_TOKEN;
306
- const resolvedCreds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
307
-
308
- const startTime = Date.now();
309
- const teamNames = teamSections.names;
310
-
311
- function timestamp() {
312
- const d = new Date();
313
- return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, "0")).join(":");
314
- }
315
-
316
- function emit(event) {
317
- onEvent?.({ ...event, timestamp: timestamp() });
318
- }
319
-
320
- // Emit session start once
321
- emit({
322
- type: "session:start",
323
- taskId, taskLink,
324
- title: description || taskId,
325
- project: project?.name || null,
326
- sessionNumber: 1,
327
- agents: teamNames,
328
- cliVersion: CLI_VERSION,
329
- });
330
-
331
- // Preflight: AFTER session:start so the UI can bind the error, BEFORE
332
- // touching the scratch sandbox or spawning a child. Same contract as
333
- // runOrchestrator above.
334
- try {
335
- assertPushable({ cwd, creds: resolvedCreds, projectName: project?.name });
336
- } catch (err) {
337
- if (err instanceof PreflightError) {
338
- emit({ type: "session:error", code: err.code, message: err.message });
339
- emit({ type: "session:end", duration: `${((Date.now() - startTime) / 1000).toFixed(1)}s`, steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
340
- return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: err.message };
341
- }
342
- throw err;
343
- }
344
-
345
- const sandbox = createScratchHome({
346
- projectId: project?.name,
347
- sessionId,
348
- creds: resolvedCreds,
349
- commitIdentity: {
350
- name: config?.identityBadge || "AgentDesk",
351
- email: resolvedCreds.JIRA_EMAIL || `agentdesk@local`,
352
- },
353
- });
354
- const env = { ...process.env, ...loadDotEnv(cwd), ...sandbox.env };
355
-
356
- const probe = probeIsolation();
357
- if (probe.kind !== "none") {
358
- console.error(`[agentdesk] session isolation: ${probe.kind}`);
359
- } else {
360
- console.error(`[agentdesk] hard isolation unavailable (${probe.reason}) — scoped-env only`);
361
- }
362
-
363
- const sessionMemoryPath = join(cwd, ".agentdesk", "session-memory.md");
364
- const reviewVerdictPath = join(cwd, ".agentdesk", "review-verdict.md");
365
- const MAX_REVIEW_RETRIES = 1; // One execution redo after a failed review, then force SUMMARY.
366
- let totalInputTokens = 0, totalOutputTokens = 0, totalSteps = 0;
367
- let handoff = false;
368
- let reviewRetries = 0;
369
-
370
- // Ordered queue of phases; REVIEW may re-enqueue EXECUTION before SUMMARY.
371
- const queue = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
372
-
373
- while (queue.length > 0) {
374
- const phase = queue.shift();
375
-
376
- let sessionMemory = "";
377
- try {
378
- if (existsSync(sessionMemoryPath)) sessionMemory = readFileSync(sessionMemoryPath, "utf-8").trim();
379
- } catch {}
380
-
381
- // Clear any stale verdict before entering REVIEW so we don't read a previous run's result.
382
- if (phase === "REVIEW") {
383
- try { if (existsSync(reviewVerdictPath)) unlinkSync(reviewVerdictPath); } catch {}
384
- }
385
-
386
- emit({ type: "phase:change", phase, model: modelLabelForPhase(phase, config?.phaseModels) });
387
-
388
- const prompt = buildPhasedPrompt({
389
- phase, taskId, taskLink, description,
390
- createTask: phase === "INTAKE" ? createTask : false,
391
- tracker, config, project, teamSections, sessionUrl, cwd, sessionMemory,
392
- });
393
-
394
- const modelArgs = modelArgsForPhase(phase, config?.phaseModels);
395
- console.error(`[agentdesk] phase ${phase} model: ${modelArgs.length ? modelArgs[1] : "default (no --model flag)"}`);
396
- const result = await runSinglePhase({ prompt, cwd, env, teamNames, emit, modelArgs, scratchHome: sandbox.home, sessionId, tracker, config });
397
-
398
- if (onChild) onChild(result.child);
399
-
400
- totalInputTokens += result.inputTokens;
401
- totalOutputTokens += result.outputTokens;
402
- totalSteps += result.steps;
403
-
404
- const hasMemory = existsSync(sessionMemoryPath);
405
-
406
- if (result.exitCode !== 0 && !hasMemory) {
407
- handoff = true;
408
-
409
- let branch = "", diffStat = "";
410
- try { branch = execSync("git branch --show-current", { cwd, encoding: "utf-8" }).trim(); } catch {}
411
- try { diffStat = execSync("git diff --stat HEAD", { cwd, encoding: "utf-8" }).trim(); } catch {}
412
-
413
- const resumePath = join(cwd, ".agentdesk-resume.md");
414
- try {
415
- writeFileSync(resumePath, [
416
- `# AgentDesk Resume — ${taskId}`,
417
- ``, `Session: ${sessionUrl}`, `Phase: ${phase}`,
418
- `Date: ${new Date().toISOString()}`,
419
- ``, `## Branch`, branch || "(none)",
420
- ``, `## Uncommitted changes`, diffStat || "(none)",
421
- ``, `## Notes`, `Phased session interrupted during ${phase}.`,
422
- ].join("\n"));
423
- } catch {}
424
- break;
425
- }
426
-
427
- // After REVIEW, check the verdict file. NEEDS_MORE_WORK -> loop back to EXECUTION (up to MAX_REVIEW_RETRIES).
428
- if (phase === "REVIEW") {
429
- let verdict = "";
430
- try {
431
- if (existsSync(reviewVerdictPath)) verdict = readFileSync(reviewVerdictPath, "utf-8").trim();
432
- } catch {}
433
-
434
- const needsMoreWork = /^NEEDS_MORE_WORK\b/i.test(verdict);
435
- if (needsMoreWork && reviewRetries < MAX_REVIEW_RETRIES) {
436
- reviewRetries++;
437
- emit({ type: "agent:message", agent: "Jane", tag: "SAY",
438
- message: `Review flagged gaps — returning to EXECUTION (retry ${reviewRetries}/${MAX_REVIEW_RETRIES}).` });
439
- queue.unshift("EXECUTION", "REVIEW"); // redo execution, then review again
440
- }
441
- }
442
- }
443
-
444
- // Clean up internal marker files regardless of outcome
445
- try { if (existsSync(reviewVerdictPath)) unlinkSync(reviewVerdictPath); } catch {}
446
-
447
- const duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
448
-
449
- if (handoff) {
450
- emit({ type: "session:end", duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, status: "handoff" });
451
- } else {
452
- // Clean exit — remove stale resume file
453
- const resumePath = join(cwd, ".agentdesk-resume.md");
454
- try { if (existsSync(resumePath)) unlinkSync(resumePath); } catch {}
455
- emit({ type: "session:end", duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens });
456
- }
457
-
458
- sandbox.cleanup();
459
-
460
- return { duration, steps: totalSteps, inputTokens: totalInputTokens, outputTokens: totalOutputTokens, handoff };
461
- }