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:
Mohamed Boudra
2026-01-17 11:46:38 +07:00
parent 420f25f1e9
commit d964415eba
33 changed files with 5205 additions and 3922 deletions
+322 -99
View File
@@ -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;
}