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:
@@ -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...");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user