stagent 0.1.7 → 0.1.10

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 (63) hide show
  1. package/README.md +129 -47
  2. package/dist/cli.js +16 -24
  3. package/package.json +1 -1
  4. package/public/readme/cost-usage-list.png +0 -0
  5. package/public/readme/dashboard-bulk-select.png +0 -0
  6. package/public/readme/dashboard-card-edit.png +0 -0
  7. package/public/readme/dashboard-create-form-ai-applied.png +0 -0
  8. package/public/readme/dashboard-create-form-ai-assist.png +0 -0
  9. package/public/readme/dashboard-create-form-empty.png +0 -0
  10. package/public/readme/dashboard-create-form-filled.png +0 -0
  11. package/public/readme/dashboard-filtered.png +0 -0
  12. package/public/readme/dashboard-list.png +0 -0
  13. package/public/readme/dashboard-sorted.png +0 -0
  14. package/public/readme/dashboard-workflow-confirm.png +0 -0
  15. package/public/readme/documents-grid.png +0 -0
  16. package/public/readme/documents-list.png +0 -0
  17. package/public/readme/home-below-fold.png +0 -0
  18. package/public/readme/home-list.png +0 -0
  19. package/public/readme/inbox-list.png +0 -0
  20. package/public/readme/monitor-list.png +0 -0
  21. package/public/readme/profiles-list.png +0 -0
  22. package/public/readme/projects-detail.png +0 -0
  23. package/public/readme/projects-list.png +0 -0
  24. package/public/readme/schedules-list.png +0 -0
  25. package/public/readme/settings-list.png +0 -0
  26. package/public/readme/workflows-list.png +0 -0
  27. package/src/app/api/documents/route.ts +21 -2
  28. package/src/app/api/tasks/route.ts +16 -3
  29. package/src/app/api/uploads/route.ts +17 -3
  30. package/src/app/api/workflows/from-assist/route.ts +143 -0
  31. package/src/app/dashboard/page.tsx +24 -2
  32. package/src/app/globals.css +34 -0
  33. package/src/app/tasks/new/page.tsx +10 -2
  34. package/src/app/workflows/from-assist/page.tsx +35 -0
  35. package/src/components/projects/project-card.tsx +47 -35
  36. package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
  37. package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
  38. package/src/components/tasks/ai-assist-panel.tsx +80 -21
  39. package/src/components/tasks/kanban-board.tsx +201 -5
  40. package/src/components/tasks/kanban-column.tsx +156 -5
  41. package/src/components/tasks/task-card.tsx +201 -44
  42. package/src/components/tasks/task-create-panel.tsx +42 -2
  43. package/src/components/tasks/task-detail-view.tsx +58 -1
  44. package/src/components/tasks/task-edit-dialog.tsx +277 -0
  45. package/src/components/workflows/workflow-confirmation-view.tsx +447 -0
  46. package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
  47. package/src/hooks/use-persisted-state.ts +40 -0
  48. package/src/lib/agents/claude-agent.ts +17 -7
  49. package/src/lib/agents/profiles/__tests__/suggest.test.ts +67 -0
  50. package/src/lib/agents/profiles/suggest.ts +36 -0
  51. package/src/lib/agents/runtime/claude-sdk.ts +20 -6
  52. package/src/lib/agents/runtime/claude.ts +59 -11
  53. package/src/lib/agents/runtime/openai-codex.ts +14 -1
  54. package/src/lib/agents/runtime/task-assist-types.ts +12 -2
  55. package/src/lib/data/__tests__/clear.test.ts +42 -0
  56. package/src/lib/data/clear.ts +3 -0
  57. package/src/lib/db/bootstrap.ts +17 -32
  58. package/src/lib/documents/cleanup.ts +3 -2
  59. package/src/lib/notifications/permissions.ts +7 -1
  60. package/src/lib/workflows/__tests__/assist-builder.test.ts +255 -0
  61. package/src/lib/workflows/assist-builder.ts +248 -0
  62. package/src/lib/workflows/assist-session.ts +78 -0
  63. package/src/lib/workflows/engine.ts +48 -3
@@ -0,0 +1,447 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "@/components/ui/select";
16
+ import {
17
+ ArrowUp,
18
+ ArrowDown,
19
+ Star,
20
+ Trash2,
21
+ Loader2,
22
+ GitBranch,
23
+ ListOrdered,
24
+ Settings,
25
+ } from "lucide-react";
26
+ import { toast } from "sonner";
27
+ import { FormSectionCard } from "@/components/shared/form-section-card";
28
+ import type { TaskAssistResponse } from "@/lib/agents/runtime/task-assist-types";
29
+ import type { WorkflowPattern } from "@/lib/workflows/types";
30
+ import { suggestProfileForStep } from "@/lib/agents/profiles/suggest";
31
+ import {
32
+ loadAssistState,
33
+ clearAssistState,
34
+ saveTaskFormState,
35
+ } from "@/lib/workflows/assist-session";
36
+
37
+ const PATTERN_OPTIONS: { value: WorkflowPattern; label: string; description: string }[] = [
38
+ { value: "sequence", label: "Sequence", description: "Steps run one after another" },
39
+ { value: "planner-executor", label: "Planner → Executor", description: "First step plans, rest execute" },
40
+ { value: "checkpoint", label: "Checkpoint", description: "Steps pause for human approval" },
41
+ { value: "parallel", label: "Parallel", description: "Independent steps run concurrently" },
42
+ { value: "loop", label: "Loop", description: "Single step repeats iteratively" },
43
+ { value: "swarm", label: "Swarm", description: "Mayor coordinates workers" },
44
+ ];
45
+
46
+ interface StepItem {
47
+ title: string;
48
+ description: string;
49
+ profile: string;
50
+ requiresApproval?: boolean;
51
+ }
52
+
53
+ interface ProfileOption {
54
+ id: string;
55
+ name: string;
56
+ }
57
+
58
+ interface WorkflowConfirmationViewProps {
59
+ projects: { id: string; name: string }[];
60
+ profiles: ProfileOption[];
61
+ }
62
+
63
+ export function WorkflowConfirmationView({
64
+ projects,
65
+ profiles,
66
+ }: WorkflowConfirmationViewProps) {
67
+ const router = useRouter();
68
+ const [workflowName, setWorkflowName] = useState("");
69
+ const [pattern, setPattern] = useState<WorkflowPattern>("sequence");
70
+ const [selectedProjectId, setSelectedProjectId] = useState("");
71
+ const [steps, setSteps] = useState<StepItem[]>([]);
72
+ const [maxIterations, setMaxIterations] = useState(5);
73
+ const [workerConcurrencyLimit, setWorkerConcurrencyLimit] = useState(2);
74
+ const [submitting, setSubmitting] = useState(false);
75
+ const [priority, setPriority] = useState(2);
76
+ const [assignedAgent, setAssignedAgent] = useState<string | undefined>();
77
+ const [loaded, setLoaded] = useState(false);
78
+
79
+ // Load state from sessionStorage on mount
80
+ useEffect(() => {
81
+ const state = loadAssistState();
82
+ if (!state) {
83
+ router.replace("/tasks/new");
84
+ return;
85
+ }
86
+
87
+ const { assistResult, formState } = state;
88
+ const recommended = assistResult.recommendedPattern;
89
+ const validPattern = recommended === "single" ? "sequence" : (recommended as WorkflowPattern);
90
+
91
+ setPattern(validPattern);
92
+ setWorkflowName(`Workflow: ${formState.title}`);
93
+ setSelectedProjectId(formState.projectId);
94
+ setPriority(parseInt(formState.priority, 10) || 2);
95
+ setAssignedAgent(formState.assignedAgent || undefined);
96
+ setMaxIterations(assistResult.suggestedLoopConfig?.maxIterations ?? 5);
97
+ setWorkerConcurrencyLimit(assistResult.suggestedSwarmConfig?.workerConcurrencyLimit ?? 2);
98
+
99
+ const profileIds = profiles.map((p) => p.id);
100
+
101
+ const newSteps: StepItem[] = [
102
+ {
103
+ title: formState.title,
104
+ description: formState.description,
105
+ profile: formState.agentProfile || "auto",
106
+ },
107
+ ...assistResult.breakdown.map((sub) => ({
108
+ title: sub.title,
109
+ description: sub.description,
110
+ profile: sub.suggestedProfile || suggestProfileForStep(sub.title, sub.description, profileIds),
111
+ requiresApproval: sub.requiresApproval,
112
+ })),
113
+ ];
114
+
115
+ setSteps(newSteps);
116
+ setLoaded(true);
117
+ }, [router, profiles]);
118
+
119
+ function navigateBackToTask() {
120
+ // Save form state so task form can restore
121
+ const state = loadAssistState();
122
+ if (state) {
123
+ saveTaskFormState(state.formState);
124
+ }
125
+ clearAssistState();
126
+ router.push("/tasks/new?restore=1");
127
+ }
128
+
129
+ function moveStep(index: number, direction: -1 | 1) {
130
+ if (index === 0) return;
131
+ const newIndex = index + direction;
132
+ if (newIndex < 1 || newIndex >= steps.length) return;
133
+ const newSteps = [...steps];
134
+ [newSteps[index], newSteps[newIndex]] = [newSteps[newIndex], newSteps[index]];
135
+ setSteps(newSteps);
136
+ }
137
+
138
+ function removeStep(index: number) {
139
+ if (index === 0 || steps.length <= 2) return;
140
+ setSteps(steps.filter((_, i) => i !== index));
141
+ }
142
+
143
+ function updateStepProfile(index: number, profile: string) {
144
+ const newSteps = [...steps];
145
+ newSteps[index] = { ...newSteps[index], profile };
146
+ setSteps(newSteps);
147
+ }
148
+
149
+ async function handleSubmit(executeImmediately: boolean) {
150
+ if (!workflowName.trim() || steps.length < 2) return;
151
+ setSubmitting(true);
152
+
153
+ try {
154
+ const definitionSteps = steps.map((step, i) => ({
155
+ id: `step_${i + 1}`,
156
+ name: step.title,
157
+ prompt: step.description,
158
+ agentProfile: step.profile === "auto" ? undefined : step.profile,
159
+ requiresApproval: pattern === "checkpoint" ? step.requiresApproval : undefined,
160
+ ...(pattern === "parallel" && i === steps.length - 1
161
+ ? { dependsOn: steps.slice(0, -1).map((_, j) => `step_${j + 1}`) }
162
+ : {}),
163
+ }));
164
+
165
+ const definition: Record<string, unknown> = {
166
+ pattern,
167
+ steps: definitionSteps,
168
+ };
169
+
170
+ if (pattern === "loop") {
171
+ definition.loopConfig = {
172
+ maxIterations,
173
+ agentProfile: steps[0]?.profile === "auto" ? undefined : steps[0]?.profile,
174
+ };
175
+ }
176
+
177
+ if (pattern === "swarm") {
178
+ definition.swarmConfig = { workerConcurrencyLimit };
179
+ }
180
+
181
+ const res = await fetch("/api/workflows/from-assist", {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({
185
+ name: workflowName.trim(),
186
+ projectId: selectedProjectId || undefined,
187
+ definition,
188
+ priority,
189
+ assignedAgent: assignedAgent || undefined,
190
+ executeImmediately,
191
+ parentTask: {
192
+ title: steps[0].title,
193
+ description: steps[0].description,
194
+ agentProfile: steps[0].profile === "auto" ? undefined : steps[0].profile,
195
+ },
196
+ }),
197
+ });
198
+
199
+ if (!res.ok) {
200
+ const data = await res.json().catch(() => null);
201
+ toast.error(data?.error ?? "Failed to create workflow");
202
+ return;
203
+ }
204
+
205
+ const data = await res.json();
206
+ toast.success(
207
+ executeImmediately
208
+ ? `Task created with workflow (${data.taskIds.length} steps)`
209
+ : `Task created with draft workflow (${data.taskIds.length} steps)`,
210
+ {
211
+ action: {
212
+ label: "View workflow",
213
+ onClick: () => window.open(`/workflows/${data.workflow.id}`, "_self"),
214
+ },
215
+ }
216
+ );
217
+ clearAssistState();
218
+ router.push("/dashboard");
219
+ } catch {
220
+ toast.error("Network error — could not create workflow");
221
+ } finally {
222
+ setSubmitting(false);
223
+ }
224
+ }
225
+
226
+ const isReorderDisabled = pattern === "parallel";
227
+
228
+ if (!loaded) {
229
+ return (
230
+ <div className="flex items-center justify-center py-12 text-muted-foreground">
231
+ <Loader2 className="h-5 w-5 animate-spin mr-2" />
232
+ Loading workflow data...
233
+ </div>
234
+ );
235
+ }
236
+
237
+ return (
238
+ <div className="space-y-6">
239
+ <div>
240
+ <h1 className="text-2xl font-bold">Create Workflow from AI Assist</h1>
241
+ <p className="text-sm text-muted-foreground mt-1">
242
+ Review and customize the AI-suggested workflow before creating it.
243
+ </p>
244
+ </div>
245
+
246
+ {/* Workflow Identity */}
247
+ <FormSectionCard icon={GitBranch} title="Workflow Identity">
248
+ <div className="space-y-3">
249
+ <div className="space-y-1.5">
250
+ <Label htmlFor="workflow-name">Name</Label>
251
+ <Input
252
+ id="workflow-name"
253
+ value={workflowName}
254
+ onChange={(e) => setWorkflowName(e.target.value)}
255
+ placeholder="Workflow name"
256
+ />
257
+ </div>
258
+
259
+ <div className="grid grid-cols-2 gap-3">
260
+ <div className="space-y-1.5">
261
+ <Label>Pattern</Label>
262
+ <Select value={pattern} onValueChange={(v) => setPattern(v as WorkflowPattern)}>
263
+ <SelectTrigger>
264
+ <SelectValue />
265
+ </SelectTrigger>
266
+ <SelectContent>
267
+ {PATTERN_OPTIONS.map((opt) => (
268
+ <SelectItem key={opt.value} value={opt.value}>
269
+ {opt.label}
270
+ </SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ <p className="text-xs text-muted-foreground">
275
+ {PATTERN_OPTIONS.find((o) => o.value === pattern)?.description}
276
+ </p>
277
+ </div>
278
+ <div className="space-y-1.5">
279
+ <Label>Project</Label>
280
+ <Select
281
+ value={selectedProjectId || "none"}
282
+ onValueChange={(v) => setSelectedProjectId(v === "none" ? "" : v)}
283
+ >
284
+ <SelectTrigger>
285
+ <SelectValue placeholder="None" />
286
+ </SelectTrigger>
287
+ <SelectContent>
288
+ <SelectItem value="none">None</SelectItem>
289
+ {projects.map((p) => (
290
+ <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
291
+ ))}
292
+ </SelectContent>
293
+ </Select>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </FormSectionCard>
298
+
299
+ {/* Steps */}
300
+ <FormSectionCard icon={ListOrdered} title={`Steps (${steps.length})`}>
301
+ <div className="space-y-2">
302
+ {steps.map((step, i) => (
303
+ <div
304
+ key={i}
305
+ className="flex items-center gap-2 p-2.5 rounded-md border bg-card text-sm"
306
+ >
307
+ <span className="text-xs text-muted-foreground w-5 shrink-0">
308
+ {i + 1}.
309
+ </span>
310
+ {i === 0 && (
311
+ <Badge variant="outline" className="text-xs shrink-0 px-1">
312
+ <Star className="h-2.5 w-2.5" />
313
+ </Badge>
314
+ )}
315
+ <div className="flex-1 min-w-0">
316
+ <span className="font-medium block truncate">{step.title}</span>
317
+ {step.description && (
318
+ <span className="text-xs text-muted-foreground block truncate">
319
+ {step.description}
320
+ </span>
321
+ )}
322
+ </div>
323
+
324
+ {/* Profile selector */}
325
+ <Select
326
+ value={step.profile}
327
+ onValueChange={(v) => updateStepProfile(i, v)}
328
+ >
329
+ <SelectTrigger className="w-32 h-7 text-xs shrink-0">
330
+ <SelectValue />
331
+ </SelectTrigger>
332
+ <SelectContent>
333
+ <SelectItem value="auto">Auto</SelectItem>
334
+ {profiles.map((p) => (
335
+ <SelectItem key={p.id} value={p.id}>
336
+ {p.name}
337
+ </SelectItem>
338
+ ))}
339
+ </SelectContent>
340
+ </Select>
341
+
342
+ {/* Reorder buttons */}
343
+ {!isReorderDisabled && (
344
+ <div className="flex flex-col gap-0.5">
345
+ <button
346
+ type="button"
347
+ onClick={() => moveStep(i, -1)}
348
+ disabled={i <= 1}
349
+ className="p-0.5 rounded hover:bg-muted disabled:opacity-30"
350
+ aria-label="Move step up"
351
+ >
352
+ <ArrowUp className="h-3 w-3" />
353
+ </button>
354
+ <button
355
+ type="button"
356
+ onClick={() => moveStep(i, 1)}
357
+ disabled={i === 0 || i === steps.length - 1}
358
+ className="p-0.5 rounded hover:bg-muted disabled:opacity-30"
359
+ aria-label="Move step down"
360
+ >
361
+ <ArrowDown className="h-3 w-3" />
362
+ </button>
363
+ </div>
364
+ )}
365
+
366
+ {/* Remove button */}
367
+ {i > 0 && steps.length > 2 && (
368
+ <button
369
+ type="button"
370
+ onClick={() => removeStep(i)}
371
+ className="p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
372
+ aria-label={`Remove step ${step.title}`}
373
+ >
374
+ <Trash2 className="h-3 w-3" />
375
+ </button>
376
+ )}
377
+ </div>
378
+ ))}
379
+ </div>
380
+ </FormSectionCard>
381
+
382
+ {/* Pattern-specific config */}
383
+ {(pattern === "loop" || pattern === "swarm") && (
384
+ <FormSectionCard icon={Settings} title="Configuration">
385
+ {pattern === "loop" && (
386
+ <div className="space-y-1.5">
387
+ <Label htmlFor="max-iterations">Max Iterations</Label>
388
+ <Input
389
+ id="max-iterations"
390
+ type="number"
391
+ min={1}
392
+ max={50}
393
+ value={maxIterations}
394
+ onChange={(e) => setMaxIterations(parseInt(e.target.value, 10) || 5)}
395
+ className="w-32"
396
+ />
397
+ </div>
398
+ )}
399
+ {pattern === "swarm" && (
400
+ <div className="space-y-1.5">
401
+ <Label htmlFor="concurrency-limit">Worker Concurrency Limit</Label>
402
+ <Input
403
+ id="concurrency-limit"
404
+ type="number"
405
+ min={1}
406
+ max={5}
407
+ value={workerConcurrencyLimit}
408
+ onChange={(e) => setWorkerConcurrencyLimit(parseInt(e.target.value, 10) || 2)}
409
+ className="w-32"
410
+ />
411
+ </div>
412
+ )}
413
+ <div className="flex items-center gap-2 text-xs text-muted-foreground opacity-50 mt-2">
414
+ <input type="checkbox" disabled />
415
+ <span>Save as Blueprint (coming soon)</span>
416
+ </div>
417
+ </FormSectionCard>
418
+ )}
419
+
420
+ {/* Action bar */}
421
+ <div className="flex justify-end gap-2 pt-2">
422
+ <Button
423
+ variant="ghost"
424
+ onClick={navigateBackToTask}
425
+ disabled={submitting}
426
+ >
427
+ Dismiss
428
+ </Button>
429
+ <Button
430
+ variant="outline"
431
+ onClick={() => handleSubmit(false)}
432
+ disabled={submitting || !workflowName.trim() || steps.length < 2}
433
+ >
434
+ {submitting ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
435
+ Accept
436
+ </Button>
437
+ <Button
438
+ onClick={() => handleSubmit(true)}
439
+ disabled={submitting || !workflowName.trim() || steps.length < 2}
440
+ >
441
+ {submitting ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : null}
442
+ Accept & Run
443
+ </Button>
444
+ </div>
445
+ </div>
446
+ );
447
+ }
@@ -0,0 +1,57 @@
1
+ import { renderHook, act } from "@testing-library/react";
2
+ import { usePersistedState } from "@/hooks/use-persisted-state";
3
+
4
+ describe("usePersistedState", () => {
5
+ beforeEach(() => {
6
+ localStorage.clear();
7
+ });
8
+
9
+ it("returns default value when localStorage is empty", () => {
10
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
11
+ expect(result.current[0]).toBe("default");
12
+ });
13
+
14
+ it("reads stored value from localStorage on mount", () => {
15
+ localStorage.setItem("test-key", "stored-value");
16
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
17
+ // After effect runs, value should be the stored one
18
+ expect(result.current[0]).toBe("stored-value");
19
+ });
20
+
21
+ it("writes to localStorage when setter is called", () => {
22
+ const { result } = renderHook(() => usePersistedState("test-key", "default"));
23
+
24
+ act(() => {
25
+ result.current[1]("new-value");
26
+ });
27
+
28
+ expect(result.current[0]).toBe("new-value");
29
+ expect(localStorage.getItem("test-key")).toBe("new-value");
30
+ });
31
+
32
+ it("handles localStorage errors gracefully", () => {
33
+ const origGetItem = Storage.prototype.getItem;
34
+ Storage.prototype.getItem = () => {
35
+ throw new Error("Quota exceeded");
36
+ };
37
+
38
+ const { result } = renderHook(() => usePersistedState("test-key", "fallback"));
39
+ expect(result.current[0]).toBe("fallback");
40
+
41
+ Storage.prototype.getItem = origGetItem;
42
+ });
43
+
44
+ it("maintains independent values for different keys", () => {
45
+ const { result: hookA } = renderHook(() => usePersistedState("key-a", "a-default"));
46
+ const { result: hookB } = renderHook(() => usePersistedState("key-b", "b-default"));
47
+
48
+ act(() => {
49
+ hookA.current[1]("a-updated");
50
+ });
51
+
52
+ expect(hookA.current[0]).toBe("a-updated");
53
+ expect(hookB.current[0]).toBe("b-default");
54
+ expect(localStorage.getItem("key-a")).toBe("a-updated");
55
+ expect(localStorage.getItem("key-b")).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+
5
+ /**
6
+ * useState wrapper that persists to localStorage.
7
+ * Reads from storage in useEffect to avoid SSR hydration mismatch.
8
+ */
9
+ export function usePersistedState<T extends string>(
10
+ key: string,
11
+ defaultValue: T
12
+ ): [T, (value: T) => void] {
13
+ const [value, setValue] = useState<T>(defaultValue);
14
+
15
+ // Restore from localStorage on mount (client-only)
16
+ useEffect(() => {
17
+ try {
18
+ const stored = localStorage.getItem(key);
19
+ if (stored !== null) {
20
+ setValue(stored as T);
21
+ }
22
+ } catch {
23
+ // localStorage unavailable (e.g. private browsing)
24
+ }
25
+ }, [key]);
26
+
27
+ const setPersisted = useCallback(
28
+ (next: T) => {
29
+ setValue(next);
30
+ try {
31
+ localStorage.setItem(key, next);
32
+ } catch {
33
+ // localStorage unavailable
34
+ }
35
+ },
36
+ [key]
37
+ );
38
+
39
+ return [value, setPersisted];
40
+ }
@@ -1,4 +1,5 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { z } from "zod";
2
3
  import { db } from "@/lib/db";
3
4
  import { tasks, projects, agentLogs, notifications } from "@/lib/db/schema";
4
5
  import { eq } from "drizzle-orm";
@@ -49,11 +50,13 @@ interface TaskUsageState extends UsageSnapshot {
49
50
  scheduleId?: string | null;
50
51
  }
51
52
 
52
- interface ToolPermissionResponse {
53
- behavior: "allow" | "deny";
54
- updatedInput?: unknown;
55
- message?: string;
56
- }
53
+ const toolPermissionResponseSchema = z.object({
54
+ behavior: z.enum(["allow", "deny"]),
55
+ updatedInput: z.unknown().optional(),
56
+ message: z.string().optional(),
57
+ });
58
+
59
+ type ToolPermissionResponse = z.infer<typeof toolPermissionResponseSchema>;
57
60
 
58
61
  const inFlightPermissionRequests = new Map<
59
62
  string,
@@ -149,8 +152,15 @@ async function waitForToolPermissionResponse(
149
152
 
150
153
  if (notification?.response) {
151
154
  try {
152
- return JSON.parse(notification.response) as ToolPermissionResponse;
153
- } catch {
155
+ const parsed = JSON.parse(notification.response);
156
+ const validated = toolPermissionResponseSchema.safeParse(parsed);
157
+ if (validated.success) {
158
+ return validated.data;
159
+ }
160
+ console.error("[claude-agent] Invalid permission response shape:", validated.error.message);
161
+ return { behavior: "deny", message: "Invalid response format" };
162
+ } catch (err) {
163
+ console.error("[claude-agent] Failed to parse permission response:", err);
154
164
  return { behavior: "deny", message: "Invalid response format" };
155
165
  }
156
166
  }
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { suggestProfileForStep } from "../suggest";
3
+
4
+ const ALL_PROFILES = [
5
+ "general",
6
+ "researcher",
7
+ "code-reviewer",
8
+ "document-writer",
9
+ "devops-engineer",
10
+ "data-analyst",
11
+ ];
12
+
13
+ describe("suggestProfileForStep", () => {
14
+ it("suggests researcher for research-related tasks", () => {
15
+ expect(
16
+ suggestProfileForStep("Research API patterns", "Investigate best practices", ALL_PROFILES)
17
+ ).toBe("researcher");
18
+ });
19
+
20
+ it("suggests code-reviewer for review tasks", () => {
21
+ expect(
22
+ suggestProfileForStep("Security audit", "Review code for vulnerabilities", ALL_PROFILES)
23
+ ).toBe("code-reviewer");
24
+ });
25
+
26
+ it("suggests document-writer for writing tasks", () => {
27
+ expect(
28
+ suggestProfileForStep("Write documentation", "Document the API endpoints", ALL_PROFILES)
29
+ ).toBe("document-writer");
30
+ });
31
+
32
+ it("suggests devops-engineer for deployment tasks", () => {
33
+ expect(
34
+ suggestProfileForStep("Deploy to production", "Set up CI pipeline and infrastructure", ALL_PROFILES)
35
+ ).toBe("devops-engineer");
36
+ });
37
+
38
+ it("suggests data-analyst for data tasks", () => {
39
+ expect(
40
+ suggestProfileForStep("Analyze metrics", "Aggregate data and create statistics", ALL_PROFILES)
41
+ ).toBe("data-analyst");
42
+ });
43
+
44
+ it('returns "auto" when no keywords match', () => {
45
+ expect(
46
+ suggestProfileForStep("Fix the thing", "Make it work", ALL_PROFILES)
47
+ ).toBe("auto");
48
+ });
49
+
50
+ it("only suggests from available profiles", () => {
51
+ expect(
52
+ suggestProfileForStep("Research API patterns", "Investigate", ["general"])
53
+ ).toBe("auto");
54
+ });
55
+
56
+ it("picks highest-scoring profile when multiple match", () => {
57
+ // "review" + "security" + "vulnerability" = 3 hits for code-reviewer
58
+ // "investigate" = 1 hit for researcher
59
+ expect(
60
+ suggestProfileForStep(
61
+ "Security review",
62
+ "Investigate vulnerability audit",
63
+ ALL_PROFILES
64
+ )
65
+ ).toBe("code-reviewer");
66
+ });
67
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Lightweight keyword-based profile suggestion.
3
+ * Used as fallback when AI doesn't suggest a profile for a step.
4
+ */
5
+
6
+ const KEYWORD_MAP: Record<string, string[]> = {
7
+ researcher: ["research", "search", "investigate", "explore", "find", "discover", "analyze data", "survey"],
8
+ "code-reviewer": ["review", "audit", "security", "vulnerability", "lint", "inspect", "code quality"],
9
+ "document-writer": ["write", "document", "report", "summarize", "draft", "compose", "blog", "article"],
10
+ "devops-engineer": ["deploy", "ci", "infrastructure", "pipeline", "docker", "kubernetes", "terraform", "monitor"],
11
+ "data-analyst": ["analyze", "data", "statistics", "metrics", "chart", "visualization", "dashboard", "aggregate"],
12
+ };
13
+
14
+ export function suggestProfileForStep(
15
+ title: string,
16
+ description: string,
17
+ availableProfileIds: string[]
18
+ ): string {
19
+ const text = `${title} ${description}`.toLowerCase();
20
+ let bestProfile = "auto";
21
+ let bestScore = 0;
22
+
23
+ for (const [profileId, keywords] of Object.entries(KEYWORD_MAP)) {
24
+ if (!availableProfileIds.includes(profileId)) continue;
25
+ let score = 0;
26
+ for (const keyword of keywords) {
27
+ if (text.includes(keyword)) score++;
28
+ }
29
+ if (score > bestScore) {
30
+ bestScore = score;
31
+ bestProfile = profileId;
32
+ }
33
+ }
34
+
35
+ return bestScore >= 1 ? bestProfile : "auto";
36
+ }