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