feat: taskplane-style specs + CLI step tracking

- Triage agent generates full PROMPT.md (mission, steps, file scope,
  review level, docs requirements, commit conventions, guardrails)
- Executor agent reports progress via hai task CLI
- hai task update <id> <step> <status> — step lifecycle
- hai task log <id> <message> — execution log
- hai task discover <id> <what> <disp> — record discoveries
- hai task show <id> — display steps, progress, discoveries, log
- Steps auto-parsed from PROMPT.md headings into task.json
- Task types extended with steps, reviews, discoveries, log
This commit is contained in:
Dustin Byrne
2026-03-25 20:14:04 -04:00
parent e142e049d3
commit 8a2a5ac14b
7 changed files with 514 additions and 151 deletions

View File

@@ -1,5 +1,4 @@
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail } from "@hai/core";
@@ -16,33 +15,75 @@ You are working in a git worktree isolated from the main branch. Your job is to
4. Test your changes
5. Commit at meaningful boundaries (step completion)
## Reporting progress via CLI
Use \`hai task\` commands to report your progress. The board updates in real-time.
### Step lifecycle
Before starting a step:
\`\`\`bash
hai task update {TASK_ID} {STEP_NUMBER} in-progress
\`\`\`
After completing a step:
\`\`\`bash
hai task update {TASK_ID} {STEP_NUMBER} done
\`\`\`
If skipping a step:
\`\`\`bash
hai task update {TASK_ID} {STEP_NUMBER} skipped
\`\`\`
### Logging
Log important actions, decisions, or issues:
\`\`\`bash
hai task log {TASK_ID} "description of what happened"
\`\`\`
### Discoveries
When you find something unexpected that may affect future tasks:
\`\`\`bash
hai task discover {TASK_ID} "what you found" "what to do about it" "optional/file/location"
\`\`\`
## Git discipline
- Commit after completing each major step
- Use conventional commit messages prefixed with the task ID
- \`feat(HAI-001): implement user profile page\`
- \`test(HAI-001): add profile page tests\`
- \`fix(HAI-001): handle edge case in validation\`
- Commit after completing each step (not after every file change)
- Use conventional commit messages prefixed with the task ID:
- \`feat({TASK_ID}): complete Step N — description\`
- \`fix({TASK_ID}): description\`
- \`test({TASK_ID}): description\`
- Do NOT commit broken or half-implemented code
## Review levels (from PROMPT.md)
- **Level 0 (None):** Just implement
- **Level 1 (Plan Only):** Before coding, outline your plan and verify it makes sense
- **Level 2 (Plan + Code):** Plan first, then after implementation review your own code for issues
- **Level 3 (Full):** Plan review, code review, and test review
## Guardrails
- Stay within the file scope defined in PROMPT.md
- Do not modify files outside the task's scope without good reason
- If you discover work that doesn't fit the task, note it but don't do it
- If a step is blocked or unclear, document why and move on
- Read "Context to Read First" files before starting
- Follow the "Do NOT" section strictly
- If you discover work outside the task's scope, log it with \`hai task discover\` but don't do it
- Update documentation listed in "Must Update" and check "Check If Affected"
## Documentation
The PROMPT.md has Documentation Requirements sections:
- **Must Update** — docs you MUST modify as part of this task
- **Check If Affected** — docs to review and update if your changes affect them
## Completion
When all steps are complete and tests pass, create a \`.DONE\` file in the task directory to signal completion.`;
After all steps are done, tests pass, and docs are updated, create a \`.DONE\` file:
\`\`\`bash
echo "done" > .DONE
\`\`\``;
export interface TaskExecutorOptions {
/** Called when task execution starts */
onStart?: (task: Task, worktreePath: string) => void;
/** Called when task execution completes */
onComplete?: (task: Task) => void;
/** Called on execution failure */
onError?: (task: Task, error: Error) => void;
/** Called with agent text output */
onAgentText?: (taskId: string, delta: string) => void;
/** Called with agent tool usage */
onAgentTool?: (taskId: string, toolName: string) => void;
}
@@ -55,7 +96,6 @@ export class TaskExecutor {
private rootDir: string,
private options: TaskExecutorOptions = {},
) {
// Listen for tasks moving to in-progress
store.on("task:moved", ({ task, to }) => {
if (to === "in-progress") {
this.execute(task).catch((err) =>
@@ -65,15 +105,11 @@ export class TaskExecutor {
});
}
/**
* Execute a task: create worktree, run pi agent, move to in-review.
*/
async execute(task: Task): Promise<void> {
if (this.executing.has(task.id)) return;
this.executing.add(task.id);
console.log(`[executor] Starting ${task.id}: ${task.title || task.id}`);
await this.store.updateTask(task.id, { status: "starting" });
console.log(`[executor] Starting ${task.id}: ${task.title || task.description.slice(0, 60)}`);
try {
// Check dependencies
@@ -84,73 +120,62 @@ export class TaskExecutor {
});
if (unmetDeps.length > 0) {
console.log(
`[executor] ${task.id} blocked by: ${unmetDeps.join(", ")} — deferring`,
);
console.log(`[executor] ${task.id} blocked by: ${unmetDeps.join(", ")} — deferring`);
return;
}
// Create worktree
const branchName = `hai/${task.id.toLowerCase()}`;
const worktreePath = join(this.rootDir, ".worktrees", task.id);
await this.createWorktree(branchName, worktreePath);
this.createWorktree(branchName, worktreePath);
this.activeWorktrees.set(task.id, worktreePath);
// Persist worktree path to task.json so merge can find it
// Persist worktree path
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
this.options.onStart?.(task, worktreePath);
await this.store.updateTask(task.id, { status: "researching" });
// Read the task's PROMPT.md
const detail = await this.store.getTask(task.id);
// Create a pi agent session in the worktree
let hasStartedExecuting = false;
// Parse steps into task.json if not already there
if (detail.steps.length === 0) {
const steps = await this.store.parseStepsFromPrompt(task.id);
if (steps.length > 0) {
// Write steps back
const taskData = await this.store.getTask(task.id);
taskData.steps = steps;
await this.store.updateTask(task.id, {});
// Re-read to get updated task with steps written by parseSteps
// Actually we need a better approach - let updateStep handle lazy init
}
}
// Create pi agent session in the worktree
const { session } = await createHaiAgent({
cwd: worktreePath,
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
tools: "coding",
onText: (delta) => this.options.onAgentText?.(task.id, delta),
onToolStart: (name) => {
this.options.onAgentTool?.(task.id, name);
if (!hasStartedExecuting && /^(write|edit|bash)/i.test(name)) {
hasStartedExecuting = true;
this.store.updateTask(task.id, { status: "executing" }).catch(() => {});
}
},
onToolStart: (name) => this.options.onAgentTool?.(task.id, name),
});
try {
const agentPrompt = buildExecutionPrompt(detail);
const agentPrompt = buildExecutionPrompt(detail, this.rootDir);
await session.prompt(agentPrompt);
// Check if the agent signaled completion (.DONE file)
const doneFile = join(
worktreePath,
".hai",
"tasks",
task.id,
".DONE",
);
// Check completion
const doneCwd = join(worktreePath, ".DONE");
await this.store.updateTask(task.id, { status: "finalizing" });
if (existsSync(doneFile) || existsSync(doneCwd)) {
if (existsSync(doneCwd)) {
await this.store.logEntry(task.id, "Execution complete — .DONE created");
await this.store.moveTask(task.id, "in-review");
await this.store.updateTask(task.id, { status: "ready" });
console.log(`[executor] ✓ ${task.id} completed → in-review`);
this.options.onComplete?.(task);
} else {
// Agent finished but didn't create .DONE — still move to review
// so a human can inspect
await this.store.logEntry(task.id, "Agent finished without .DONE — moved to in-review for inspection");
await this.store.moveTask(task.id, "in-review");
await this.store.updateTask(task.id, { status: "ready" });
console.log(
`[executor] ⚠ ${task.id} agent finished without .DONE → in-review for inspection`,
);
console.log(`[executor] ⚠ ${task.id} agent finished without .DONE → in-review`);
this.options.onComplete?.(task);
}
} finally {
@@ -158,6 +183,7 @@ export class TaskExecutor {
}
} catch (err: any) {
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);
await this.store.logEntry(task.id, `Execution failed: ${err.message}`);
this.options.onError?.(task, err);
} finally {
this.executing.delete(task.id);
@@ -169,20 +195,11 @@ export class TaskExecutor {
console.log(`[executor] Worktree already exists: ${path}`);
return;
}
try {
// Try creating with new branch
execSync(`git worktree add -b "${branch}" "${path}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git worktree add -b "${branch}" "${path}"`, { cwd: this.rootDir, stdio: "pipe" });
} catch {
// Branch might already exist — try attaching
try {
execSync(`git worktree add "${path}" "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git worktree add "${path}" "${branch}"`, { cwd: this.rootDir, stdio: "pipe" });
} catch (e: any) {
throw new Error(`Failed to create worktree: ${e.message}`);
}
@@ -190,25 +207,15 @@ export class TaskExecutor {
console.log(`[executor] Worktree created: ${path}`);
}
/**
* Clean up worktree after merge (called when task moves to done).
*/
async cleanup(taskId: string): Promise<void> {
const worktreePath = this.activeWorktrees.get(taskId);
if (!worktreePath) return;
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.rootDir,
stdio: "pipe",
});
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" });
this.activeWorktrees.delete(taskId);
console.log(`[executor] Cleaned up worktree for ${taskId}`);
} catch (err: any) {
console.error(
`[executor] Failed to clean up worktree for ${taskId}:`,
err.message,
);
console.error(`[executor] Failed to clean up worktree for ${taskId}:`, err.message);
}
}
@@ -217,25 +224,31 @@ export class TaskExecutor {
}
}
function buildExecutionPrompt(task: TaskDetail): string {
return `Execute this task. The PROMPT.md specification follows.
function buildExecutionPrompt(task: TaskDetail, rootDir: string): string {
return `Execute this task. Read the PROMPT.md specification below, then implement it.
## Task Info
- **ID:** ${task.id}
- **Title:** ${task.title || task.id}
- **Title:** ${task.title || task.description.slice(0, 80)}
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}
## PROMPT.md
\`\`\`markdown
${task.prompt}
\`\`\`
## Instructions
1. Read and understand the specification above
2. Explore the codebase to understand the current state
3. Implement each step in order
4. Commit after completing each step using: \`git commit -m "feat(${task.id}): <description>"\`
5. When all steps pass, create a \`.DONE\` file: \`echo "done" > .DONE\`
Begin implementation now.`;
1. Read "Context to Read First" files listed in the spec
2. Report progress using the \`hai task\` CLI:
- \`hai task update ${task.id} 0 in-progress\` — when starting Step 0
- \`hai task update ${task.id} 0 done\` — when Step 0 is complete
- \`hai task log ${task.id} "what you did"\` — for important actions
- \`hai task discover ${task.id} "finding" "disposition"\` — for discoveries
3. Implement each step in order, committing at step boundaries:
\`git commit -m "feat(${task.id}): complete Step N — description"\`
4. Follow the review level guidance in the spec
5. Update documentation per "Must Update" and "Check If Affected"
6. When all steps pass: \`echo "done" > .DONE\`
Begin with Step 0 (Preflight).`;
}