feat(task): add acceptance criteria, planner/worker/judge loop

- Add acceptanceCriteria field to tasks (structured checklist)
- Add --accept flag to create/update commands (repeatable, append-only)
- Add failed status for catastrophically stuck tasks
- Implement planner/worker/judge agent loop in run command:
  - Planner (gpt-5.2): breaks down tasks, reorganizes tree, writes criteria
  - Worker (sonnet/codex): implements tasks, cannot mark done
  - Judge (haiku): verifies acceptance criteria with no-excuses policy
- Hybrid replanning: after NOT_DONE and every N completions
- Parent tasks blocked until all children are done
- Add getDescendants method for children tree traversal
- Update getReady/getBlocked/getClosed to use children tree for scope

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Mohamed Boudra
2026-01-15 10:00:01 +07:00
parent 86ea9966fc
commit 02b3f83ae2
4 changed files with 607 additions and 122 deletions
+369 -81
View File
@@ -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 <id>", "Parent task ID (for hierarchy)")
.option("--assignee <agent>", "Agent to assign (claude or codex)")
.option("--draft", "Create as draft (not actionable)")
.option("-a, --accept <criterion>", "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 <text>", "New title")
.option("-b, --body <text>", "New body (use '-' to read from stdin)")
.option("--assignee <agent>", "New assignee (claude or codex)")
.option("-a, --accept <criterion>", "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<string> {
const scopeArg = scopeId ? ` --scope ${scopeId}` : "";
program
.command("fail <id>")
.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<string> {
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 <id>\` - view task details with parent context
- \`task children <id>\` - list subtasks
- \`task update <id> --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 <id> --body "..." --accept "criterion"\` - create task (any level)
- \`task update <id> --title "..." --body "..." --accept "criterion"\` - update task
- \`task note <id> "content"\` - add planning notes
- \`task delete <id>\` - remove a task that's no longer needed
- \`task fail <id>\` - 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:
<VERDICT>DONE</VERDICT>
If ANY criterion fails verification:
<VERDICT>NOT_DONE</VERDICT>
<FAILED_CRITERIA>
- criterion that failed: what you checked and what you found
</FAILED_CRITERIA>
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(/<VERDICT>(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 <type>", "Agent to use (claude or codex)", "claude")
.description("Run agent loop on tasks with planner/worker/judge")
.option("--plan", "Enable planner agent")
.option("--planner <model>", "Planner model (default: gpt-5.2)", "gpt-5.2")
.option("--replan <n>", "Run planner every N completed tasks (default: 3)", "3")
.option("--judge <model>", "Judge model (default: haiku)", "haiku")
.option("--max-iterations <n>", "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<void> => {
const runPlanner = async (task: Task, reason: string): Promise<boolean> => {
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<void> => {
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...");
}
}
+142 -30
View File
@@ -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);
});
});
});
+83 -10
View File
@@ -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<Task[]> {
const result: Task[] = [];
const traverse = async (parentId: string): Promise<void> => {
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<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;
// 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<string, Task[]>();
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<Task[]> {
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<void> {
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<void> {
const task = await this.get(id);
if (!task) {
throw new Error(`Task not found: ${id}`);
}
task.acceptanceCriteria.push(criterion);
await this.writeTask(task);
}
}
+13 -1
View File
@@ -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<void>; // draft -> open
start(id: string): Promise<void>; // open -> in_progress
close(id: string): Promise<void>; // any -> done
fail(id: string): Promise<void>; // any -> failed
// Acceptance criteria
addAcceptanceCriteria(id: string, criterion: string): Promise<void>;
}