feat(server): improve task CLI with steering, execution order, and planner guidance
- Fix steering notes to check scope task instead of leaf task
- Fix NOT_DONE replan to use scope for bird's eye view
- Pass reason to planner prompt ("Why you were called" section)
- Add execution-order.ts with reusable computeExecutionOrder and buildSortedChildrenMap
- Make 'tree' an alias for 'plan' command
- Add comprehensive help text for run command and acceptance criteria
- Add planner guidance for propagating requirements to subtasks
This commit is contained in:
@@ -4,6 +4,10 @@ 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 {
|
||||
computeExecutionOrder,
|
||||
buildSortedChildrenMap,
|
||||
} from "./execution-order.js";
|
||||
import type { AgentType, ModelName, Task } from "./types.js";
|
||||
|
||||
const TASKS_DIR = resolve(process.cwd(), ".tasks");
|
||||
@@ -65,11 +69,6 @@ Examples:
|
||||
# See completed work
|
||||
task closed --scope abc123
|
||||
|
||||
# Run agent loop on an epic
|
||||
task run abc123
|
||||
task run abc123 --agent codex
|
||||
task run --watch
|
||||
|
||||
Body vs Notes:
|
||||
The BODY is the task's markdown document - edit it while grooming/defining the task.
|
||||
NOTES are timestamped entries added during implementation to document progress.
|
||||
@@ -77,6 +76,89 @@ Body vs Notes:
|
||||
- While defining a task: edit the body with "task update <id> --body ..."
|
||||
- While implementing: add notes with "task note <id> ..."
|
||||
- When done: add a final note explaining what was done, then close
|
||||
|
||||
---
|
||||
|
||||
The Run Command (task run):
|
||||
|
||||
The run command executes an agent loop that works until acceptance criteria are met.
|
||||
This can run for hours, days, or weeks for large epics.
|
||||
|
||||
Basic usage:
|
||||
task run <scope-id> # Run without planner (task must be well-defined)
|
||||
task run <scope-id> --plan # Run with planner (for larger epics)
|
||||
|
||||
Without --plan: The scoped task runs in a worker/judge loop. The worker implements,
|
||||
the judge verifies. If not done, it loops. No breakdown happens - the task must be
|
||||
atomic and well-defined from the start.
|
||||
|
||||
With --plan: The planner first breaks down the scope into subtasks, then workers
|
||||
execute leaf tasks while the planner can reorganize as needed. Use this for epics
|
||||
where you define WHAT you want but not HOW to achieve it.
|
||||
|
||||
When replanning happens:
|
||||
- At the start (initial breakdown)
|
||||
- When a task fails 5+ times (something is wrong with the approach)
|
||||
- When you add a steering note (task steer <scope-id> "new direction")
|
||||
|
||||
Monitoring a running loop:
|
||||
task plan <scope-id> # See execution timeline and progress
|
||||
task show <task-id> # Check specific task details
|
||||
tail -f task-run.*.log # Follow the live log
|
||||
|
||||
Steering mid-run:
|
||||
task steer <scope-id> "focus on X first"
|
||||
task steer <scope-id> "skip Y, it's not needed"
|
||||
task steer <scope-id> "run e2e tests every iteration"
|
||||
|
||||
Steering triggers an immediate replan. Use it to course-correct without stopping.
|
||||
|
||||
Writing Good Acceptance Criteria:
|
||||
|
||||
Acceptance criteria determine when a task is DONE. They must be specific enough
|
||||
that agents cannot "cheat" by taking shortcuts.
|
||||
|
||||
Bad (agents can game these):
|
||||
- "tests pass" → could delete tests, skip them, or weaken assertions
|
||||
- "code works" → subjective, unverifiable
|
||||
- "feature complete" → vague
|
||||
|
||||
Good (specific and non-gameable):
|
||||
- "npm test exits 0 with 0 failures and 0 skipped"
|
||||
- "test count >= 150 (no deleting tests)"
|
||||
- "coverage does not decrease from baseline"
|
||||
- "npm run typecheck exits 0"
|
||||
- "GET /api/users returns 200 with JSON array"
|
||||
|
||||
Philosophy: Specify the floor (minimum requirements) without capping the ceiling.
|
||||
"Zero failures, zero skipped" is strict but doesn't prevent adding more tests.
|
||||
|
||||
For test-related tasks, consider:
|
||||
--accept "npm test exits 0"
|
||||
--accept "no test files deleted"
|
||||
--accept "no .skip or test.todo added"
|
||||
--accept "no assertions removed or weakened"
|
||||
|
||||
Acceptance Criteria vs Body Guidance:
|
||||
|
||||
Acceptance criteria = required OUTPUT (must-haves, verified at the end)
|
||||
Body guidance = HOW to approach the work (instructions for the planner)
|
||||
|
||||
Acceptance criteria examples (verifiable end-state):
|
||||
- "PR created with description and test plan"
|
||||
- "Branch name follows convention: feat/<name>"
|
||||
- "npm run typecheck exits 0"
|
||||
|
||||
Body guidance examples (process instructions):
|
||||
- "Split work by module"
|
||||
- "Commit after each subtask"
|
||||
- "Run typecheck after each change"
|
||||
|
||||
The planner reads body guidance and propagates it into subtask acceptance criteria.
|
||||
For example, if the body says "commit after each chunk of work", the planner adds
|
||||
"git status shows clean working tree" to each subtask's criteria.
|
||||
|
||||
This makes process requirements verifiable at each step, not just at the end.
|
||||
`
|
||||
);
|
||||
|
||||
@@ -233,68 +315,106 @@ program
|
||||
});
|
||||
|
||||
program
|
||||
.command("tree <id>")
|
||||
.description("Show task hierarchy with dependencies")
|
||||
.action(async (id) => {
|
||||
const root = await store.get(id);
|
||||
if (!root) {
|
||||
process.stderr.write(`Task not found: ${id}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build a map of all tasks for dependency lookups
|
||||
.command("plan [scope]")
|
||||
.alias("tree")
|
||||
.description("Show execution timeline (tree view by default)")
|
||||
.option("--flat", "Show flat list instead of tree")
|
||||
.option("-d, --depth <n>", "Limit tree depth (0 = root only)")
|
||||
.action(async (scopeId: string | undefined, opts) => {
|
||||
const { timeline, orderMap, blocked } = await computeExecutionOrder(
|
||||
store,
|
||||
scopeId
|
||||
);
|
||||
const allTasks = await store.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
const maxDepth = opts.depth !== undefined ? parseInt(opts.depth, 10) : Infinity;
|
||||
|
||||
// Print a task line with optional dependency info
|
||||
const printTask = (task: Task, prefix: string, connector: string) => {
|
||||
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
||||
const priority = task.priority !== undefined ? ` !${task.priority}` : "";
|
||||
process.stdout.write(
|
||||
`${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}\n`
|
||||
);
|
||||
// Print dependencies on next line with arrow
|
||||
if (task.deps.length > 0) {
|
||||
const depNames = task.deps
|
||||
.map((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep ? `${dep.title} (${depId})` : depId;
|
||||
})
|
||||
.join(", ");
|
||||
const depPrefix = prefix + (connector === "└── " ? " " : "│ ");
|
||||
process.stdout.write(`${depPrefix}→ depends on: ${depNames}\n`);
|
||||
}
|
||||
const formatDeps = (task: Task): string => {
|
||||
if (task.deps.length === 0) return "";
|
||||
return ` (deps: ${task.deps.join(", ")})`;
|
||||
};
|
||||
|
||||
// Print root task
|
||||
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
||||
const rootPriority = root.priority !== undefined ? ` !${root.priority}` : "";
|
||||
process.stdout.write(`${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}\n`);
|
||||
if (root.deps.length > 0) {
|
||||
const depNames = root.deps
|
||||
.map((depId) => {
|
||||
const dep = taskMap.get(depId);
|
||||
return dep ? `${dep.title} (${depId})` : depId;
|
||||
})
|
||||
.join(", ");
|
||||
process.stdout.write(`→ depends on: ${depNames}\n`);
|
||||
if (opts.flat) {
|
||||
// Flat list view
|
||||
const printTask = (t: Task, idx: number) => {
|
||||
const priority = t.priority !== undefined ? `!${t.priority} ` : "";
|
||||
const assignee = t.assignee ? ` @${t.assignee}` : "";
|
||||
const num = String(idx + 1).padStart(3, " ");
|
||||
const mark = t.status === "done" ? "✓" : " ";
|
||||
const deps = formatDeps(t);
|
||||
process.stdout.write(
|
||||
`${mark}${num}. ${priority}${t.id} ${t.title}${assignee}${deps}\n`
|
||||
);
|
||||
};
|
||||
|
||||
for (let i = 0; i < timeline.length; i++) {
|
||||
printTask(timeline[i], i);
|
||||
}
|
||||
} else {
|
||||
// Tree view (default)
|
||||
if (!scopeId) {
|
||||
process.stderr.write("Tree view requires a scope ID\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const root = await store.get(scopeId);
|
||||
if (!root) {
|
||||
process.stderr.write(`Task not found: ${scopeId}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sortedChildrenMap = buildSortedChildrenMap(allTasks, orderMap);
|
||||
|
||||
const printTask = (
|
||||
task: Task,
|
||||
prefix: string,
|
||||
connector: string,
|
||||
depth: number
|
||||
) => {
|
||||
const assignee = task.assignee ? ` @${task.assignee}` : "";
|
||||
const priority =
|
||||
task.priority !== undefined ? ` !${task.priority}` : "";
|
||||
const mark = task.status === "done" ? "✓ " : " ";
|
||||
const deps = formatDeps(task);
|
||||
process.stdout.write(
|
||||
`${mark}${prefix}${connector}${task.id} [${task.status}] ${task.title}${priority}${assignee}${deps}\n`
|
||||
);
|
||||
};
|
||||
|
||||
// Print root
|
||||
const rootAssignee = root.assignee ? ` @${root.assignee}` : "";
|
||||
const rootPriority =
|
||||
root.priority !== undefined ? ` !${root.priority}` : "";
|
||||
const rootMark = root.status === "done" ? "✓ " : " ";
|
||||
const rootDeps = formatDeps(root);
|
||||
process.stdout.write(
|
||||
`${rootMark}${root.id} [${root.status}] ${root.title}${rootPriority}${rootAssignee}${rootDeps}\n`
|
||||
);
|
||||
|
||||
// Recursively print children in execution order
|
||||
const printChildren = (parentId: string, prefix: string, depth: number) => {
|
||||
if (depth >= maxDepth) return;
|
||||
const children = sortedChildrenMap.get(parentId) ?? [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
const isLast = i === children.length - 1;
|
||||
const connector = isLast ? "└── " : "├── ";
|
||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||
|
||||
printTask(child, prefix, connector, depth);
|
||||
printChildren(child.id, childPrefix, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
printChildren(scopeId, "", 0);
|
||||
}
|
||||
|
||||
// Recursively print children (hierarchy)
|
||||
const printChildren = async (parentId: string, prefix: string) => {
|
||||
const children = await store.getChildren(parentId);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
const isLast = i === children.length - 1;
|
||||
const connector = isLast ? "└── " : "├── ";
|
||||
const childPrefix = prefix + (isLast ? " " : "│ ");
|
||||
|
||||
printTask(child, prefix, connector);
|
||||
await printChildren(child.id, childPrefix);
|
||||
}
|
||||
};
|
||||
|
||||
await printChildren(id, "");
|
||||
if (blocked.size > 0) {
|
||||
process.stdout.write(`\n... +${blocked.size} blocked/unreachable\n`);
|
||||
}
|
||||
if (timeline.length === 0) {
|
||||
process.stdout.write("No tasks.\n");
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
@@ -590,7 +710,7 @@ ${task.body || "(no body)"}
|
||||
${acceptanceCriteriaText}${notesText}`;
|
||||
}
|
||||
|
||||
function makePlannerPrompt(task: Task, context: string, iteration: number): string {
|
||||
function makePlannerPrompt(task: Task, context: string, reason: string): string {
|
||||
return `${context}
|
||||
|
||||
---
|
||||
@@ -599,7 +719,9 @@ You are a PLANNER agent. You ORCHESTRATE work - you do NOT do the work yourself.
|
||||
|
||||
Your job is to organize tasks so that WORKER agents can accomplish the top-level acceptance criteria.
|
||||
|
||||
Current iteration: ${iteration}
|
||||
## Why you were called
|
||||
|
||||
${reason}
|
||||
|
||||
## Planning vs Working
|
||||
|
||||
@@ -617,29 +739,71 @@ As a planner you must NOT:
|
||||
Take the task descriptions AS GIVEN and create well-organized subtasks for workers to investigate and implement.
|
||||
|
||||
## Task CLI Commands
|
||||
- \`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
|
||||
|
||||
### Understanding the plan
|
||||
- \`task plan ${task.id}\` - **START HERE**: shows full execution timeline (done ✓ then pending in order)
|
||||
- \`task tree ${task.id}\` - see task hierarchy with dependencies (parent → children)
|
||||
- \`task show <id>\` - view task details, body, acceptance criteria, and notes
|
||||
- \`task children <id>\` - list direct children of a task
|
||||
- \`task ready --scope ${task.id}\` - see what's ready to work on right now
|
||||
- \`task blocked --scope ${task.id}\` - see what's blocked and why
|
||||
|
||||
### Navigating the task tree
|
||||
|
||||
The tree has two relationships:
|
||||
1. **Parent/children** (hierarchy): \`--parent <id>\` groups tasks. Use \`task tree\` and \`task children\`.
|
||||
2. **Dependencies** (ordering): \`--deps <id>\` blocks execution. Shown as \`← [dep1, dep2]\` in tree output.
|
||||
|
||||
To understand what workers will actually implement, find the **leaf tasks** (tasks with no children).
|
||||
Parent tasks are just containers - workers execute leaf tasks.
|
||||
|
||||
**Always navigate down to leaves:**
|
||||
\`\`\`
|
||||
task tree ${task.id} # see full structure
|
||||
task children <parent> # drill into a branch
|
||||
task show <leaf> # read the actual work item
|
||||
\`\`\`
|
||||
|
||||
When planning, ensure leaf tasks have:
|
||||
- Clear acceptance criteria (verifiable by judge)
|
||||
- Correct dependencies (won't run until deps are done)
|
||||
- Appropriate priority (controls order among siblings)
|
||||
|
||||
### Modifying tasks
|
||||
- \`task create "title" --parent <id> --body "..." --accept "criterion" -p <priority>\` - create task
|
||||
- \`task update <id> --title "..." --body "..." -p <priority>\` - update task properties
|
||||
- \`task update <id> --accept "criterion"\` - add acceptance criterion
|
||||
- \`task update <id> --clear-acceptance -a "new criterion"\` - replace all acceptance criteria
|
||||
- \`task delete <id>\` - remove a task permanently
|
||||
- \`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
|
||||
|
||||
### Priority (-p flag)
|
||||
Priority controls execution order. **Lower number = higher priority** (executed first).
|
||||
- \`-p 0\` - critical/urgent, do immediately
|
||||
- \`-p 1\` - high priority
|
||||
- \`-p 2\` - normal priority
|
||||
- (no priority) - lowest, done after all prioritized tasks
|
||||
|
||||
When user steers with urgent changes, set priority on new tasks to ensure they run BEFORE existing tasks.
|
||||
Example: \`task create "Fix critical bug" --parent ${task.id} -p 0 --accept "..."\`
|
||||
Example: \`task update <id> -p 0\` - bump existing task to run next
|
||||
|
||||
## 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
|
||||
- **Deleted** if no longer relevant (use \`task delete <id>\`)
|
||||
- **Reprioritized** to change execution order (use \`task update <id> -p <n>\`)
|
||||
- **Broken down** into subtasks
|
||||
- **Updated** with better acceptance criteria
|
||||
|
||||
User steering notes override the task body - follow them.
|
||||
**You are FREE to delete tasks and start over.** If the plan isn't working, throw it away. If user steers in a new direction, delete obsolete tasks rather than leaving them in the queue.
|
||||
|
||||
Always keep the top-level goal in mind when reorganizing.
|
||||
User steering notes (STEER:) override the task body - follow them immediately by:
|
||||
1. Creating new tasks with high priority (-p 0 or -p 1)
|
||||
2. Deleting tasks that are now obsolete
|
||||
3. Adjusting priorities on existing tasks if needed
|
||||
|
||||
## Writing Good Acceptance Criteria
|
||||
|
||||
@@ -660,6 +824,33 @@ Good examples:
|
||||
- "File src/utils/parser.ts exports parseConfig function"
|
||||
- "Running \`node cli.js --help\` prints usage information"
|
||||
|
||||
## Propagating Requirements to Subtasks
|
||||
|
||||
Both body guidance AND acceptance criteria must be propagated to subtasks. Break them
|
||||
down so requirements are verified at each step, not deferred until the end.
|
||||
|
||||
**Propagate body guidance** (process instructions → subtask criteria):
|
||||
|
||||
Example: Body says "commit after each chunk of work"
|
||||
→ Add to EACH subtask: --accept "git status shows clean working tree"
|
||||
|
||||
Example: Body says "run typecheck after each change"
|
||||
→ Add to EACH subtask: --accept "npm run typecheck exits 0"
|
||||
|
||||
**Propagate acceptance criteria** (scope them to the subtask's module/area):
|
||||
|
||||
Example: Top-level says "no test.skip added"
|
||||
→ Subtask for module X: --accept "no test.skip in src/modules/X/**"
|
||||
|
||||
Example: Top-level says "npm test passes with 0 failures"
|
||||
→ Subtask for auth module: --accept "npm test src/auth passes with 0 failures"
|
||||
|
||||
Example: Top-level says "coverage >= 80%"
|
||||
→ Subtask for utils: --accept "coverage for src/utils >= 80%"
|
||||
|
||||
This ensures requirements are enforced incrementally. Don't wait until the end to
|
||||
verify - by then it's too late to fix without rework.
|
||||
|
||||
## TDD Pattern
|
||||
|
||||
When the top-level task mentions "TDD" or "test-driven", you MUST structure subtasks as test-first pairs:
|
||||
@@ -701,6 +892,17 @@ Key points:
|
||||
|
||||
Note: Multiple iterations are fine if there's progress. Only mark failed if truly stuck with no way forward.
|
||||
|
||||
## Before You Exit - Sanity Check
|
||||
|
||||
ALWAYS run these checks before finishing:
|
||||
|
||||
1. **Run \`task plan ${task.id}\`** - verify the execution order makes sense
|
||||
2. **Check priorities** - are urgent/steering tasks at the top (low priority numbers)?
|
||||
3. **Check acceptance criteria** - does every pending task have clear, verifiable criteria?
|
||||
4. **Clean up obsolete tasks** - delete anything that's no longer relevant
|
||||
|
||||
If something looks wrong, fix it before exiting.
|
||||
|
||||
DO NOT implement tasks. Only plan and organize.
|
||||
When done planning, simply exit. Do not mark tasks as done.
|
||||
`;
|
||||
@@ -836,7 +1038,7 @@ program
|
||||
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);
|
||||
const plannerPrompt = makePlannerPrompt(task, context, reason);
|
||||
runAgentWithModel(plannerPrompt, plannerModel, logFile);
|
||||
|
||||
// Check if planner created subtasks for this task
|
||||
@@ -895,7 +1097,13 @@ program
|
||||
}
|
||||
|
||||
const task = ready[0];
|
||||
const workerModel = (task.assignee === "codex" ? "gpt-5.2" : "sonnet") as ModelName;
|
||||
const baseModel = (
|
||||
task.assignee === "codex" ? "gpt-5.2-codex" :
|
||||
task.assignee ? task.assignee :
|
||||
"sonnet"
|
||||
) as ModelName;
|
||||
let workerModel = baseModel;
|
||||
const canUpgrade = baseModel === "sonnet"; // Only upgrade if starting from sonnet
|
||||
|
||||
log(logFile, `\n=== Starting task: ${task.id} - ${task.title} ===`);
|
||||
await store.start(task.id);
|
||||
@@ -904,27 +1112,36 @@ program
|
||||
let iteration = 1;
|
||||
let taskDone = false;
|
||||
let consecutiveNotDone = 0;
|
||||
let lastSeenSteerCount = task.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||
// Track steering notes on scope (not leaf task) since that's where user adds them
|
||||
const scopeTaskForSteer = scopeId ? await store.get(scopeId) : null;
|
||||
let lastSeenSteerCount = scopeTaskForSteer?.notes.filter(n => n.content.startsWith("STEER:")).length ?? 0;
|
||||
|
||||
while ((maxIterations === 0 || iteration <= maxIterations) && !taskDone) {
|
||||
const iterLabel = maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`;
|
||||
log(logFile, `[WORKER] Iteration ${iterLabel} with ${workerModel}...`);
|
||||
|
||||
// Check for new steering notes before worker runs
|
||||
const preTask = await store.get(task.id);
|
||||
if (!preTask) break;
|
||||
const currentSteerCount = preTask.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||
if (enablePlanner && currentSteerCount > lastSeenSteerCount) {
|
||||
const newSteers = preTask.notes.filter(n => n.content.startsWith("STEER:")).slice(lastSeenSteerCount);
|
||||
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
||||
lastSeenSteerCount = currentSteerCount;
|
||||
const shouldRestart = await runPlanner(preTask, `steering: ${newSteers.map(n => n.content).join("; ")}`);
|
||||
if (shouldRestart) {
|
||||
const updatedTask = await store.get(task.id);
|
||||
if (updatedTask && updatedTask.status === "in_progress") {
|
||||
await store.update(task.id, { status: "open" });
|
||||
// Check for new steering notes on scope before worker runs
|
||||
if (enablePlanner && scopeId) {
|
||||
const freshScope = await store.get(scopeId);
|
||||
if (freshScope) {
|
||||
const currentSteerCount = freshScope.notes.filter(n => n.content.startsWith("STEER:")).length;
|
||||
if (currentSteerCount > lastSeenSteerCount) {
|
||||
const newSteers = freshScope.notes
|
||||
.filter(n => n.content.startsWith("STEER:"))
|
||||
.slice(lastSeenSteerCount)
|
||||
.map(n => n.content.replace(/^STEER:\s*/, ""));
|
||||
log(logFile, `[STEER] New steering note detected - triggering planner`);
|
||||
lastSeenSteerCount = currentSteerCount;
|
||||
const reason = `User steering: ${newSteers.join("; ")}`;
|
||||
const shouldRestart = await runPlanner(freshScope, reason);
|
||||
if (shouldRestart) {
|
||||
const updatedTask = await store.get(task.id);
|
||||
if (updatedTask && updatedTask.status === "in_progress") {
|
||||
await store.update(task.id, { status: "open" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,12 +1171,18 @@ program
|
||||
consecutiveNotDone = 0;
|
||||
} else {
|
||||
consecutiveNotDone++;
|
||||
// Only replan after 5 consecutive NOT_DONEs
|
||||
if (enablePlanner && consecutiveNotDone >= 5) {
|
||||
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE - triggering planner`);
|
||||
const currentTask = await store.get(task.id);
|
||||
if (currentTask) {
|
||||
const shouldRestart = await runPlanner(currentTask, `${consecutiveNotDone} consecutive NOT_DONE`);
|
||||
// Upgrade to opus on first NOT_DONE (if not already using opus)
|
||||
if (canUpgrade && workerModel === "sonnet") {
|
||||
workerModel = "opus";
|
||||
log(logFile, `[WORKER] Upgrading to opus after NOT_DONE`);
|
||||
}
|
||||
// Only replan after 5 consecutive NOT_DONEs - use scope for bird's eye view
|
||||
if (enablePlanner && consecutiveNotDone >= 5 && scopeId) {
|
||||
log(logFile, `[JUDGE] ${consecutiveNotDone} consecutive NOT_DONE on ${task.id} - triggering planner`);
|
||||
const scopeTask = await store.get(scopeId);
|
||||
if (scopeTask) {
|
||||
const reason = `task ${task.id} "${task.title}" got ${consecutiveNotDone} consecutive NOT_DONE`;
|
||||
const shouldRestart = await runPlanner(scopeTask, reason);
|
||||
consecutiveNotDone = 0;
|
||||
if (shouldRestart) {
|
||||
// Planner created subtasks or marked failed, restart the main loop
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js";
|
||||
import type { Task, TaskStore } from "./types.js";
|
||||
|
||||
/**
|
||||
* In-memory task store for testing
|
||||
*/
|
||||
class MemoryTaskStore implements TaskStore {
|
||||
private tasks: Map<string, Task> = new Map();
|
||||
|
||||
addTask(task: Task): void {
|
||||
this.tasks.set(task.id, task);
|
||||
}
|
||||
|
||||
async list(): Promise<Task[]> {
|
||||
return [...this.tasks.values()];
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Task | null> {
|
||||
return this.tasks.get(id) ?? null;
|
||||
}
|
||||
|
||||
async getDescendants(id: string): Promise<Task[]> {
|
||||
const result: Task[] = [];
|
||||
const traverse = (parentId: string): void => {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentId === parentId) {
|
||||
result.push(task);
|
||||
traverse(task.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
traverse(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Unused in tests but required by interface
|
||||
async getDepTree(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
async getAncestors(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
async getChildren(id: string): Promise<Task[]> {
|
||||
return [...this.tasks.values()].filter((t) => t.parentId === id);
|
||||
}
|
||||
async getReady(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
async getBlocked(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
async getClosed(): Promise<Task[]> {
|
||||
return [];
|
||||
}
|
||||
async create(): Promise<Task> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async update(): Promise<Task> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async delete(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async addDep(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async removeDep(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async setParent(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async addNote(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async open(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async start(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async fail(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
async addAcceptanceCriteria(): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
function makeTask(
|
||||
id: string,
|
||||
opts: {
|
||||
title?: string;
|
||||
status?: Task["status"];
|
||||
parentId?: string;
|
||||
deps?: string[];
|
||||
priority?: number;
|
||||
created?: string;
|
||||
} = {}
|
||||
): Task {
|
||||
return {
|
||||
id,
|
||||
title: opts.title ?? id,
|
||||
status: opts.status ?? "open",
|
||||
parentId: opts.parentId,
|
||||
deps: opts.deps ?? [],
|
||||
priority: opts.priority,
|
||||
created: opts.created ?? new Date().toISOString(),
|
||||
body: "",
|
||||
acceptanceCriteria: [],
|
||||
notes: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeExecutionOrder", () => {
|
||||
let store: MemoryTaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new MemoryTaskStore();
|
||||
});
|
||||
|
||||
it("should return empty timeline for empty store", async () => {
|
||||
const result = await computeExecutionOrder(store);
|
||||
expect(result.timeline).toEqual([]);
|
||||
expect(result.orderMap.size).toBe(0);
|
||||
expect(result.blocked.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should order by priority (lower number first)", async () => {
|
||||
store.addTask(makeTask("a", { priority: 2, created: "2024-01-01" }));
|
||||
store.addTask(makeTask("b", { priority: 0, created: "2024-01-02" }));
|
||||
store.addTask(makeTask("c", { priority: 1, created: "2024-01-03" }));
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
expect(ids).toEqual(["b", "c", "a"]);
|
||||
});
|
||||
|
||||
it("should order tasks without priority after prioritized tasks", async () => {
|
||||
store.addTask(makeTask("a", { created: "2024-01-01" })); // no priority
|
||||
store.addTask(makeTask("b", { priority: 1, created: "2024-01-02" }));
|
||||
store.addTask(makeTask("c", { created: "2024-01-03" })); // no priority
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// b first (has priority), then a and c by created date
|
||||
expect(ids).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("should respect dependencies", async () => {
|
||||
// c depends on b
|
||||
store.addTask(makeTask("a", { created: "2024-01-01" }));
|
||||
store.addTask(makeTask("b", { created: "2024-01-02" }));
|
||||
store.addTask(makeTask("c", { deps: ["b"], created: "2024-01-03" }));
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// a and b can run (a first by created), then c after b
|
||||
expect(ids).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should execute children before parent", async () => {
|
||||
// Parent b has children b1 and b2
|
||||
store.addTask(makeTask("b", { created: "2024-01-01" }));
|
||||
store.addTask(makeTask("b1", { parentId: "b", created: "2024-01-02" }));
|
||||
store.addTask(makeTask("b2", { parentId: "b", created: "2024-01-03" }));
|
||||
|
||||
const result = await computeExecutionOrder(store, "b");
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// Children first, then parent
|
||||
expect(ids).toEqual(["b1", "b2", "b"]);
|
||||
});
|
||||
|
||||
it("should handle complex epic with phases and dependencies", async () => {
|
||||
/**
|
||||
* Epic structure:
|
||||
*
|
||||
* epic (root)
|
||||
* ├── phase1 (priority 0)
|
||||
* │ ├── p1-task1
|
||||
* │ └── p1-task2 (depends on p1-task1)
|
||||
* ├── phase2 (depends on phase1)
|
||||
* │ ├── p2-task1
|
||||
* │ └── p2-task2
|
||||
* └── cleanup (no priority, depends on phase2)
|
||||
*/
|
||||
|
||||
// Epic root
|
||||
store.addTask(makeTask("epic", { created: "2024-01-01" }));
|
||||
|
||||
// Phase 1 - priority 0 (should run first)
|
||||
store.addTask(
|
||||
makeTask("phase1", {
|
||||
parentId: "epic",
|
||||
priority: 0,
|
||||
created: "2024-01-02",
|
||||
})
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("p1-task1", { parentId: "phase1", created: "2024-01-03" })
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("p1-task2", {
|
||||
parentId: "phase1",
|
||||
deps: ["p1-task1"],
|
||||
created: "2024-01-04",
|
||||
})
|
||||
);
|
||||
|
||||
// Phase 2 - depends on phase1
|
||||
store.addTask(
|
||||
makeTask("phase2", {
|
||||
parentId: "epic",
|
||||
deps: ["phase1"],
|
||||
created: "2024-01-05",
|
||||
})
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("p2-task1", { parentId: "phase2", created: "2024-01-06" })
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("p2-task2", { parentId: "phase2", created: "2024-01-07" })
|
||||
);
|
||||
|
||||
// Cleanup - no priority, depends on phase2
|
||||
store.addTask(
|
||||
makeTask("cleanup", {
|
||||
parentId: "epic",
|
||||
deps: ["phase2"],
|
||||
created: "2024-01-08",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await computeExecutionOrder(store, "epic");
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// Expected order:
|
||||
// 1. p1-task1 (leaf, no deps)
|
||||
// 2. p1-task2 (leaf, deps on p1-task1)
|
||||
// 3. phase1 (parent, children done)
|
||||
// 4. p2-task1, p2-task2 (leaves, phase1 done)
|
||||
// 5. phase2 (parent, children done, dep on phase1 done)
|
||||
// 6. cleanup (dep on phase2 done)
|
||||
// 7. epic (all children done)
|
||||
|
||||
expect(ids).toEqual([
|
||||
"p1-task1",
|
||||
"p1-task2",
|
||||
"phase1",
|
||||
"p2-task1",
|
||||
"p2-task2",
|
||||
"phase2",
|
||||
"cleanup",
|
||||
"epic",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle priority override within same parent", async () => {
|
||||
/**
|
||||
* Parent with children where priority overrides created order:
|
||||
*
|
||||
* parent
|
||||
* ├── child-a (no priority, created first)
|
||||
* ├── child-b (priority 0, created second) <- should run first
|
||||
* └── child-c (priority 1, created third)
|
||||
*/
|
||||
|
||||
store.addTask(makeTask("parent", { created: "2024-01-01" }));
|
||||
store.addTask(
|
||||
makeTask("child-a", { parentId: "parent", created: "2024-01-02" })
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("child-b", {
|
||||
parentId: "parent",
|
||||
priority: 0,
|
||||
created: "2024-01-03",
|
||||
})
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("child-c", {
|
||||
parentId: "parent",
|
||||
priority: 1,
|
||||
created: "2024-01-04",
|
||||
})
|
||||
);
|
||||
|
||||
const result = await computeExecutionOrder(store, "parent");
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// b first (priority 0), c second (priority 1), a last (no priority), then parent
|
||||
expect(ids).toEqual(["child-b", "child-c", "child-a", "parent"]);
|
||||
});
|
||||
|
||||
it("should place done tasks first in historical order", async () => {
|
||||
store.addTask(
|
||||
makeTask("done-later", { status: "done", created: "2024-01-03" })
|
||||
);
|
||||
store.addTask(
|
||||
makeTask("done-first", { status: "done", created: "2024-01-01" })
|
||||
);
|
||||
store.addTask(makeTask("pending", { created: "2024-01-02" }));
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
// Done tasks first (by created), then pending
|
||||
expect(ids).toEqual(["done-first", "done-later", "pending"]);
|
||||
});
|
||||
|
||||
it("should mark tasks with unresolvable deps as blocked", async () => {
|
||||
// b depends on external-dep which doesn't exist in scope
|
||||
store.addTask(makeTask("a", { created: "2024-01-01" }));
|
||||
store.addTask(
|
||||
makeTask("b", { deps: ["external-dep"], created: "2024-01-02" })
|
||||
);
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
|
||||
expect(result.timeline.map((t) => t.id)).toEqual(["a"]);
|
||||
expect(result.blocked.has("b")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect circular dependencies as blocked", async () => {
|
||||
// a depends on b, b depends on a
|
||||
store.addTask(makeTask("a", { deps: ["b"], created: "2024-01-01" }));
|
||||
store.addTask(makeTask("b", { deps: ["a"], created: "2024-01-02" }));
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
|
||||
expect(result.timeline).toEqual([]);
|
||||
expect(result.blocked.has("a")).toBe(true);
|
||||
expect(result.blocked.has("b")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle in_progress tasks same as open", async () => {
|
||||
store.addTask(makeTask("a", { status: "in_progress", created: "2024-01-01" }));
|
||||
store.addTask(makeTask("b", { status: "open", created: "2024-01-02" }));
|
||||
|
||||
const result = await computeExecutionOrder(store);
|
||||
const ids = result.timeline.map((t) => t.id);
|
||||
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSortedChildrenMap", () => {
|
||||
it("should sort children by execution order", () => {
|
||||
const tasks: Task[] = [
|
||||
makeTask("parent"),
|
||||
makeTask("child-a", { parentId: "parent" }),
|
||||
makeTask("child-b", { parentId: "parent" }),
|
||||
makeTask("child-c", { parentId: "parent" }),
|
||||
];
|
||||
|
||||
// Execution order: child-c (0), child-a (1), child-b (2)
|
||||
const orderMap = new Map<string, number>([
|
||||
["child-c", 0],
|
||||
["child-a", 1],
|
||||
["child-b", 2],
|
||||
["parent", 3],
|
||||
]);
|
||||
|
||||
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
|
||||
const children = childrenMap.get("parent")!;
|
||||
|
||||
expect(children.map((t) => t.id)).toEqual(["child-c", "child-a", "child-b"]);
|
||||
});
|
||||
|
||||
it("should handle tasks not in order map (put at end)", () => {
|
||||
const tasks: Task[] = [
|
||||
makeTask("parent"),
|
||||
makeTask("child-a", { parentId: "parent" }),
|
||||
makeTask("child-b", { parentId: "parent" }),
|
||||
];
|
||||
|
||||
// Only child-b in order map
|
||||
const orderMap = new Map<string, number>([["child-b", 0]]);
|
||||
|
||||
const childrenMap = buildSortedChildrenMap(tasks, orderMap);
|
||||
const children = childrenMap.get("parent")!;
|
||||
|
||||
// child-b first (has order), child-a last (Infinity)
|
||||
expect(children.map((t) => t.id)).toEqual(["child-b", "child-a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Task, TaskStore } from "./types.js";
|
||||
|
||||
function sortByPriorityThenCreated(a: Task, b: Task): number {
|
||||
if (a.priority !== undefined && b.priority === undefined) return -1;
|
||||
if (a.priority === undefined && b.priority !== undefined) return 1;
|
||||
if (a.priority !== undefined && b.priority !== undefined) {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
}
|
||||
return a.created.localeCompare(b.created);
|
||||
}
|
||||
|
||||
export interface ExecutionOrderResult {
|
||||
/** Tasks in execution order (done first, then pending) */
|
||||
timeline: Task[];
|
||||
/** Map from task ID to execution order index */
|
||||
orderMap: Map<string, number>;
|
||||
/** Task IDs that are blocked/unreachable */
|
||||
blocked: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes execution order for tasks within a scope.
|
||||
* Simulates running `task ready` repeatedly until no tasks remain.
|
||||
*
|
||||
* @param store - Task store for fetching tasks
|
||||
* @param scopeId - Optional scope task ID (if omitted, uses all tasks)
|
||||
* @returns Execution order result with timeline, order map, and blocked tasks
|
||||
*/
|
||||
export async function computeExecutionOrder(
|
||||
store: TaskStore,
|
||||
scopeId?: string
|
||||
): Promise<ExecutionOrderResult> {
|
||||
const allTasks = await store.list();
|
||||
const taskMap = new Map(allTasks.map((t) => [t.id, t]));
|
||||
|
||||
// Get scoped tasks
|
||||
let candidates: Task[];
|
||||
if (scopeId) {
|
||||
const scopeTask = await store.get(scopeId);
|
||||
const descendants = await store.getDescendants(scopeId);
|
||||
candidates = scopeTask ? [scopeTask, ...descendants] : descendants;
|
||||
} else {
|
||||
candidates = allTasks;
|
||||
}
|
||||
|
||||
// Build children map
|
||||
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 candidateIds = new Set(candidates.map((t) => t.id));
|
||||
|
||||
// Simulate execution: track done status
|
||||
// Include all done tasks (not just in scope) for dep resolution
|
||||
const simDone = new Set(
|
||||
allTasks.filter((t) => t.status === "done").map((t) => t.id)
|
||||
);
|
||||
const remaining = new Set(
|
||||
candidates
|
||||
.filter((t) => t.status === "open" || t.status === "in_progress")
|
||||
.map((t) => t.id)
|
||||
);
|
||||
|
||||
const isReady = (taskId: string): boolean => {
|
||||
const task = taskMap.get(taskId);
|
||||
if (!task) return false;
|
||||
// All deps done (deps can be outside scope)
|
||||
const depsOk = task.deps.every((depId) => simDone.has(depId));
|
||||
// All children done (only consider children in scope)
|
||||
const children = (childrenMap.get(taskId) ?? []).filter((c) =>
|
||||
candidateIds.has(c.id)
|
||||
);
|
||||
const childrenOk = children.every((c) => simDone.has(c.id));
|
||||
return depsOk && childrenOk;
|
||||
};
|
||||
|
||||
const timeline: Task[] = [];
|
||||
const orderMap = new Map<string, number>();
|
||||
let orderIdx = 0;
|
||||
|
||||
// Done tasks first (by created date = historical execution order)
|
||||
const done = candidates
|
||||
.filter((t) => t.status === "done")
|
||||
.sort((a, b) => a.created.localeCompare(b.created));
|
||||
for (const t of done) {
|
||||
timeline.push(t);
|
||||
orderMap.set(t.id, orderIdx++);
|
||||
}
|
||||
|
||||
// Then pending tasks in execution order
|
||||
while (remaining.size > 0) {
|
||||
const readyNow = [...remaining]
|
||||
.filter(isReady)
|
||||
.map((tid) => taskMap.get(tid)!)
|
||||
.sort(sortByPriorityThenCreated);
|
||||
|
||||
if (readyNow.length === 0) break; // No more can be done (cycle or blocked)
|
||||
|
||||
const next = readyNow[0];
|
||||
timeline.push(next);
|
||||
orderMap.set(next.id, orderIdx++);
|
||||
simDone.add(next.id);
|
||||
remaining.delete(next.id);
|
||||
}
|
||||
|
||||
// Remaining are blocked/unreachable
|
||||
const blocked = remaining;
|
||||
|
||||
return { timeline, orderMap, blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of parent ID to children sorted by execution order.
|
||||
*/
|
||||
export function buildSortedChildrenMap(
|
||||
tasks: Task[],
|
||||
orderMap: Map<string, number>
|
||||
): Map<string, Task[]> {
|
||||
const childrenMap = new Map<string, Task[]>();
|
||||
|
||||
for (const t of tasks) {
|
||||
if (t.parentId) {
|
||||
const siblings = childrenMap.get(t.parentId) ?? [];
|
||||
siblings.push(t);
|
||||
childrenMap.set(t.parentId, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort each group by execution order
|
||||
for (const [parentId, children] of childrenMap) {
|
||||
children.sort((a, b) => {
|
||||
const orderA = orderMap.get(a.id) ?? Infinity;
|
||||
const orderB = orderMap.get(b.id) ?? Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
childrenMap.set(parentId, children);
|
||||
}
|
||||
|
||||
return childrenMap;
|
||||
}
|
||||
Reference in New Issue
Block a user