From d233115ab967e0a392b9f891d54fde8a6c640003 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 8 Jan 2026 14:56:10 +0700 Subject: [PATCH] feat: add task management CLI and improve activity curation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add task store with dependency tracking and CLI (packages/server/src/tasks/) - Extract principal param logic to shared util for server/app reuse - Improve activity curator: collapse timeline, dedupe tool calls, simpler output - Add MessageInput ref for focus control after agent cancel - Include recent activity in wait timeout messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .tasks/0c32789c.md | 9 + .tasks/2769910f.md | 15 + .tasks/537563b5.md | 15 + .tasks/7018be3f.md | 15 + package-lock.json | 1 + packages/app/package.json | 1 + .../app/src/components/agent-input-area.tsx | 4 + packages/app/src/components/message-input.tsx | 84 ++- packages/app/src/utils/tool-call-parsers.ts | 43 +- packages/server/package.json | 3 + .../src/server/agent/activity-curator.ts | 131 ++-- .../server/src/server/agent/mcp-server.ts | 6 +- packages/server/src/tasks/cli.ts | 409 +++++++++++ packages/server/src/tasks/task-store.test.ts | 677 ++++++++++++++++++ packages/server/src/tasks/task-store.ts | 359 ++++++++++ packages/server/src/tasks/types.ts | 48 ++ .../server/src/utils/tool-call-parsers.ts | 89 +++ 17 files changed, 1779 insertions(+), 130 deletions(-) create mode 100644 .tasks/0c32789c.md create mode 100644 .tasks/2769910f.md create mode 100644 .tasks/537563b5.md create mode 100644 .tasks/7018be3f.md create mode 100644 packages/server/src/tasks/cli.ts create mode 100644 packages/server/src/tasks/task-store.test.ts create mode 100644 packages/server/src/tasks/task-store.ts create mode 100644 packages/server/src/tasks/types.ts create mode 100644 packages/server/src/utils/tool-call-parsers.ts diff --git a/.tasks/0c32789c.md b/.tasks/0c32789c.md new file mode 100644 index 000000000..db6527161 --- /dev/null +++ b/.tasks/0c32789c.md @@ -0,0 +1,9 @@ +--- +id: 0c32789c +title: Add three random numbers +status: open +deps: [2769910f, 7018be3f, 537563b5] +created: 2026-01-07T16:32:47.905Z +--- + +Epic: We will add three random numbers together. Task 1 picks a number, Task 2 adds another, Task 3 adds a third and reports the final sum. diff --git a/.tasks/2769910f.md b/.tasks/2769910f.md new file mode 100644 index 000000000..e42cee00c --- /dev/null +++ b/.tasks/2769910f.md @@ -0,0 +1,15 @@ +--- +id: 2769910f +title: Pick first random number +status: done +deps: [] +created: 2026-01-07T16:32:56.827Z +--- + +Pick a random number between 1-100. Record it in your notes as 'Number: X'. This is the starting value. + +## Notes + +**2026-01-07T16:33:43.884Z** + +Number: 47 diff --git a/.tasks/537563b5.md b/.tasks/537563b5.md new file mode 100644 index 000000000..25c800a30 --- /dev/null +++ b/.tasks/537563b5.md @@ -0,0 +1,15 @@ +--- +id: 537563b5 +title: Add third random number and report final sum +status: done +deps: [7018be3f] +created: 2026-01-07T16:32:57.495Z +--- + +Pick a final random number between 1-100. Read previous notes to get the running total. Add your number. Record in notes: 'Added: X, FINAL SUM: Y' + +## Notes + +**2026-01-07T16:34:56.240Z** + +Added: 42, FINAL SUM: 112 diff --git a/.tasks/7018be3f.md b/.tasks/7018be3f.md new file mode 100644 index 000000000..e7b58ba98 --- /dev/null +++ b/.tasks/7018be3f.md @@ -0,0 +1,15 @@ +--- +id: 7018be3f +title: Add second random number +status: done +deps: [2769910f] +created: 2026-01-07T16:32:57.142Z +--- + +Pick another random number between 1-100. Read the previous task's notes to get the running total. Add your number to it. Record in notes: 'Added: X, Running total: Y' + +## Notes + +**2026-01-07T16:34:17.347Z** + +Added: 23, Running total: 70 diff --git a/package-lock.json b/package-lock.json index fdc3ab2ad..50fc112be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21246,6 +21246,7 @@ "@lezer/json": "^1.0.3", "@lezer/markdown": "^1.6.2", "@lezer/python": "^1.1.18", + "@paseo/server": "*", "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", diff --git a/packages/app/package.json b/packages/app/package.json index c72661510..9198ecf49 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -15,6 +15,7 @@ "test": "vitest run" }, "dependencies": { + "@paseo/server": "*", "@boudra/expo-two-way-audio": "^0.1.3", "@expo/vector-icons": "^15.0.2", "@gorhom/bottom-sheet": "^5.2.6", diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 1b705a3a1..4a9c75c04 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -26,6 +26,7 @@ import { MessageInput, type MessagePayload, type ImageAttachment, + type MessageInputRef, } from "./message-input"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import { Theme } from "@/styles/theme"; @@ -132,6 +133,7 @@ export function AgentInputArea({ const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef(sendAgentMessage); const onSubmitMessageRef = useRef(onSubmitMessage); + const messageInputRef = useRef(null); // Expose addImages function to parent for drag-and-drop support const addImages = useCallback((images: ImageAttachment[]) => { @@ -338,6 +340,7 @@ export function AgentInputArea({ } setIsCancellingAgent(true); cancelAgentRun(agentIdRef.current); + messageInputRef.current?.focus(); } function handleEditQueuedMessage(id: string) { @@ -470,6 +473,7 @@ export function AgentInputArea({ {/* MessageInput handles everything: text, dictation, attachments, all buttons */} void; } +export interface MessageInputRef { + focus: () => void; +} + const MIN_INPUT_HEIGHT = 30; const MAX_INPUT_HEIGHT = 160; const IS_WEB = Platform.OS === "web"; @@ -78,35 +89,45 @@ type TextAreaHandle = { } & Record; }; -export function MessageInput({ - value, - onChangeText, - onSubmit, - isSubmitDisabled = false, - isSubmitLoading = false, - images = [], - onPickImages, - onRemoveImage, - ws, - sendAgentAudio, - placeholder = "Message...", - autoFocus = false, - disabled = false, - leftContent, - rightContent, - isAgentRunning = false, - onQueue, -}: MessageInputProps) { - const { theme } = useUnistyles(); - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); - const textInputRef = useRef< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >(null); - const inputHeightRef = useRef(MIN_INPUT_HEIGHT); - const baselineInputHeightRef = useRef(null); - const overlayTransition = useSharedValue(0); - const sendAfterTranscriptRef = useRef(false); - const valueRef = useRef(value); +export const MessageInput = forwardRef( + function MessageInput( + { + value, + onChangeText, + onSubmit, + isSubmitDisabled = false, + isSubmitLoading = false, + images = [], + onPickImages, + onRemoveImage, + ws, + sendAgentAudio, + placeholder = "Message...", + autoFocus = false, + disabled = false, + leftContent, + rightContent, + isAgentRunning = false, + onQueue, + }, + ref + ) { + const { theme } = useUnistyles(); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const textInputRef = useRef< + TextInput | (TextInput & { getNativeRef?: () => unknown }) | null + >(null); + + useImperativeHandle(ref, () => ({ + focus: () => { + textInputRef.current?.focus(); + }, + })); + const inputHeightRef = useRef(MIN_INPUT_HEIGHT); + const baselineInputHeightRef = useRef(null); + const overlayTransition = useSharedValue(0); + const sendAfterTranscriptRef = useRef(false); + const valueRef = useRef(value); useEffect(() => { valueRef.current = value; @@ -566,7 +587,8 @@ export function MessageInput({ ); -} + } +); const styles = StyleSheet.create(((theme: any) => ({ container: { diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 15d4bb58d..99066738a 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -754,39 +754,10 @@ export function extractKeyValuePairs(result: unknown): KeyValuePair[] { } // ---- Principal Parameter Extraction ---- - -const PrincipalParamSchema = z.union([ - z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })), - z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })), - z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })), - z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })), - z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })), - z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })), - z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })), -]); - -export function stripCwdPrefix(filePath: string, cwd?: string): string { - if (!cwd || !filePath) return filePath; - - const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); - const normalizedPath = filePath.replace(/\\/g, "/"); - - const prefix = `${normalizedCwd}/`; - if (normalizedPath.startsWith(prefix)) { - return normalizedPath.slice(prefix.length); - } - if (normalizedPath === normalizedCwd) { - return "."; - } - return filePath; -} - -export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined { - const parsed = PrincipalParamSchema.safeParse(args); - if (!parsed.success) { - return undefined; - } - - const { type, value } = parsed.data; - return type === "path" ? stripCwdPrefix(value, cwd) : value; -} +// Re-export from server to avoid drift +export { + extractPrincipalParam, + stripCwdPrefix, + extractTodos, + type TodoItem, +} from "@paseo/server/utils/tool-call-parsers"; diff --git a/packages/server/package.json b/packages/server/package.json index bff703129..42cfd6951 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "description": "Paseo backend server", "type": "module", + "exports": { + "./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts" + }, "scripts": { "dev": "NODE_ENV=development tsx scripts/dev-runner.ts", "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 852f07d7f..bfa29bce9 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,19 +1,8 @@ -import type { AgentTimelineItem, ToolCallKind } from "./agent-sdk-types.js"; +import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import { extractPrincipalParam } from "../../utils/tool-call-parsers.js"; const DEFAULT_MAX_ITEMS = 40; -/** - * Derive tool kind from the tool name for rendering purposes. - */ -function getToolKind(name: string): ToolCallKind { - const lower = name.toLowerCase(); - if (lower === "read" || lower === "read_file") return "read"; - if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit"; - if (lower === "bash" || lower === "shell") return "execute"; - if (lower === "grep" || lower === "glob" || lower === "web_search") return "search"; - return "other"; -} - function appendText(buffer: string, text: string): string { const normalized = text.trim(); if (!normalized) { @@ -36,37 +25,69 @@ function flushBuffers(lines: string[], buffers: { message: string; thought: stri buffers.thought = ""; } -function isObject(value: unknown): value is { [key: string]: unknown } { - return typeof value === "object" && value !== null; -} +/** + * Collapse timeline items: + * - Dedupe tool calls by callId (pending/completed -> single) + * - Merge consecutive assistant_message/reasoning into single items + */ +function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] { + const result: AgentTimelineItem[] = []; + const toolCallMap = new Map(); + let assistantBuffer = ""; + let reasoningBuffer = ""; -function isFileChange(value: unknown): value is { path: string; kind: string } { - return ( - isObject(value) && - typeof value.path === "string" && - typeof value.kind === "string" - ); -} - -function extractFileChanges(value: unknown): { path: string; kind: string }[] { - if (!isObject(value) || !Array.isArray(value.files)) { - return []; + function flushAssistant() { + if (assistantBuffer) { + result.push({ type: "assistant_message", text: assistantBuffer }); + assistantBuffer = ""; + } } - return value.files.filter(isFileChange); -} -function extractWebQuery(value: unknown): string { - if (!isObject(value) || typeof value.query !== "string") { - return ""; + function flushReasoning() { + if (reasoningBuffer) { + result.push({ type: "reasoning", text: reasoningBuffer }); + reasoningBuffer = ""; + } } - return value.query; -} -function extractCommand(value: unknown): string { - if (!isObject(value) || typeof value.command !== "string") { - return ""; + function flushToolCalls() { + for (const toolItem of toolCallMap.values()) { + result.push(toolItem); + } + toolCallMap.clear(); } - return value.command; + + for (const item of items) { + if (item.type === "assistant_message") { + flushReasoning(); + flushToolCalls(); + assistantBuffer += item.text; + } else if (item.type === "reasoning") { + flushAssistant(); + flushToolCalls(); + reasoningBuffer += item.text; + } else if (item.type === "tool_call" && item.callId) { + flushAssistant(); + flushReasoning(); + toolCallMap.set(item.callId, item); + } else if (item.type === "tool_call") { + flushAssistant(); + flushReasoning(); + flushToolCalls(); + result.push(item); + } else { + flushAssistant(); + flushReasoning(); + flushToolCalls(); + result.push(item); + } + } + + flushAssistant(); + flushReasoning(); + flushToolCalls(); + + return result; } /** @@ -80,11 +101,14 @@ export function curateAgentActivity( return "No activity to display."; } + // Collapse timeline: dedupe tool calls, merge consecutive messages + const collapsed = collapseTimeline(timeline); + const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS; const recentItems = - maxItems > 0 && timeline.length > maxItems - ? timeline.slice(-maxItems) - : timeline; + maxItems > 0 && collapsed.length > maxItems + ? collapsed.slice(-maxItems) + : collapsed; const lines: string[] = []; const buffers = { message: "", thought: "" }; @@ -103,26 +127,11 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); - const status = item.status ? ` ${item.status}` : ""; - const kind = getToolKind(item.name); - if (kind === "execute") { - const command = extractCommand(item.input); - lines.push(`[Command: ${command || item.name}]${status}`); - } else if (kind === "edit") { - const files = extractFileChanges(item.output); - if (files.length > 0) { - lines.push("[File Changes]"); - for (const file of files) { - lines.push(`- (${file.kind}) ${file.path}`); - } - } else { - lines.push(`[Edit] ${item.name}${status}`); - } - } else if (kind === "search") { - const query = extractWebQuery(item.input); - lines.push(`[Web Search] ${query || item.name}`); + const principal = extractPrincipalParam(item.input); + if (principal) { + lines.push(`[${item.name}] ${principal}`); } else { - lines.push(`[Tool ${item.name}]${status}`); + lines.push(`[${item.name}]`); } break; } @@ -143,5 +152,5 @@ export function curateAgentActivity( flushBuffers(lines, buffers); - return lines.length > 0 ? lines.join("\n\n") : "No activity to display."; + return lines.length > 0 ? lines.join("\n") : "No activity to display."; } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 5266cf554..35aab80ae 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -157,11 +157,13 @@ async function waitForAgentWithTimeout( } catch (error) { if (error instanceof Error && error.message === "wait timeout") { const snapshot = agentManager.getAgent(agentId); + const timeline = agentManager.getTimeline(agentId); + const recentActivity = curateAgentActivity(timeline.slice(-5)); + const message = `Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`; return { status: snapshot?.lifecycle ?? "idle", permission: null, - lastMessage: - "Awaiting the agent timed out. This does not mean the agent failed - call wait_for_agent again to continue waiting.", + lastMessage: message, }; } throw error; diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts new file mode 100644 index 000000000..df9a23527 --- /dev/null +++ b/packages/server/src/tasks/cli.ts @@ -0,0 +1,409 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, openSync } from "node:fs"; +import { resolve } from "node:path"; +import { FileTaskStore } from "./task-store.js"; +import type { AgentType, Task } from "./types.js"; + +const TASKS_DIR = resolve(process.cwd(), ".tasks"); +const store = new FileTaskStore(TASKS_DIR); + +const program = new Command() + .name("task") + .description("Minimal task management with dependency tracking") + .version("0.1.0") + .addHelpText( + "after", + ` +Examples: + # Create an epic with subtasks (top-down) + task create "Build auth system" + task create "Add login endpoint" --parent abc123 + task create "Add logout endpoint" --parent abc123 + + # Create with dependencies (bottom-up) + task create "Setup database" + task create "Add user model" --deps def456 + + # Assign to specific agent + task create "Complex refactor" --assignee codex + + # Create as draft (not actionable until opened) + task create "Future feature" --draft + task open abc123 # make it actionable + + # View the work breakdown + task tree abc123 + + # See what's ready to work on + task ready + task ready --scope abc123 + + # See completed work + task closed --scope abc123 + + # Run agent loop on an epic + task run abc123 + task run abc123 --agent codex + task run --watch +` + ); + +program + .command("create ") + .description("Create a new task") + .option("-d, --description <text>", "Task description") + .option("--deps <ids>", "Comma-separated dependency IDs") + .option("--parent <id>", "Parent task (parent will depend on this new task)") + .option("--assignee <agent>", "Agent to assign (claude or codex)") + .option("--draft", "Create as draft (not actionable)") + .action(async (title, opts) => { + const task = await store.create(title, { + description: opts.description, + deps: opts.deps + ? opts.deps.split(",").map((s: string) => s.trim()) + : [], + status: opts.draft ? "draft" : "open", + assignee: opts.assignee as AgentType | undefined, + }); + + if (opts.parent) { + await store.addDep(opts.parent, task.id); + } + + console.log(task.id); + }); + +program + .command("list") + .alias("ls") + .description("List all tasks") + .option("-s, --status <status>", "Filter by status") + .action(async (opts) => { + const tasks = await store.list(); + const filtered = opts.status + ? tasks.filter((t) => t.status === opts.status) + : tasks; + + for (const t of filtered) { + const deps = t.deps.length ? ` <- [${t.deps.join(", ")}]` : ""; + const assignee = t.assignee ? ` @${t.assignee}` : ""; + console.log(`${t.id} [${t.status}] ${t.title}${assignee}${deps}`); + } + }); + +program + .command("show <id>") + .description("Show task details") + .action(async (id) => { + const task = await store.get(id); + if (!task) { + console.error(`Task not found: ${id}`); + process.exit(1); + } + console.log(`id: ${task.id}`); + console.log(`title: ${task.title}`); + console.log(`status: ${task.status}`); + console.log(`created: ${task.created}`); + if (task.assignee) { + console.log(`assignee: ${task.assignee}`); + } + console.log(`deps: [${task.deps.join(", ")}]`); + if (task.description) { + console.log(`\n${task.description}`); + } + if (task.notes.length) { + console.log("\n## Notes"); + for (const note of task.notes) { + console.log(`\n**${note.timestamp}**\n${note.content}`); + } + } + }); + +program + .command("ready") + .description("List tasks ready to work on (open + deps resolved)") + .option("--scope <id>", "Scope to epic/task dep tree") + .action(async (opts) => { + const tasks = await store.getReady(opts.scope); + for (const t of tasks) { + const assignee = t.assignee ? ` @${t.assignee}` : ""; + console.log(`${t.id} ${t.title}${assignee}`); + } + }); + +program + .command("blocked") + .description("List tasks blocked by unresolved deps") + .option("--scope <id>", "Scope to epic/task dep tree") + .action(async (opts) => { + const tasks = await store.getBlocked(opts.scope); + for (const t of tasks) { + console.log(`${t.id} ${t.title} <- [${t.deps.join(", ")}]`); + } + }); + +program + .command("closed") + .description("List completed tasks") + .option("--scope <id>", "Scope to epic/task dep tree") + .action(async (opts) => { + const tasks = await store.getClosed(opts.scope); + for (const t of tasks) { + console.log(`${t.id} ${t.title}`); + } + }); + +program + .command("tree <id>") + .description("Show dependency tree") + .action(async (id) => { + const root = await store.get(id); + if (!root) { + console.error(`Task not found: ${id}`); + process.exit(1); + } + console.log(`${root.id} [${root.status}] ${root.title}`); + + const tree = await store.getDepTree(id); + const taskMap = new Map(tree.map((t) => [t.id, t])); + + const printed = new Set<string>(); + const printDeps = async (taskId: string, prefix: string) => { + const task = await store.get(taskId); + if (!task) return; + + const deps = task.deps.filter((d) => !printed.has(d)); + for (let i = 0; i < deps.length; i++) { + const depId = deps[i]; + const dep = taskMap.get(depId); + if (!dep) continue; + + printed.add(depId); + const isLast = i === deps.length - 1; + const connector = isLast ? "└── " : "├── "; + const childPrefix = isLast ? " " : "│ "; + + console.log( + `${prefix}${connector}${dep.id} [${dep.status}] ${dep.title}` + ); + await printDeps(depId, prefix + childPrefix); + } + }; + + await printDeps(id, ""); + }); + +program + .command("dep <id> <dep-id>") + .description("Add dependency (id depends on dep-id)") + .action(async (id, depId) => { + await store.addDep(id, depId); + console.log(`Added: ${id} -> ${depId}`); + }); + +program + .command("undep <id> <dep-id>") + .description("Remove dependency") + .action(async (id, depId) => { + await store.removeDep(id, depId); + console.log(`Removed: ${id} -> ${depId}`); + }); + +program + .command("note <id> <content>") + .description("Add a timestamped note") + .action(async (id, content) => { + await store.addNote(id, content); + console.log("Note added"); + }); + +program + .command("open <id>") + .description("Mark draft as open (actionable)") + .action(async (id) => { + await store.open(id); + console.log(`${id} -> open`); + }); + +program + .command("start <id>") + .description("Mark as in progress") + .action(async (id) => { + await store.start(id); + console.log(`${id} -> in_progress`); + }); + +program + .command("close <id>") + .alias("done") + .description("Mark as done") + .action(async (id) => { + await store.close(id); + console.log(`${id} -> done`); + }); + +// Agent runner +async function makePrompt( + task: Task, + scopeId: string | undefined +): Promise<string> { + const scopeArg = scopeId ? ` --scope ${scopeId}` : ""; + + let scopeContext = ""; + if (scopeId) { + const scope = await store.get(scopeId); + if (scope) { + scopeContext = `Scope: ${scope.title} (${scopeId}) +${scope.description ? `\n${scope.description}\n` : ""}`; + } + } + + return `Working directory: ${process.cwd()} +${scopeContext} +--- + +YOUR TASK (${task.id}): ${task.title} + +${task.description || "(no description)"} + +--- + +STEPS: +1. UNDERSTAND CONTEXT FIRST - Before any implementation: + - Run \`task tree ${task.id}\` to see the full dependency graph + - Run \`task closed${scopeArg}\` to see completed sibling tasks + - Run \`task show <id>\` on completed tasks to read their notes + - Understand what's been done, what decisions were made, what's planned +2. Implement the task described above +3. Add a note documenting what you did: \`task note ${task.id} "what you did"\` +4. Mark complete: \`task close ${task.id}\` + +COMMANDS: +- \`task tree <id>\` - see dependency graph from any task +- \`task show <id>\` - view task details and notes +- \`task closed${scopeArg}\` - list completed tasks in scope +- \`task note ${task.id} "content"\` - add a note to your task +- \`task close ${task.id}\` - mark your task done + +You MUST run \`task close ${task.id}\` when finished. +`; +} + +function runAgent(prompt: string, agent: AgentType, logFile: string): boolean { + const args = + agent === "claude" + ? ["--dangerously-skip-permissions", "-p", prompt] + : ["exec", "--dangerously-bypass-approvals-and-sandbox", prompt]; + + const fd = openSync(logFile, "a"); + const result = spawnSync(agent, args, { + stdio: ["inherit", fd, fd], + cwd: process.cwd(), + }); + + return result.status === 0; +} + +function getLogFile(): string { + let num = 0; + while (existsSync(`task-run.${num}.log`)) { + num++; + } + return `task-run.${num}.log`; +} + +function log(logFile: string, message: string): void { + const timestamp = new Date().toISOString(); + appendFileSync(logFile, `[${timestamp}] ${message}\n`); +} + +program + .command("run [scope]") + .description("Run agent loop on tasks") + .option("--agent <type>", "Agent to use (claude or codex)", "claude") + .option("-w, --watch", "Keep running and wait for new tasks") + .action(async (scopeId: string | undefined, opts) => { + const defaultAgent = opts.agent as AgentType; + const watchMode = opts.watch; + const logFile = getLogFile(); + + console.log("Task Runner started"); + console.log(`Agent: ${defaultAgent}`); + if (scopeId) console.log(`Scope: ${scopeId}`); + console.log(`Log: ${logFile}`); + console.log(""); + + log(logFile, `Started with agent=${defaultAgent} scope=${scopeId || "all"}`); + + const MAX_RETRIES = 3; + + const runLoop = async (): Promise<void> => { + while (true) { + const ready = await store.getReady(scopeId); + if (ready.length === 0) break; + + const task = ready[0]; + const agent = task.assignee || defaultAgent; + + console.log(`⏳ ${task.title} [${agent}]`); + log(logFile, `Starting: ${task.id} - ${task.title} [${agent}]`); + + await store.start(task.id); + const prompt = await makePrompt(task, scopeId); + + let attempt = 1; + let success = false; + + while (attempt <= MAX_RETRIES) { + success = runAgent(prompt, agent, logFile); + if (success) break; + + if (attempt < MAX_RETRIES) { + const backoff = attempt * 10; + console.log(`⚠️ Attempt ${attempt} failed, retrying in ${backoff}s...`); + log(logFile, `Attempt ${attempt} failed, retrying in ${backoff}s`); + await new Promise((r) => setTimeout(r, backoff * 1000)); + } + attempt++; + } + + if (!success) { + console.log(`❌ ${task.title} (failed after ${MAX_RETRIES} attempts)`); + log(logFile, `Failed: ${task.id} after ${MAX_RETRIES} attempts`); + process.exit(1); + } + + // Check if agent closed the task + const updated = await store.get(task.id); + if (updated?.status !== "done") { + console.log(`⚠️ Agent did not close task ${task.id}`); + log(logFile, `Warning: agent did not close task ${task.id}`); + } + + console.log(`✅ ${task.title}`); + log(logFile, `Completed: ${task.id}`); + } + }; + + await runLoop(); + + if (watchMode) { + console.log("💤 Waiting for new tasks..."); + while (true) { + await new Promise((r) => setTimeout(r, 5000)); + const ready = await store.getReady(scopeId); + if (ready.length > 0) { + await runLoop(); + console.log("💤 Waiting for new tasks..."); + } + } + } + + console.log(""); + console.log(`All tasks complete. (${new Date().toISOString()})`); + log(logFile, "All tasks complete"); + }); + +program.parse(); diff --git a/packages/server/src/tasks/task-store.test.ts b/packages/server/src/tasks/task-store.test.ts new file mode 100644 index 000000000..f3c6500a4 --- /dev/null +++ b/packages/server/src/tasks/task-store.test.ts @@ -0,0 +1,677 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FileTaskStore } from "./task-store.js"; + +describe("FileTaskStore", () => { + let tempDir: string; + let store: FileTaskStore; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "task-store-test-")); + store = new FileTaskStore(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("create", () => { + it("creates a task with default status open", async () => { + const task = await store.create("My first task"); + + expect(task.id).toMatch(/^[a-f0-9]{8}$/); + expect(task.title).toBe("My first task"); + expect(task.status).toBe("open"); + expect(task.deps).toEqual([]); + expect(task.description).toBe(""); + expect(task.notes).toEqual([]); + expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(task.assignee).toBeUndefined(); + }); + + it("creates a task with custom status", async () => { + const task = await store.create("Draft task", { status: "draft" }); + + expect(task.status).toBe("draft"); + }); + + it("creates a task with dependencies", async () => { + const dep1 = await store.create("Dependency 1"); + const dep2 = await store.create("Dependency 2"); + const task = await store.create("Main task", { + deps: [dep1.id, dep2.id], + }); + + expect(task.deps).toEqual([dep1.id, dep2.id]); + }); + + it("creates a task with description", async () => { + const task = await store.create("Task with desc", { + description: "This is a **long** description\n\nWith multiple lines.", + }); + + expect(task.description).toBe( + "This is a **long** description\n\nWith multiple lines." + ); + }); + + it("creates a task with assignee", async () => { + const task = await store.create("Task for Claude", { + assignee: "claude", + }); + + expect(task.assignee).toBe("claude"); + }); + + it("generates unique IDs for each task", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2"); + const task3 = await store.create("Task 3"); + + const ids = [task1.id, task2.id, task3.id]; + expect(new Set(ids).size).toBe(3); + }); + + it("sets created timestamp", async () => { + const before = new Date().toISOString(); + const task = await store.create("Task"); + const after = new Date().toISOString(); + + expect(task.created >= before).toBe(true); + expect(task.created <= after).toBe(true); + }); + }); + + describe("get", () => { + it("returns task by id", async () => { + const created = await store.create("Test task"); + const retrieved = await store.get(created.id); + + expect(retrieved).toEqual(created); + }); + + it("returns null for non-existent task", async () => { + const result = await store.get("nonexistent"); + + expect(result).toBeNull(); + }); + + it("preserves assignee field", async () => { + const created = await store.create("Task", { assignee: "codex" }); + const retrieved = await store.get(created.id); + + expect(retrieved?.assignee).toBe("codex"); + }); + }); + + describe("list", () => { + it("returns empty array when no tasks", async () => { + const tasks = await store.list(); + + expect(tasks).toEqual([]); + }); + + it("returns all tasks", async () => { + await store.create("Task 1"); + await store.create("Task 2"); + await store.create("Task 3"); + + const tasks = await store.list(); + + expect(tasks).toHaveLength(3); + expect(tasks.map((t) => t.title).sort()).toEqual([ + "Task 1", + "Task 2", + "Task 3", + ]); + }); + }); + + describe("update", () => { + it("updates task title", async () => { + const task = await store.create("Original title"); + const updated = await store.update(task.id, { title: "New title" }); + + expect(updated.title).toBe("New title"); + expect(updated.id).toBe(task.id); + }); + + it("updates task description", async () => { + const task = await store.create("Task"); + const updated = await store.update(task.id, { + description: "New description", + }); + + expect(updated.description).toBe("New description"); + }); + + it("updates task assignee", async () => { + const task = await store.create("Task"); + const updated = await store.update(task.id, { assignee: "claude" }); + + expect(updated.assignee).toBe("claude"); + }); + + it("persists updates", async () => { + const task = await store.create("Task"); + await store.update(task.id, { title: "Updated" }); + + const retrieved = await store.get(task.id); + expect(retrieved?.title).toBe("Updated"); + }); + + it("preserves created timestamp on update", async () => { + const task = await store.create("Task"); + const originalCreated = task.created; + + await new Promise((r) => setTimeout(r, 10)); + await store.update(task.id, { title: "Updated" }); + + const retrieved = await store.get(task.id); + expect(retrieved?.created).toBe(originalCreated); + }); + + it("throws for non-existent task", async () => { + await expect( + store.update("nonexistent", { title: "New" }) + ).rejects.toThrow(); + }); + }); + + describe("status transitions", () => { + describe("open", () => { + it("transitions draft to open", async () => { + const task = await store.create("Draft", { status: "draft" }); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("throws when task is not draft", async () => { + const task = await store.create("Open task", { status: "open" }); + + await expect(store.open(task.id)).rejects.toThrow(); + }); + }); + + describe("start", () => { + it("transitions open to in_progress", async () => { + const task = await store.create("Task"); + await store.start(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("in_progress"); + }); + + it("throws when task is draft", async () => { + const task = await store.create("Draft", { status: "draft" }); + + await expect(store.start(task.id)).rejects.toThrow(); + }); + + it("throws when task is already done", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + await expect(store.start(task.id)).rejects.toThrow(); + }); + }); + + describe("close", () => { + it("transitions open to done", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); + + it("transitions in_progress to done", async () => { + const task = await store.create("Task"); + await store.start(task.id); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); + + it("transitions draft to done", async () => { + const task = await store.create("Task", { status: "draft" }); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); + }); + }); + + describe("dependencies", () => { + describe("addDep", () => { + it("adds a dependency", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task"); + + await store.addDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toContain(dep.id); + }); + + it("does not duplicate dependencies", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task"); + + await store.addDep(task.id, dep.id); + await store.addDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([dep.id]); + }); + + it("throws for non-existent task", async () => { + const dep = await store.create("Dependency"); + + await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow(); + }); + + it("throws for non-existent dependency", async () => { + const task = await store.create("Task"); + + await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow(); + }); + }); + + describe("removeDep", () => { + it("removes a dependency", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task", { deps: [dep.id] }); + + await store.removeDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([]); + }); + + it("is idempotent for non-existent dep", async () => { + const task = await store.create("Task"); + + await store.removeDep(task.id, "nonexistent"); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([]); + }); + }); + }); + + describe("notes", () => { + it("adds a note with timestamp", async () => { + const task = await store.create("Task"); + const before = new Date().toISOString(); + + await store.addNote(task.id, "This is a note"); + + const updated = await store.get(task.id); + expect(updated?.notes).toHaveLength(1); + expect(updated?.notes[0].content).toBe("This is a note"); + expect(updated?.notes[0].timestamp >= before).toBe(true); + }); + + it("appends multiple notes in order", async () => { + const task = await store.create("Task"); + + await store.addNote(task.id, "First note"); + await store.addNote(task.id, "Second note"); + await store.addNote(task.id, "Third note"); + + const updated = await store.get(task.id); + expect(updated?.notes).toHaveLength(3); + expect(updated?.notes.map((n) => n.content)).toEqual([ + "First note", + "Second note", + "Third note", + ]); + }); + }); + + describe("getReady", () => { + it("returns open tasks with no deps", async () => { + const task = await store.create("Ready task"); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); + + it("excludes draft tasks", async () => { + await store.create("Draft task", { status: "draft" }); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(0); + }); + + it("excludes in_progress tasks", async () => { + const task = await store.create("Task"); + await store.start(task.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(0); + }); + + it("excludes done tasks", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(0); + }); + + it("excludes tasks with unresolved deps", async () => { + const dep = await store.create("Dependency"); + await store.create("Blocked task", { deps: [dep.id] }); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(dep.id); + }); + + it("includes tasks when all deps are done", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task", { deps: [dep.id] }); + await store.close(dep.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); + + it("handles multiple deps correctly", async () => { + const dep1 = await store.create("Dep 1"); + const dep2 = await store.create("Dep 2"); + const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); + + // Only one dep done - task not ready + await store.close(dep1.id); + let ready = await store.getReady(); + expect(ready.map((t) => t.id)).not.toContain(task.id); + + // Both deps done - task ready + await store.close(dep2.id); + ready = await store.getReady(); + expect(ready.map((t) => t.id)).toContain(task.id); + }); + + it("sorts by created date (oldest first)", async () => { + const task1 = await store.create("Task 1"); + await new Promise((r) => setTimeout(r, 10)); + const task2 = await store.create("Task 2"); + await new Promise((r) => setTimeout(r, 10)); + const task3 = await store.create("Task 3"); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]); + }); + + describe("scoped to epic", () => { + it("returns only ready tasks in epic dep tree", async () => { + await store.create("Unrelated task"); + const dep = await store.create("Epic dep"); + const epic = await store.create("Epic", { deps: [dep.id] }); + + const ready = await store.getReady(epic.id); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(dep.id); + }); + + it("returns empty when epic has no ready deps", async () => { + const dep = await store.create("Dep", { status: "draft" }); + const epic = await store.create("Epic", { deps: [dep.id] }); + + const ready = await store.getReady(epic.id); + + expect(ready).toHaveLength(0); + }); + + it("handles nested deps", async () => { + const leaf = await store.create("Leaf"); + const middle = await store.create("Middle", { deps: [leaf.id] }); + const epic = await store.create("Epic", { deps: [middle.id] }); + + // Only leaf is ready initially + let ready = await store.getReady(epic.id); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(leaf.id); + + // After leaf done, middle is ready + await store.close(leaf.id); + ready = await store.getReady(epic.id); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(middle.id); + + // After middle done, epic itself is ready (but we're scoped, so epic not in results) + await store.close(middle.id); + ready = await store.getReady(epic.id); + expect(ready).toHaveLength(0); + }); + }); + }); + + describe("getBlocked", () => { + it("returns tasks with unresolved deps", async () => { + const dep = await store.create("Dependency"); + const blocked = await store.create("Blocked", { deps: [dep.id] }); + + const result = await store.getBlocked(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(blocked.id); + }); + + it("excludes tasks with no deps", async () => { + await store.create("No deps"); + + const result = await store.getBlocked(); + + expect(result).toHaveLength(0); + }); + + it("excludes tasks with all deps done", async () => { + const dep = await store.create("Dep"); + await store.create("Task", { deps: [dep.id] }); + await store.close(dep.id); + + const result = await store.getBlocked(); + + expect(result).toHaveLength(0); + }); + + it("excludes draft tasks", async () => { + const dep = await store.create("Dep"); + await store.create("Draft blocked", { status: "draft", deps: [dep.id] }); + + const result = await store.getBlocked(); + + expect(result).toHaveLength(0); + }); + + it("includes in_progress tasks with unresolved deps", async () => { + const dep = await store.create("Dep"); + const task = await store.create("Task", { deps: [dep.id] }); + // Force start even with unresolved deps (edge case) + await store.update(task.id, { status: "in_progress" }); + + const result = await store.getBlocked(); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(task.id); + }); + + describe("scoped to epic", () => { + it("returns only blocked tasks in epic dep tree", async () => { + const unrelatedDep = await store.create("Unrelated dep"); + await store.create("Unrelated blocked", { + deps: [unrelatedDep.id], + }); + + const epicDep = await store.create("Epic dep"); + const epicChild = await store.create("Epic child", { + deps: [epicDep.id], + }); + const epic = await store.create("Epic", { deps: [epicChild.id] }); + + const blocked = await store.getBlocked(epic.id); + + expect(blocked).toHaveLength(1); + expect(blocked[0].id).toBe(epicChild.id); + }); + }); + }); + + describe("getClosed", () => { + it("returns done tasks", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + const closed = await store.getClosed(); + + expect(closed).toHaveLength(1); + expect(closed[0].id).toBe(task.id); + }); + + it("excludes non-done tasks", async () => { + await store.create("Open task"); + await store.create("Draft task", { status: "draft" }); + const inProgress = await store.create("In progress"); + await store.start(inProgress.id); + + const closed = await store.getClosed(); + + expect(closed).toHaveLength(0); + }); + + it("sorts by created date (most recent first)", async () => { + const task1 = await store.create("Task 1"); + await new Promise((r) => setTimeout(r, 10)); + const task2 = await store.create("Task 2"); + await new Promise((r) => setTimeout(r, 10)); + const task3 = await store.create("Task 3"); + + await store.close(task1.id); + await store.close(task2.id); + await store.close(task3.id); + + const closed = await store.getClosed(); + + expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]); + }); + + describe("scoped to epic", () => { + it("returns only closed tasks in epic dep tree", async () => { + const unrelated = await store.create("Unrelated"); + await store.close(unrelated.id); + + const dep = await store.create("Epic dep"); + const epic = await store.create("Epic", { deps: [dep.id] }); + await store.close(dep.id); + + const closed = await store.getClosed(epic.id); + + expect(closed).toHaveLength(1); + expect(closed[0].id).toBe(dep.id); + }); + }); + }); + + describe("getDepTree", () => { + it("returns empty for task with no deps", async () => { + const task = await store.create("Leaf task"); + + const tree = await store.getDepTree(task.id); + + expect(tree).toEqual([]); + }); + + it("returns direct deps", async () => { + const dep1 = await store.create("Dep 1"); + const dep2 = await store.create("Dep 2"); + const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); + + const tree = await store.getDepTree(task.id); + + expect(tree).toHaveLength(2); + expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort()); + }); + + it("returns nested deps recursively", async () => { + const leaf = await store.create("Leaf"); + const middle = await store.create("Middle", { deps: [leaf.id] }); + const root = await store.create("Root", { deps: [middle.id] }); + + const tree = await store.getDepTree(root.id); + + expect(tree).toHaveLength(2); + expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort()); + }); + + it("handles diamond deps without duplicates", async () => { + const shared = await store.create("Shared"); + const left = await store.create("Left", { deps: [shared.id] }); + const right = await store.create("Right", { deps: [shared.id] }); + const root = await store.create("Root", { deps: [left.id, right.id] }); + + const tree = await store.getDepTree(root.id); + + expect(tree).toHaveLength(3); + expect(tree.map((t) => t.id).sort()).toEqual( + [shared.id, left.id, right.id].sort() + ); + }); + + it("handles circular deps gracefully", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2", { deps: [task1.id] }); + await store.addDep(task1.id, task2.id); // create cycle + + // Should not infinite loop + const tree = await store.getDepTree(task1.id); + + expect(tree.map((t) => t.id)).toContain(task2.id); + }); + + it("throws for non-existent task", async () => { + await expect(store.getDepTree("nonexistent")).rejects.toThrow(); + }); + }); + + describe("file persistence", () => { + it("persists tasks across store instances", async () => { + const task = await store.create("Persistent task", { + description: "With description", + assignee: "claude", + }); + await store.addNote(task.id, "A note"); + + // Create new store instance pointing to same dir + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(task.id); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.title).toBe("Persistent task"); + expect(retrieved?.description).toBe("With description"); + expect(retrieved?.assignee).toBe("claude"); + expect(retrieved?.created).toBe(task.created); + expect(retrieved?.notes).toHaveLength(1); + expect(retrieved?.notes[0].content).toBe("A note"); + }); + }); +}); diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts new file mode 100644 index 000000000..7936574b3 --- /dev/null +++ b/packages/server/src/tasks/task-store.ts @@ -0,0 +1,359 @@ +import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import type { + Task, + TaskStore, + CreateTaskOptions, + TaskStatus, + AgentType, +} from "./types.js"; + +function generateId(): string { + return randomBytes(4).toString("hex"); +} + +function serializeTask(task: Task): string { + const frontmatterLines = [ + "---", + `id: ${task.id}`, + `title: ${task.title}`, + `status: ${task.status}`, + `deps: [${task.deps.join(", ")}]`, + `created: ${task.created}`, + ]; + + if (task.assignee) { + frontmatterLines.push(`assignee: ${task.assignee}`); + } + + frontmatterLines.push("---"); + + const frontmatter = frontmatterLines.join("\n"); + + let body = ""; + if (task.description) { + body += task.description + "\n"; + } + + if (task.notes.length > 0) { + body += "\n## Notes\n"; + for (const note of task.notes) { + body += `\n**${note.timestamp}**\n\n${note.content}\n`; + } + } + + return frontmatter + "\n\n" + body; +} + +function parseTask(content: string): Task { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (!frontmatterMatch) { + throw new Error("Invalid task file: missing frontmatter"); + } + + const frontmatter = frontmatterMatch[1]; + const body = content.slice(frontmatterMatch[0].length); + + const getValue = (key: string): string => { + const match = frontmatter.match(new RegExp(`^${key}: (.*)$`, "m")); + return match ? match[1] : ""; + }; + + const depsStr = getValue("deps"); + const depsMatch = depsStr.match(/\[(.*)\]/); + const deps = + depsMatch && depsMatch[1].trim() + ? depsMatch[1] + .split(",") + .map((d) => d.trim()) + .filter(Boolean) + : []; + + // Parse notes from body + const notes: Task["notes"] = []; + const notesSection = body.match(/## Notes\n([\s\S]*?)$/); + if (notesSection) { + const noteMatches = notesSection[1].matchAll( + /\*\*(\d{4}-\d{2}-\d{2}T[\d:.Z]+)\*\*\n\n([\s\S]*?)(?=\n\*\*\d{4}|$)/g + ); + for (const match of noteMatches) { + notes.push({ + timestamp: match[1], + content: match[2].trim(), + }); + } + } + + // Description is everything before ## Notes + let description = body; + if (notesSection) { + description = body.slice(0, body.indexOf("## Notes")).trim(); + } + description = description.trim(); + + const assignee = getValue("assignee") as AgentType | ""; + + return { + id: getValue("id"), + title: getValue("title"), + status: getValue("status") as TaskStatus, + deps, + description, + notes, + created: getValue("created") || new Date().toISOString(), + assignee: assignee || undefined, + }; +} + +export class FileTaskStore implements TaskStore { + constructor(private readonly dir: string) {} + + private taskPath(id: string): string { + return join(this.dir, `${id}.md`); + } + + private async ensureDir(): Promise<void> { + await mkdir(this.dir, { recursive: true }); + } + + private async readTask(id: string): Promise<Task | null> { + try { + const content = await readFile(this.taskPath(id), "utf-8"); + return parseTask(content); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + private async writeTask(task: Task): Promise<void> { + await this.ensureDir(); + await writeFile(this.taskPath(task.id), serializeTask(task), "utf-8"); + } + + async list(): Promise<Task[]> { + await this.ensureDir(); + try { + const files = await readdir(this.dir); + const tasks: Task[] = []; + for (const file of files) { + if (file.endsWith(".md")) { + const id = file.slice(0, -3); + const task = await this.readTask(id); + if (task) { + tasks.push(task); + } + } + } + return tasks; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + } + + async get(id: string): Promise<Task | null> { + return this.readTask(id); + } + + async getDepTree(id: string): Promise<Task[]> { + const root = await this.get(id); + if (!root) { + throw new Error(`Task not found: ${id}`); + } + + const visited = new Set<string>(); + const result: Task[] = []; + + const traverse = async (taskId: string): Promise<void> => { + if (visited.has(taskId)) return; + visited.add(taskId); + + const task = await this.get(taskId); + if (!task) return; + + for (const depId of task.deps) { + if (!visited.has(depId)) { + const dep = await this.get(depId); + if (dep) { + result.push(dep); + await traverse(depId); + } + } + } + }; + + await traverse(id); + return result; + } + + async getReady(scopeId?: string): Promise<Task[]> { + const allTasks = await this.list(); + const taskMap = new Map(allTasks.map((t) => [t.id, t])); + + let candidates: Task[]; + if (scopeId) { + const tree = await this.getDepTree(scopeId); + candidates = tree; + } else { + candidates = allTasks; + } + + const isReady = (task: Task): boolean => { + if (task.status !== "open") return false; + return task.deps.every((depId) => { + const dep = taskMap.get(depId); + return dep?.status === "done"; + }); + }; + + // Sort by created date (oldest first) for consistent ordering + return candidates.filter(isReady).sort((a, b) => { + return a.created.localeCompare(b.created); + }); + } + + async getBlocked(scopeId?: string): Promise<Task[]> { + const allTasks = await this.list(); + const taskMap = new Map(allTasks.map((t) => [t.id, t])); + + let candidates: Task[]; + if (scopeId) { + const tree = await this.getDepTree(scopeId); + candidates = tree; + } else { + candidates = allTasks; + } + + const isBlocked = (task: Task): boolean => { + if (task.status === "draft" || task.status === "done") return false; + if (task.deps.length === 0) return false; + return task.deps.some((depId) => { + const dep = taskMap.get(depId); + return dep?.status !== "done"; + }); + }; + + return candidates.filter(isBlocked); + } + + async getClosed(scopeId?: string): Promise<Task[]> { + let candidates: Task[]; + if (scopeId) { + const tree = await this.getDepTree(scopeId); + candidates = tree; + } else { + candidates = await this.list(); + } + + // Sort by created date (most recent first) for closed tasks + return candidates + .filter((t) => t.status === "done") + .sort((a, b) => b.created.localeCompare(a.created)); + } + + async create(title: string, opts?: CreateTaskOptions): Promise<Task> { + const task: Task = { + id: generateId(), + title, + status: opts?.status ?? "open", + deps: opts?.deps ?? [], + description: opts?.description ?? "", + notes: [], + created: new Date().toISOString(), + assignee: opts?.assignee, + }; + + await this.writeTask(task); + return task; + } + + async update( + id: string, + changes: Partial<Omit<Task, "id" | "created">> + ): Promise<Task> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + const updated: Task = { ...task, ...changes }; + await this.writeTask(updated); + return updated; + } + + async addDep(id: string, depId: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + const dep = await this.get(depId); + if (!dep) { + throw new Error(`Dependency not found: ${depId}`); + } + + if (!task.deps.includes(depId)) { + task.deps.push(depId); + await this.writeTask(task); + } + } + + async removeDep(id: string, depId: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + task.deps = task.deps.filter((d) => d !== depId); + await this.writeTask(task); + } + + async addNote(id: string, content: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + + task.notes.push({ + timestamp: new Date().toISOString(), + content, + }); + await this.writeTask(task); + } + + async open(id: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + if (task.status !== "draft") { + throw new Error(`Cannot open task with status: ${task.status}`); + } + await this.update(id, { status: "open" }); + } + + async start(id: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + if (task.status !== "open") { + throw new Error(`Cannot start task with status: ${task.status}`); + } + await this.update(id, { status: "in_progress" }); + } + + async close(id: string): Promise<void> { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + await this.update(id, { status: "done" }); + } +} diff --git a/packages/server/src/tasks/types.ts b/packages/server/src/tasks/types.ts new file mode 100644 index 000000000..c740f3516 --- /dev/null +++ b/packages/server/src/tasks/types.ts @@ -0,0 +1,48 @@ +export type TaskStatus = "draft" | "open" | "in_progress" | "done"; + +export type AgentType = "claude" | "codex"; + +export interface Note { + timestamp: string; // ISO date + content: string; // markdown +} + +export interface Task { + id: string; // random hash, e.g. "a1b2c3d4" + title: string; + status: TaskStatus; + deps: string[]; + description: string; // long form markdown + notes: Note[]; + created: string; // ISO date + assignee?: AgentType; // optional agent override +} + +export interface CreateTaskOptions { + deps?: string[]; + status?: TaskStatus; + description?: string; + assignee?: AgentType; +} + +export interface TaskStore { + // Queries + list(): Promise<Task[]>; + get(id: string): Promise<Task | null>; + getDepTree(id: string): Promise<Task[]>; // all descendants in dep graph + getReady(scopeId?: string): Promise<Task[]>; // open + all deps done, optionally scoped + getBlocked(scopeId?: string): Promise<Task[]>; // open/in_progress but has unresolved deps + getClosed(scopeId?: string): Promise<Task[]>; // done tasks, optionally scoped + + // Mutations + create(title: string, opts?: CreateTaskOptions): Promise<Task>; + update(id: string, changes: Partial<Omit<Task, "id" | "created">>): Promise<Task>; + addDep(id: string, depId: string): Promise<void>; + removeDep(id: string, depId: string): Promise<void>; + addNote(id: string, content: string): Promise<void>; + + // Status transitions + open(id: string): Promise<void>; // draft -> open + start(id: string): Promise<void>; // open -> in_progress + close(id: string): Promise<void>; // any -> done +} diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts new file mode 100644 index 000000000..c5ee84c74 --- /dev/null +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +// ---- Principal Parameter Extraction ---- + +// Schema for file entries in arrays (e.g., apply_patch files) +const FileEntrySchema = z.object({ path: z.string() }); + +// Schema for TodoWrite todos array +const TodoEntrySchema = z.object({ + content: z.string(), + status: z.enum(["pending", "in_progress", "completed"]), + activeForm: z.string().optional(), +}); + +const PrincipalParamSchema = z.union([ + // Direct path keys + z.object({ file_path: z.string() }).transform((d) => ({ type: "path" as const, value: d.file_path })), + z.object({ filePath: z.string() }).transform((d) => ({ type: "path" as const, value: d.filePath })), + z.object({ path: z.string() }).transform((d) => ({ type: "path" as const, value: d.path })), + // Command as string + z.object({ command: z.string() }).transform((d) => ({ type: "text" as const, value: d.command })), + // Command as array (Codex sends this) + z.object({ command: z.array(z.string()).nonempty() }).transform((d) => ({ type: "text" as const, value: d.command.join(" ") })), + // Other text params + z.object({ pattern: z.string() }).transform((d) => ({ type: "text" as const, value: d.pattern })), + z.object({ query: z.string() }).transform((d) => ({ type: "text" as const, value: d.query })), + z.object({ url: z.string() }).transform((d) => ({ type: "text" as const, value: d.url })), + // Files array (Codex apply_patch) + z.object({ files: z.array(FileEntrySchema).nonempty() }).transform((d) => ({ type: "path" as const, value: d.files[0].path })), + // TodoWrite - show in_progress item or count + z.object({ todos: z.array(TodoEntrySchema).nonempty() }).transform((d) => { + const inProgress = d.todos.find((t) => t.status === "in_progress"); + if (inProgress) { + return { type: "text" as const, value: inProgress.activeForm ?? inProgress.content }; + } + return { type: "text" as const, value: `${d.todos.length} tasks` }; + }), +]); + +export function stripCwdPrefix(filePath: string, cwd?: string): string { + if (!cwd || !filePath) return filePath; + + const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedPath = filePath.replace(/\\/g, "/"); + + const prefix = `${normalizedCwd}/`; + if (normalizedPath.startsWith(prefix)) { + return normalizedPath.slice(prefix.length); + } + if (normalizedPath === normalizedCwd) { + return "."; + } + return filePath; +} + +export function extractPrincipalParam(args: unknown, cwd?: string): string | undefined { + const parsed = PrincipalParamSchema.safeParse(args); + if (!parsed.success) { + return undefined; + } + + const { type, value } = parsed.data; + return type === "path" ? stripCwdPrefix(value, cwd) : value; +} + +// ---- TodoWrite Extraction ---- + +export interface TodoItem { + content: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + +export function extractTodos(value: unknown): TodoItem[] { + if (typeof value !== "object" || value === null) { + return []; + } + const obj = value as Record<string, unknown>; + if (!Array.isArray(obj.todos)) { + return []; + } + return obj.todos.filter( + (t): t is TodoItem => + typeof t === "object" && + t !== null && + typeof (t as Record<string, unknown>).content === "string" && + typeof (t as Record<string, unknown>).status === "string" + ); +}