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...");
}
}