diff --git a/packages/server/src/tasks/cli.ts b/packages/server/src/tasks/cli.ts index 2babeb2b1..09f13e521 100644 --- a/packages/server/src/tasks/cli.ts +++ b/packages/server/src/tasks/cli.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { FileTaskStore } from "./task-store.js"; -import type { AgentType, Task } from "./types.js"; +import type { AgentType, ModelName, Task } from "./types.js"; const TASKS_DIR = resolve(process.cwd(), ".tasks"); const store = new FileTaskStore(TASKS_DIR); @@ -88,6 +88,7 @@ program .option("--parent ", "Parent task ID (for hierarchy)") .option("--assignee ", "Agent to assign (claude or codex)") .option("--draft", "Create as draft (not actionable)") + .option("-a, --accept ", "Acceptance criterion (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) .action(async (title, opts) => { let body = opts.body ?? ""; if (body === "-") { @@ -102,6 +103,7 @@ program parentId: opts.parent, status: opts.draft ? "draft" : "open", assignee: opts.assignee as AgentType | undefined, + acceptanceCriteria: opts.accept, }); console.log(task.id); @@ -174,6 +176,12 @@ program if (task.body) { console.log(`\n${task.body}`); } + if (task.acceptanceCriteria.length) { + console.log("\n## Acceptance Criteria\n"); + for (const criterion of task.acceptanceCriteria) { + console.log(`- [ ] ${criterion}`); + } + } if (task.notes.length) { console.log("\n## Notes"); for (const note of task.notes) { @@ -301,6 +309,7 @@ program .option("-t, --title ", "New title") .option("-b, --body ", "New body (use '-' to read from stdin)") .option("--assignee ", "New assignee (claude or codex)") + .option("-a, --accept ", "Add acceptance criterion (repeatable, append-only)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) .action(async (id, opts) => { const task = await store.get(id); if (!task) { @@ -322,12 +331,19 @@ program changes.assignee = opts.assignee as AgentType; } - if (Object.keys(changes).length === 0) { + // Add new acceptance criteria (append only) + for (const criterion of opts.accept) { + await store.addAcceptanceCriteria(id, criterion); + } + + if (Object.keys(changes).length === 0 && opts.accept.length === 0) { console.error("No changes specified"); process.exit(1); } - await store.update(id, changes); + if (Object.keys(changes).length > 0) { + await store.update(id, changes); + } console.log(`Updated: ${id}`); }); @@ -410,14 +426,69 @@ program console.log(`${id} -> done`); }); -// Agent runner -async function makePrompt( - task: Task, - scopeId: string | undefined -): Promise { - const scopeArg = scopeId ? ` --scope ${scopeId}` : ""; +program + .command("fail ") + .description("Mark as failed (catastrophically stuck)") + .action(async (id) => { + await store.fail(id); + console.log(`${id} -> failed`); + }); - // Build parent context from ancestor chain +// Agent runner + +interface AgentConfig { + cli: "claude" | "codex"; + model?: string; +} + +function getAgentConfig(model: ModelName): AgentConfig { + if (model.startsWith("gpt-")) { + return { cli: "codex", model }; + } + // Claude CLI accepts aliases directly: haiku, sonnet, opus + return { cli: "claude", model }; +} + +function runAgentWithModel( + prompt: string, + model: ModelName, + logFile: string +): { success: boolean; output: string } { + const config = getAgentConfig(model); + let args: string[]; + + if (config.cli === "claude") { + args = ["--dangerously-skip-permissions"]; + if (config.model) { + args.push("--model", config.model); + } + args.push("-p", prompt); + } else { + args = [ + "exec", + "--dangerously-bypass-approvals-and-sandbox", + "--skip-git-repo-check", + ]; + if (config.model) { + args.push("--model", config.model); + } + args.push(prompt); + } + + const fd = openSync(logFile, "a"); + const result = spawnSync(config.cli, args, { + stdio: ["inherit", fd, fd], + cwd: process.cwd(), + maxBuffer: 50 * 1024 * 1024, + }); + + // Read the output from the log file to check for judge verdict + const output = existsSync(logFile) ? readFileSync(logFile, "utf-8") : ""; + + return { success: result.status === 0, output }; +} + +async function buildTaskContext(task: Task, scopeId?: string): Promise { const ancestors = await store.getAncestors(task.id); let parentContext = ""; if (ancestors.length > 0) { @@ -436,54 +507,177 @@ async function makePrompt( if (scopeId && !ancestors.some((a) => a.id === scopeId)) { const scope = await store.get(scopeId); if (scope) { - scopeContext = `Scope: ${scope.title} (${scopeId}) -${scope.body ? `\n${scope.body}\n` : ""}`; + scopeContext = `Scope: ${scope.title} (${scopeId})\n${scope.body ? `\n${scope.body}\n` : ""}`; + } + } + + let acceptanceCriteriaText = ""; + if (task.acceptanceCriteria.length > 0) { + acceptanceCriteriaText = "\n## Acceptance Criteria\n\n"; + for (const criterion of task.acceptanceCriteria) { + acceptanceCriteriaText += `- [ ] ${criterion}\n`; + } + } + + let notesText = ""; + if (task.notes.length > 0) { + notesText = "\n## Notes\n"; + for (const note of task.notes) { + notesText += `\n**${note.timestamp}**\n${note.content}\n`; } } return `Working directory: ${process.cwd()} ${scopeContext}${parentContext} -# YOUR TASK (${task.id}): ${task.title} +# Task: ${task.title} ${task.body || "(no body)"} +${acceptanceCriteriaText}${notesText}`; +} + +function makePlannerPrompt(task: Task, context: string, iteration: number): string { + return `${context} --- -STEPS: -1. UNDERSTAND CONTEXT FIRST - Before any implementation: - - Run \`task show ${task.id}\` to see full context with parent chain - - Run \`task children ${task.id}\` to see subtasks if any - - Run \`task closed${scopeArg}\` to see completed sibling tasks - - 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}\` +You are a PLANNER agent. Your job is to analyze the task tree and organize work to achieve the top-level goal. -COMMANDS: -- \`task show \` - view task details with parent context -- \`task children \` - list subtasks -- \`task update --body "..."\` - update task body (for grooming/clarifying) -- \`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 +Current iteration: ${iteration} -You MUST run \`task close ${task.id}\` when finished. +You have FULL ACCESS to the task CLI and can reorganize the ENTIRE task tree under the scope: +- \`task show ${task.id}\` - view task details with parent context +- \`task list\` - see all tasks +- \`task tree ${task.id}\` - see full task hierarchy +- \`task children ${task.id}\` - list subtasks +- \`task ready\` - see what's ready to work on +- \`task create "title" --parent --body "..." --accept "criterion"\` - create task (any level) +- \`task update --title "..." --body "..." --accept "criterion"\` - update task +- \`task note "content"\` - add planning notes +- \`task delete \` - remove a task that's no longer needed +- \`task fail \` - mark task as failed if catastrophically stuck + +## Your Scope + +You can reorganize ANY task under this scope. The TOP-LEVEL task's acceptance criteria are IMMUTABLE - they are the north star. Everything else can be: +- Broken down into subtasks +- Deleted if no longer relevant +- Reordered/reprioritized +- Updated with better acceptance criteria + +Always keep the top-level goal in mind when reorganizing. + +## Writing Good Acceptance Criteria + +Acceptance criteria MUST be: +- **Verifiable**: Can be checked programmatically or with a clear command (e.g., "npm run test passes", "file X exists", "API returns 200") +- **Objective**: No subjective judgments like "code is clean" or "good performance" - use measurable thresholds +- **Specific**: Reference exact files, functions, endpoints, or behaviors +- **Complete**: Cover edge cases, error handling, and integration points + +Bad examples: +- "Code is well-written" (subjective) +- "Feature works" (vague) +- "Tests pass" (which tests?) + +Good examples: +- "npm run test passes with 0 failures" +- "GET /api/users returns 200 with JSON array" +- "File src/utils/parser.ts exports parseConfig function" +- "Running \`node cli.js --help\` prints usage information" + +## Your Options + +1. If the current task is simple enough to implement directly, add a note explaining why and exit +2. If it needs breakdown, create subtasks with clear, verifiable acceptance criteria +3. If other tasks in the tree need reorganization based on what's been learned, do that +4. If a task is catastrophically stuck with no clear path forward (repeated failures WITHOUT progress), mark it failed + +Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward. + +DO NOT implement tasks. Only plan and organize. +When done planning, simply exit. Do not mark tasks as done. `; } -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]; +function makeWorkerPrompt(task: Task, context: string, iteration: number): string { + return `${context} - const fd = openSync(logFile, "a"); - const result = spawnSync(agent, args, { - stdio: ["inherit", fd, fd], - cwd: process.cwd(), - }); +--- - return result.status === 0; +You are a WORKER agent implementing this task. + +Current iteration: ${iteration} + +IMPORTANT RULES: +- You CANNOT mark this task as done (task close is forbidden for you) +- You MUST add a note documenting what you did: \`task note ${task.id} "what you did"\` +- You CAN use \`task show ${task.id}\` to see full context +- You CAN use \`task children ${task.id}\` to see subtasks + +Focus on implementing the task. The acceptance criteria will be verified by a separate judge agent. + +When you've done your best work, add a note explaining what you did and exit. +`; +} + +function makeJudgePrompt(task: Task): string { + let criteriaText = ""; + if (task.acceptanceCriteria.length > 0) { + criteriaText = "## Acceptance Criteria to Verify\n\n"; + for (let i = 0; i < task.acceptanceCriteria.length; i++) { + criteriaText += `${i + 1}. ${task.acceptanceCriteria[i]}\n`; + } + } else { + criteriaText = "No explicit acceptance criteria defined. Verify the task is reasonably complete based on the title and body.\n"; + } + + return `You are a JUDGE agent. Your ONLY job is to verify if acceptance criteria are met. + +# Task: ${task.title} + +${task.body || "(no body)"} + +${criteriaText} + +## Your Instructions + +1. For EACH criterion, verify if it is satisfied by running commands and checking actual state +2. You may run commands to check (e.g., \`npm run test\`, \`npm run typecheck\`, check file existence, run the code) +3. After checking all criteria, output your verdict + +## CRITICAL: No Excuses Policy + +You are evaluating RESULTS, not effort or intent. The following are NOT acceptable reasons to mark something DONE: + +- "The agent tried their best" - IRRELEVANT +- "It mostly works" - NOT_DONE +- "The environment wasn't set up correctly" - NOT_DONE (setup is part of the task) +- "This feature requires X which isn't available" - NOT_DONE (making it available is part of the task) +- "The agent documented why it couldn't complete" - NOT_DONE +- "It's a good start" - NOT_DONE +- "The core functionality works" - Check ALL criteria, not just "core" +- "This is blocked by external factors" - NOT_DONE + +The ONLY question: Does the acceptance criterion pass when verified? Yes or No. + +If an agent left notes explaining why something couldn't be done, IGNORE THE EXPLANATION. Just check: is the criterion met? + +## Output Format + +After verification, output EXACTLY one of these XML tags: + +If ALL criteria pass verification: +DONE + +If ANY criterion fails verification: +NOT_DONE + +- criterion that failed: what you checked and what you found + + +Then add a note to the task with your findings: +\`task note ${task.id} "Judge verdict: [DONE/NOT_DONE]. Details: ..."\` +`; } function getLogFile(): string { @@ -497,77 +691,171 @@ function getLogFile(): string { function log(logFile: string, message: string): void { const timestamp = new Date().toISOString(); appendFileSync(logFile, `[${timestamp}] ${message}\n`); + console.log(`[${timestamp}] ${message}`); +} + +function parseJudgeVerdict(output: string): "DONE" | "NOT_DONE" | null { + const match = output.match(/(DONE|NOT_DONE)<\/VERDICT>/); + return match ? (match[1] as "DONE" | "NOT_DONE") : null; } program .command("run [scope]") - .description("Run agent loop on tasks") - .option("--agent ", "Agent to use (claude or codex)", "claude") + .description("Run agent loop on tasks with planner/worker/judge") + .option("--plan", "Enable planner agent") + .option("--planner ", "Planner model (default: gpt-5.2)", "gpt-5.2") + .option("--replan ", "Run planner every N completed tasks (default: 3)", "3") + .option("--judge ", "Judge model (default: haiku)", "haiku") + .option("--max-iterations ", "Max worker/judge iterations per task (0 = no limit)", "0") .option("-w, --watch", "Keep running and wait for new tasks") .action(async (scopeId: string | undefined, opts) => { - const defaultAgent = opts.agent as AgentType; + const enablePlanner = opts.plan; + const plannerModel = opts.planner as ModelName; + const replanInterval = parseInt(opts.replan, 10); + const judgeModel = opts.judge as ModelName; + const maxIterations = parseInt(opts.maxIterations, 10); const watchMode = opts.watch; const logFile = getLogFile(); - console.log("Task Runner started"); - console.log(`Agent: ${defaultAgent}`); + console.log("Task Runner started (planner/worker/judge loop)"); + console.log(`Planner: ${enablePlanner ? `${plannerModel} (replan every ${replanInterval} tasks)` : "disabled"}`); + console.log(`Judge: ${judgeModel}`); + console.log(`Max iterations: ${maxIterations === 0 ? "unlimited" : maxIterations}`); if (scopeId) console.log(`Scope: ${scopeId}`); console.log(`Log: ${logFile}`); console.log(""); - log(logFile, `Started with agent=${defaultAgent} scope=${scopeId || "all"}`); + log(logFile, `Started with planner=${enablePlanner ? plannerModel : "disabled"} replan=${replanInterval} judge=${judgeModel} maxIter=${maxIterations} scope=${scopeId || "all"}`); - const MAX_RETRIES = 3; + let completedSinceReplan = 0; - const runLoop = async (): Promise => { + const runPlanner = async (task: Task, reason: string): Promise => { + log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`); + const context = await buildTaskContext(task, scopeId); + const plannerPrompt = makePlannerPrompt(task, context, 1); + runAgentWithModel(plannerPrompt, plannerModel, logFile); + + // Check if planner created subtasks for this task + const children = await store.getChildren(task.id); + if (children.some((c) => c.status !== "done")) { + log(logFile, `[PLANNER] Task has pending children, will process those first`); + return true; // Signal to restart loop + } + + // Check if planner marked task as failed + const updatedTask = await store.get(task.id); + if (updatedTask?.status === "failed") { + log(logFile, `[PLANNER] Marked task as failed`); + return true; // Signal to continue to next task + } + + return false; + }; + + const runTaskLoop = async (): Promise => { while (true) { + // Find ready tasks 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}]`); + const workerModel = (task.assignee === "codex" ? "gpt-5.2" : "sonnet") as ModelName; + log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`); 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)); + // Step 1: Initial planner run (if enabled) + if (enablePlanner) { + const shouldRestart = await runPlanner(task, "initial planning"); + if (shouldRestart) { + // Reset task to open so we process children first + const currentTask = await store.get(task.id); + if (currentTask && currentTask.status === "in_progress") { + await store.update(task.id, { status: "open" }); + } + continue; } - attempt++; } - if (!success) { - console.log(`❌ ${task.title} (failed after ${MAX_RETRIES} attempts)`); - log(logFile, `Failed: ${task.id} after ${MAX_RETRIES} attempts`); - process.exit(1); + // Step 2: Worker/Judge loop + let iteration = 1; + let taskDone = false; + + while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) { + const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`; + log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`); + + // Refresh task context (notes may have been added) + const freshTask = await store.get(task.id); + if (!freshTask) break; + + const freshContext = await buildTaskContext(freshTask, scopeId); + const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration); + runAgentWithModel(workerPrompt, workerModel, logFile); + + // Step 3: Judge + log(logFile, `[JUDGE] Verifying with ${judgeModel}...`); + const judgeTask = await store.get(task.id); + if (!judgeTask) break; + + const judgePrompt = makeJudgePrompt(judgeTask); + const judgeResult = runAgentWithModel(judgePrompt, judgeModel, logFile); + + const verdict = parseJudgeVerdict(judgeResult.output); + log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`); + + if (verdict === "DONE") { + await store.close(task.id); + log(logFile, `✅ Task ${task.id} completed`); + taskDone = true; + completedSinceReplan++; + + // Periodic replanning after N completions + if (enablePlanner && completedSinceReplan >= replanInterval) { + completedSinceReplan = 0; + const nextReady = await store.getReady(scopeId); + if (nextReady.length > 0) { + log(logFile, `[PLANNER] Periodic replan after ${replanInterval} completions`); + // Run planner on the scope root or next ready task to reassess + const scopeTask = scopeId ? await store.get(scopeId) : nextReady[0]; + if (scopeTask) { + await runPlanner(scopeTask, "periodic reassessment"); + } + } + } + } else { + // NOT_DONE - run planner to reassess before retry + if (enablePlanner) { + log(logFile, `[JUDGE] NOT_DONE - triggering planner reassessment`); + const currentTask = await store.get(task.id); + if (currentTask) { + const shouldRestart = await runPlanner(currentTask, `NOT_DONE iteration ${iteration}`); + if (shouldRestart) { + // Planner created subtasks or marked failed, restart the main loop + const updatedTask = await store.get(task.id); + if (updatedTask && updatedTask.status === "in_progress") { + await store.update(task.id, { status: "open" }); + } + break; + } + } + } + iteration++; + } } - // 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}`); + if (!taskDone && maxIterations > 0) { + const finalTask = await store.get(task.id); + if (finalTask && finalTask.status === "in_progress") { + log(logFile, `⚠️ Task ${task.id} not completed after ${maxIterations} iterations`); + // Reset to open so it can be picked up again (planner may have adjusted things) + await store.update(task.id, { status: "open" }); + } } - - console.log(`✅ ${task.title}`); - log(logFile, `Completed: ${task.id}`); } }; - await runLoop(); + await runTaskLoop(); if (watchMode) { console.log("💤 Waiting for new tasks..."); @@ -575,7 +863,7 @@ program await new Promise((r) => setTimeout(r, 5000)); const ready = await store.getReady(scopeId); if (ready.length > 0) { - await runLoop(); + await runTaskLoop(); console.log("💤 Waiting for new tasks..."); } } diff --git a/packages/server/src/tasks/task-store.test.ts b/packages/server/src/tasks/task-store.test.ts index acf047832..d94b0282c 100644 --- a/packages/server/src/tasks/task-store.test.ts +++ b/packages/server/src/tasks/task-store.test.ts @@ -435,47 +435,157 @@ describe("FileTaskStore", () => { expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]); }); + describe("parent blocked by children", () => { + it("excludes parent task when it has open children", async () => { + const parent = await store.create("Parent task"); + await store.create("Child 1", { parentId: parent.id }); + await store.create("Child 2", { parentId: parent.id }); + + const ready = await store.getReady(); + + // Children are ready but parent is not (has open children) + expect(ready).toHaveLength(2); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + }); + + it("includes parent task when all children are done", async () => { + const parent = await store.create("Parent task"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + const child2 = await store.create("Child 2", { parentId: parent.id }); + + await store.close(child1.id); + await store.close(child2.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); + + it("excludes parent when some children are not done", async () => { + const parent = await store.create("Parent task"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + await store.create("Child 2", { parentId: parent.id }); + + await store.close(child1.id); + + const ready = await store.getReady(); + + // Only child2 is ready, parent is blocked by child2 + expect(ready).toHaveLength(1); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + }); + + it("handles nested children - parent waits for all descendants", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + const grandchild = await store.create("Grandchild", { + parentId: child.id, + }); + + // Only grandchild is ready (no children) + let ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(grandchild.id); + + // After grandchild done, child becomes ready + await store.close(grandchild.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(child.id); + + // After child done, parent becomes ready + await store.close(child.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); + + it("combines deps and children blocking", async () => { + const dep = await store.create("Dependency"); + const parent = await store.create("Parent", { deps: [dep.id] }); + const child = await store.create("Child", { parentId: parent.id }); + + // Only dep and child are ready (parent blocked by both dep and child) + let ready = await store.getReady(); + expect(ready).toHaveLength(2); + expect(ready.map((t) => t.id)).toContain(dep.id); + expect(ready.map((t) => t.id)).toContain(child.id); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + + // Close child but not dep - parent still blocked + await store.close(child.id); + ready = await store.getReady(); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + + // Close dep - now parent is ready + await store.close(dep.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); + + it("task with no children is ready (leaf task)", async () => { + const task = await store.create("Leaf task"); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); + }); + describe("scoped to epic", () => { - it("returns only ready tasks in epic dep tree", async () => { + it("returns only ready tasks in epic children tree", async () => { await store.create("Unrelated task"); - const dep = await store.create("Epic dep"); - const epic = await store.create("Epic", { deps: [dep.id] }); + const epic = await store.create("Epic"); + const child = await store.create("Epic child", { parentId: epic.id }); const ready = await store.getReady(epic.id); + // Only child is ready (epic blocked by its open child) expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(dep.id); + expect(ready[0].id).toBe(child.id); + + // After closing child, epic becomes ready + await store.close(child.id); + const readyAfter = await store.getReady(epic.id); + expect(readyAfter).toHaveLength(1); + expect(readyAfter[0].id).toBe(epic.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] }); + it("returns empty when epic and children are not ready", async () => { + const epic = await store.create("Epic", { status: "draft" }); + await store.create("Child", { parentId: epic.id, status: "draft" }); 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] }); + it("handles nested children", async () => { + const epic = await store.create("Epic"); + const child = await store.create("Child", { parentId: epic.id }); + const grandchild = await store.create("Grandchild", { + parentId: child.id, + }); - // Only leaf is ready initially + // Only grandchild is ready (leaf node) let ready = await store.getReady(epic.id); expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(leaf.id); + expect(ready[0].id).toBe(grandchild.id); - // After leaf done, middle is ready - await store.close(leaf.id); + // After closing grandchild, child becomes ready + await store.close(grandchild.id); ready = await store.getReady(epic.id); expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(middle.id); + expect(ready[0].id).toBe(child.id); - // After middle done, epic itself is ready (but we're scoped, so epic not in results) - await store.close(middle.id); + // After closing child, epic becomes ready + await store.close(child.id); ready = await store.getReady(epic.id); - expect(ready).toHaveLength(0); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(epic.id); }); }); }); @@ -531,22 +641,23 @@ describe("FileTaskStore", () => { }); describe("scoped to epic", () => { - it("returns only blocked tasks in epic dep tree", async () => { + it("returns only blocked tasks in epic children 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"); + const externalDep = await store.create("External dep"); + const blockedChild = await store.create("Blocked child", { + parentId: epic.id, + deps: [externalDep.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); + expect(blocked[0].id).toBe(blockedChild.id); }); }); }); @@ -590,18 +701,19 @@ describe("FileTaskStore", () => { }); describe("scoped to epic", () => { - it("returns only closed tasks in epic dep tree", async () => { + it("returns only closed tasks in epic children 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 epic = await store.create("Epic"); + const child = await store.create("Epic child", { parentId: epic.id }); + await store.close(child.id); const closed = await store.getClosed(epic.id); + // Only the closed child is returned (epic is still open) expect(closed).toHaveLength(1); - expect(closed[0].id).toBe(dep.id); + expect(closed[0].id).toBe(child.id); }); }); }); diff --git a/packages/server/src/tasks/task-store.ts b/packages/server/src/tasks/task-store.ts index b72dcf5d8..4ed7d1f24 100644 --- a/packages/server/src/tasks/task-store.ts +++ b/packages/server/src/tasks/task-store.ts @@ -40,6 +40,13 @@ function serializeTask(task: Task): string { content += task.body + "\n"; } + if (task.acceptanceCriteria.length > 0) { + content += "\n## Acceptance Criteria\n\n"; + for (const criterion of task.acceptanceCriteria) { + content += `- [ ] ${criterion}\n`; + } + } + if (task.notes.length > 0) { content += "\n## Notes\n"; for (const note of task.notes) { @@ -89,10 +96,25 @@ function parseTask(content: string): Task { } } - // Body is everything before ## Notes (backwards compat: also check for old "description" field name) + // Parse acceptance criteria + const acceptanceCriteria: string[] = []; + const criteriaSection = fileBody.match( + /## Acceptance Criteria\n\n([\s\S]*?)(?=\n## Notes|$)/ + ); + if (criteriaSection) { + const criteriaMatches = criteriaSection[1].matchAll( + /- \[[ x]\] (.+)$/gm + ); + for (const match of criteriaMatches) { + acceptanceCriteria.push(match[1].trim()); + } + } + + // Body is everything before ## Acceptance Criteria or ## Notes let taskBody = fileBody; - if (notesSection) { - taskBody = fileBody.slice(0, fileBody.indexOf("## Notes")).trim(); + const firstSection = fileBody.match(/\n## (Acceptance Criteria|Notes)\n/); + if (firstSection) { + taskBody = fileBody.slice(0, firstSection.index).trim(); } taskBody = taskBody.trim(); @@ -106,6 +128,7 @@ function parseTask(content: string): Task { deps, parentId: parentId || undefined, body: taskBody, + acceptanceCriteria, notes, created: getValue("created") || new Date().toISOString(), assignee: assignee || undefined, @@ -225,24 +248,54 @@ export class FileTaskStore implements TaskStore { .sort((a, b) => a.created.localeCompare(b.created)); } + async getDescendants(id: string): Promise { + const result: Task[] = []; + const traverse = async (parentId: string): Promise => { + const children = await this.getChildren(parentId); + for (const child of children) { + result.push(child); + await traverse(child.id); + } + }; + await traverse(id); + return result; + } + async getReady(scopeId?: string): Promise { 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; + // Include the scoped task itself and all its descendants (children tree) + const scopeTask = await this.get(scopeId); + const descendants = await this.getDescendants(scopeId); + candidates = scopeTask ? [scopeTask, ...descendants] : descendants; } else { candidates = allTasks; } + // Build children map for quick lookup + const childrenMap = new Map(); + for (const t of allTasks) { + if (t.parentId) { + const siblings = childrenMap.get(t.parentId) ?? []; + siblings.push(t); + childrenMap.set(t.parentId, siblings); + } + } + const isReady = (task: Task): boolean => { if (task.status !== "open") return false; - return task.deps.every((depId) => { + // All deps must be done + const depsReady = task.deps.every((depId) => { const dep = taskMap.get(depId); return dep?.status === "done"; }); + if (!depsReady) return false; + // All children must be done (if any exist) + const children = childrenMap.get(task.id) ?? []; + return children.every((c) => c.status === "done"); }; // Sort by created date (oldest first) for consistent ordering @@ -257,8 +310,9 @@ export class FileTaskStore implements TaskStore { let candidates: Task[]; if (scopeId) { - const tree = await this.getDepTree(scopeId); - candidates = tree; + const scopeTask = await this.get(scopeId); + const descendants = await this.getDescendants(scopeId); + candidates = scopeTask ? [scopeTask, ...descendants] : descendants; } else { candidates = allTasks; } @@ -278,8 +332,9 @@ export class FileTaskStore implements TaskStore { async getClosed(scopeId?: string): Promise { let candidates: Task[]; if (scopeId) { - const tree = await this.getDepTree(scopeId); - candidates = tree; + const scopeTask = await this.get(scopeId); + const descendants = await this.getDescendants(scopeId); + candidates = scopeTask ? [scopeTask, ...descendants] : descendants; } else { candidates = await this.list(); } @@ -306,6 +361,7 @@ export class FileTaskStore implements TaskStore { deps: opts?.deps ?? [], parentId: opts?.parentId, body: opts?.body ?? "", + acceptanceCriteria: opts?.acceptanceCriteria ?? [], notes: [], created: new Date().toISOString(), assignee: opts?.assignee, @@ -437,4 +493,21 @@ export class FileTaskStore implements TaskStore { } await this.update(id, { status: "done" }); } + + async fail(id: string): Promise { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + await this.update(id, { status: "failed" }); + } + + async addAcceptanceCriteria(id: string, criterion: string): Promise { + const task = await this.get(id); + if (!task) { + throw new Error(`Task not found: ${id}`); + } + task.acceptanceCriteria.push(criterion); + await this.writeTask(task); + } } diff --git a/packages/server/src/tasks/types.ts b/packages/server/src/tasks/types.ts index d4a15dd72..fbca3883a 100644 --- a/packages/server/src/tasks/types.ts +++ b/packages/server/src/tasks/types.ts @@ -1,7 +1,13 @@ -export type TaskStatus = "draft" | "open" | "in_progress" | "done"; +export type TaskStatus = "draft" | "open" | "in_progress" | "done" | "failed"; export type AgentType = "claude" | "codex"; +export type ModelName = + | "haiku" + | "sonnet" + | "opus" + | `gpt-${string}`; + export interface Note { timestamp: string; // ISO date content: string; // markdown @@ -14,6 +20,7 @@ export interface Task { deps: string[]; // task IDs this task depends on (must be done before this can start) parentId?: string; // parent task ID for hierarchical structure (context inheritance) body: string; // long form markdown document + acceptanceCriteria: string[]; // immutable checklist items for verification notes: Note[]; created: string; // ISO date assignee?: AgentType; // optional agent override @@ -24,6 +31,7 @@ export interface CreateTaskOptions { parentId?: string; status?: TaskStatus; body?: string; + acceptanceCriteria?: string[]; assignee?: AgentType; } @@ -50,4 +58,8 @@ export interface TaskStore { open(id: string): Promise; // draft -> open start(id: string): Promise; // open -> in_progress close(id: string): Promise; // any -> done + fail(id: string): Promise; // any -> failed + + // Acceptance criteria + addAcceptanceCriteria(id: string, criterion: string): Promise; }