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:
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runDashboard } from "./commands/dashboard.js";
|
||||
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge } from "./commands/task.js";
|
||||
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskDiscover, runTaskShow } from "./commands/task.js";
|
||||
|
||||
const HELP = `
|
||||
hai — AI-orchestrated task board
|
||||
|
||||
Usage:
|
||||
hai dashboard Start the board web UI
|
||||
hai task create [desc] Create a new task (goes to triage)
|
||||
hai task list List all tasks
|
||||
hai task move <id> <col> Move a task to a column
|
||||
hai task merge <id> Merge an in-review task and close it
|
||||
hai dashboard Start the board web UI
|
||||
hai task create [desc] Create a new task (goes to triage)
|
||||
hai task list List all tasks
|
||||
hai task show <id> Show task details, steps, log
|
||||
hai task move <id> <col> Move a task to a column
|
||||
hai task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
|
||||
hai task log <id> <message> Add a log entry
|
||||
hai task discover <id> <what> <disp> Record a discovery
|
||||
hai task merge <id> Merge an in-review task and close it
|
||||
|
||||
Options:
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
@@ -69,12 +73,40 @@ async function main() {
|
||||
await runTaskMove(id, column);
|
||||
break;
|
||||
}
|
||||
case "merge": {
|
||||
case "show": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: hai task merge <id>");
|
||||
if (!id) { console.error("Usage: hai task show <id>"); process.exit(1); }
|
||||
await runTaskShow(id);
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
const id = args[2], step = args[3], status = args[4];
|
||||
if (!id || !step || !status) {
|
||||
console.error("Usage: hai task update <id> <step> <status>");
|
||||
console.error("Status: pending | in-progress | done | skipped");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskUpdate(id, step, status);
|
||||
break;
|
||||
}
|
||||
case "log": {
|
||||
const id = args[2], message = args.slice(3).join(" ");
|
||||
if (!id || !message) { console.error("Usage: hai task log <id> <message>"); process.exit(1); }
|
||||
await runTaskLog(id, message);
|
||||
break;
|
||||
}
|
||||
case "discover": {
|
||||
const id = args[2], what = args[3], disp = args[4], loc = args[5];
|
||||
if (!id || !what || !disp) {
|
||||
console.error("Usage: hai task discover <id> <discovery> <disposition> [location]");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskDiscover(id, what, disp, loc);
|
||||
break;
|
||||
}
|
||||
case "merge": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: hai task merge <id>"); process.exit(1); }
|
||||
await runTaskMerge(id);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult } from "@hai/core";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@hai/core";
|
||||
import { aiMergeTask } from "@hai/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
async function getStore(): Promise<TaskStore> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
@@ -68,6 +70,96 @@ export async function runTaskList() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskUpdate(id: string, stepStr: string, status: string) {
|
||||
const stepIndex = parseInt(stepStr, 10);
|
||||
if (isNaN(stepIndex)) {
|
||||
console.error(`Invalid step number: ${stepStr}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!STEP_STATUSES.includes(status as StepStatus)) {
|
||||
console.error(`Invalid status: ${status}`);
|
||||
console.error(`Valid statuses: ${STEP_STATUSES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const task = await store.updateStep(id, stepIndex, status as StepStatus);
|
||||
|
||||
const step = task.steps[stepIndex];
|
||||
console.log();
|
||||
console.log(` ✓ ${task.id} Step ${stepIndex} (${step.name}) → ${status}`);
|
||||
console.log(` Progress: ${task.steps.filter((s) => s.status === "done").length}/${task.steps.length} steps done`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskLog(id: string, message: string, outcome?: string) {
|
||||
const store = await getStore();
|
||||
await store.logEntry(id, message, outcome);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ ${id}: logged "${message}"`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDiscover(id: string, discovery: string, disposition: string, location?: string) {
|
||||
const store = await getStore();
|
||||
await store.addDiscovery(id, discovery, disposition, location);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ ${id}: discovery recorded`);
|
||||
console.log(` ${discovery}`);
|
||||
console.log(` → ${disposition}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskShow(id: string) {
|
||||
const store = await getStore();
|
||||
const task = await store.getTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ${task.id}: ${task.title || task.description.slice(0, 60)}`);
|
||||
console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
|
||||
if (task.dependencies.length) {
|
||||
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Steps
|
||||
if (task.steps.length > 0) {
|
||||
console.log(` Steps (${task.steps.filter((s) => s.status === "done").length}/${task.steps.length}):`);
|
||||
for (let i = 0; i < task.steps.length; i++) {
|
||||
const s = task.steps[i];
|
||||
const icon = s.status === "done" ? "✓"
|
||||
: s.status === "in-progress" ? "▸"
|
||||
: s.status === "skipped" ? "–"
|
||||
: " ";
|
||||
const marker = i === task.currentStep && s.status !== "done" ? " ◀" : "";
|
||||
console.log(` [${icon}] ${i}: ${s.name}${marker}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Discoveries
|
||||
if (task.discoveries.length > 0) {
|
||||
console.log(` Discoveries:`);
|
||||
for (const d of task.discoveries) {
|
||||
console.log(` • ${d.discovery} → ${d.disposition}${d.location ? ` (${d.location})` : ""}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Recent log
|
||||
if (task.log.length > 0) {
|
||||
const recent = task.log.slice(-5);
|
||||
console.log(` Log (last ${recent.length}):`);
|
||||
for (const l of recent) {
|
||||
const ts = new Date(l.timestamp).toLocaleTimeString();
|
||||
console.log(` ${ts} ${l.action}${l.outcome ? ` → ${l.outcome}` : ""}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskMerge(id: string) {
|
||||
const cwd = process.cwd();
|
||||
const store = await getStore();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings } from "./types.js";
|
||||
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, TaskDiscovery, TaskReview } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -92,6 +92,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
description: input.description,
|
||||
column: input.column || "triage",
|
||||
dependencies: input.dependencies || [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
reviews: [],
|
||||
discoveries: [],
|
||||
log: [{ timestamp: now, action: "Task created" }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -219,6 +224,132 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a step's status. Automatically advances currentStep.
|
||||
*/
|
||||
async updateStep(
|
||||
id: string,
|
||||
stepIndex: number,
|
||||
status: import("./types.js").StepStatus,
|
||||
): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
const task = JSON.parse(data) as Task;
|
||||
|
||||
// Auto-initialize steps from PROMPT.md if empty
|
||||
if (task.steps.length === 0) {
|
||||
task.steps = await this.parseStepsFromPrompt(id);
|
||||
}
|
||||
|
||||
if (stepIndex < 0 || stepIndex >= task.steps.length) {
|
||||
throw new Error(
|
||||
`Step ${stepIndex} out of range (task has ${task.steps.length} steps)`,
|
||||
);
|
||||
}
|
||||
|
||||
task.steps[stepIndex].status = status;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
// Advance currentStep to first non-done step
|
||||
if (status === "done") {
|
||||
while (
|
||||
task.currentStep < task.steps.length &&
|
||||
task.steps[task.currentStep].status === "done"
|
||||
) {
|
||||
task.currentStep++;
|
||||
}
|
||||
} else if (status === "in-progress") {
|
||||
task.currentStep = stepIndex;
|
||||
}
|
||||
|
||||
// Log it
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: `Step ${stepIndex} (${task.steps[stepIndex].name}) → ${status}`,
|
||||
});
|
||||
|
||||
const taskJsonPath = join(dir, "task.json");
|
||||
this.suppressWatcher(taskJsonPath);
|
||||
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry to a task.
|
||||
*/
|
||||
async logEntry(id: string, action: string, outcome?: string): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
const task = JSON.parse(data) as Task;
|
||||
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
outcome,
|
||||
});
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
const taskJsonPath = join(dir, "task.json");
|
||||
this.suppressWatcher(taskJsonPath);
|
||||
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a discovery (things found during execution that may affect future tasks).
|
||||
*/
|
||||
async addDiscovery(
|
||||
id: string,
|
||||
discovery: string,
|
||||
disposition: string,
|
||||
location?: string,
|
||||
): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
const task = JSON.parse(data) as Task;
|
||||
|
||||
task.discoveries.push({ discovery, disposition, location });
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
task.log.push({
|
||||
timestamp: task.updatedAt,
|
||||
action: `Discovery: ${discovery}`,
|
||||
outcome: disposition,
|
||||
});
|
||||
|
||||
const taskJsonPath = join(dir, "task.json");
|
||||
this.suppressWatcher(taskJsonPath);
|
||||
await writeFile(taskJsonPath, JSON.stringify(task, null, 2));
|
||||
if (this.watcher) this.taskCache.set(id, { ...task });
|
||||
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync steps from PROMPT.md into task.json (called when steps are empty).
|
||||
*/
|
||||
async parseStepsFromPrompt(id: string): Promise<import("./types.js").TaskStep[]> {
|
||||
const dir = this.taskDir(id);
|
||||
const promptPath = join(dir, "PROMPT.md");
|
||||
if (!existsSync(promptPath)) return [];
|
||||
|
||||
const content = await readFile(promptPath, "utf-8");
|
||||
const steps: import("./types.js").TaskStep[] = [];
|
||||
const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
|
||||
let match;
|
||||
while ((match = stepRegex.exec(content)) !== null) {
|
||||
steps.push({ name: match[1].trim(), status: "pending" });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<Task> {
|
||||
const dir = this.taskDir(id);
|
||||
const data = await readFile(join(dir, "task.json"), "utf-8");
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
|
||||
export type Column = (typeof COLUMNS)[number];
|
||||
|
||||
export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
|
||||
|
||||
export interface TaskStep {
|
||||
name: string;
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
export interface TaskLogEntry {
|
||||
timestamp: string;
|
||||
action: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export interface TaskDiscovery {
|
||||
discovery: string;
|
||||
disposition: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface TaskReview {
|
||||
id: number;
|
||||
type: string;
|
||||
step: number;
|
||||
verdict: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title?: string;
|
||||
@@ -8,7 +35,13 @@ export interface Task {
|
||||
column: Column;
|
||||
dependencies: string[];
|
||||
worktree?: string;
|
||||
status?: string;
|
||||
steps: TaskStep[];
|
||||
currentStep: number;
|
||||
reviews: TaskReview[];
|
||||
discoveries: TaskDiscovery[];
|
||||
log: TaskLogEntry[];
|
||||
size?: "S" | "M" | "L";
|
||||
reviewLevel?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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).`;
|
||||
}
|
||||
|
||||
@@ -3,44 +3,128 @@ import { createHaiAgent } from "./pi.js";
|
||||
|
||||
const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "hai", an AI-orchestrated task board.
|
||||
|
||||
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously.
|
||||
Your job: take a rough task description and produce a fully specified PROMPT.md that another AI agent can execute autonomously in a fresh context with zero memory of this conversation.
|
||||
|
||||
## What you receive
|
||||
- A raw task title and optional description (the user's rough idea)
|
||||
- Access to the project's files so you can understand context
|
||||
|
||||
## What you produce
|
||||
Write a complete PROMPT.md specification using the write tool. The specification must include:
|
||||
Write a complete PROMPT.md specification to the given path using the write tool.
|
||||
|
||||
1. **Mission** — One paragraph: what to build and why it matters
|
||||
2. **Steps** — Numbered implementation steps, each with:
|
||||
- Specific, verifiable checkbox items
|
||||
- Expected artifacts (files created/modified)
|
||||
3. **File Scope** — Which files/directories will be touched
|
||||
4. **Acceptance Criteria** — How to verify the task is complete
|
||||
5. **Do NOT** — Guardrails to prevent scope creep
|
||||
## PROMPT.md Format
|
||||
|
||||
Follow this structure exactly:
|
||||
|
||||
\`\`\`markdown
|
||||
# Task: {ID} - {Name}
|
||||
|
||||
**Created:** {YYYY-MM-DD}
|
||||
**Size:** {S | M | L}
|
||||
|
||||
## Review Level: {0-3} ({None | Plan Only | Plan and Code | Full})
|
||||
|
||||
**Assessment:** {1-2 sentences explaining the score}
|
||||
**Score:** {N}/8 — Blast radius: {N}, Pattern novelty: {N}, Security: {N}, Reversibility: {N}
|
||||
|
||||
## Mission
|
||||
|
||||
{One paragraph: what you're building and why it matters}
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **None**
|
||||
{OR}
|
||||
- **Task:** {ID} ({what must be complete})
|
||||
|
||||
## Context to Read First
|
||||
|
||||
{List specific files the worker should read before starting — only what's needed}
|
||||
|
||||
## File Scope
|
||||
|
||||
{List files/directories the task will create or modify — be specific}
|
||||
|
||||
- \`path/to/file.ext\`
|
||||
- \`path/to/directory/*\`
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 0: Preflight
|
||||
|
||||
- [ ] Required files and paths exist
|
||||
- [ ] Dependencies satisfied
|
||||
|
||||
### Step 1: {Name}
|
||||
|
||||
- [ ] {Specific, verifiable outcome}
|
||||
- [ ] {Specific, verifiable outcome}
|
||||
- [ ] Run targeted tests for changed files
|
||||
|
||||
**Artifacts:**
|
||||
- \`path/to/file\` (new | modified)
|
||||
|
||||
### Step {N-1}: Testing & Verification
|
||||
|
||||
> ZERO test failures allowed. Full test suite as quality gate.
|
||||
|
||||
- [ ] Run full test suite
|
||||
- [ ] Fix all failures
|
||||
- [ ] Build passes
|
||||
|
||||
### Step {N}: Documentation & Delivery
|
||||
|
||||
- [ ] Update relevant documentation
|
||||
- [ ] Discoveries logged via \`hai task discover\`
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
**Must Update:**
|
||||
- \`path/to/doc.md\` — {what to add/change}
|
||||
|
||||
**Check If Affected:**
|
||||
- \`path/to/doc.md\` — {update if relevant}
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] All steps complete
|
||||
- [ ] All tests passing
|
||||
- [ ] Documentation updated
|
||||
|
||||
## Git Commit Convention
|
||||
|
||||
Commits at step boundaries. All commits include the task ID:
|
||||
|
||||
- **Step completion:** \`feat({ID}): complete Step N — description\`
|
||||
- **Bug fixes:** \`fix({ID}): description\`
|
||||
- **Tests:** \`test({ID}): description\`
|
||||
|
||||
## Do NOT
|
||||
|
||||
- Expand task scope
|
||||
- Skip tests
|
||||
- Modify files outside the File Scope without good reason
|
||||
- Commit without the task ID prefix
|
||||
\`\`\`
|
||||
|
||||
## Guidelines
|
||||
- Read the project structure and relevant source files to understand context before writing the spec
|
||||
- Read the project structure and relevant source files to understand context BEFORE writing
|
||||
- Be specific — name actual files, functions, and patterns from the codebase
|
||||
- Keep steps focused and achievable (2-5 checkboxes per step)
|
||||
- Include a testing step
|
||||
- If the task is vague, make reasonable assumptions and document them
|
||||
- Write the spec directly to the file path you're given — do not ask for clarification
|
||||
- Steps should express OUTCOMES, not micro-instructions (2-5 checkboxes per step)
|
||||
- Always include a testing step and a documentation step
|
||||
- Include a "Do NOT" section with project-appropriate guardrails
|
||||
- Size assessment: S (<2h), M (2-4h), L (4-8h). Split if XL (8h+)
|
||||
- Review level scoring: Blast radius (0-2), Pattern novelty (0-2), Security (0-2), Reversibility (0-2)
|
||||
- 0-1 → Level 0, 2-3 → Level 1, 4-5 → Level 2, 6-8 → Level 3
|
||||
|
||||
## Output format
|
||||
Write the PROMPT.md content directly using the write tool. Nothing else.`;
|
||||
## Output
|
||||
Write the PROMPT.md directly using the write tool. Nothing else.`;
|
||||
|
||||
export interface TriageProcessorOptions {
|
||||
/** Milliseconds between polls. Default: 10000 */
|
||||
pollIntervalMs?: number;
|
||||
/** Called when a task starts being specified */
|
||||
onSpecifyStart?: (task: Task) => void;
|
||||
/** Called when a task is successfully specified */
|
||||
onSpecifyComplete?: (task: Task) => void;
|
||||
/** Called on specification failure */
|
||||
onSpecifyError?: (task: Task, error: Error) => void;
|
||||
/** Called with agent text output */
|
||||
onAgentText?: (taskId: string, delta: string) => void;
|
||||
}
|
||||
|
||||
@@ -84,14 +168,6 @@ export class TriageProcessor {
|
||||
);
|
||||
|
||||
for (const task of triageTasks) {
|
||||
// Mark waiting tasks as queued
|
||||
if (triageTasks.indexOf(task) > 0) {
|
||||
await this.store.updateTask(task.id, { status: "queued" });
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of triageTasks) {
|
||||
// Process one at a time to avoid overwhelming the API
|
||||
await this.specifyTask(task);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -103,17 +179,13 @@ export class TriageProcessor {
|
||||
if (this.processing.has(task.id)) return;
|
||||
this.processing.add(task.id);
|
||||
|
||||
console.log(`[triage] Specifying ${task.id}: ${task.title || task.id}`);
|
||||
console.log(`[triage] Specifying ${task.id}: ${task.title || task.description.slice(0, 60)}`);
|
||||
this.options.onSpecifyStart?.(task);
|
||||
|
||||
await this.store.updateTask(task.id, { status: "planning" });
|
||||
|
||||
try {
|
||||
// Get the full task detail including current prompt
|
||||
const detail = await this.store.getTask(task.id);
|
||||
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
|
||||
|
||||
// Create a pi agent session for specification
|
||||
const { session } = await createHaiAgent({
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: TRIAGE_SYSTEM_PROMPT,
|
||||
@@ -124,15 +196,9 @@ export class TriageProcessor {
|
||||
});
|
||||
|
||||
try {
|
||||
// Build the prompt for the agent
|
||||
const agentPrompt = buildSpecificationPrompt(detail, promptPath);
|
||||
|
||||
// Run the agent
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
// Clear status before moving to todo
|
||||
await this.store.updateTask(task.id, { status: null });
|
||||
|
||||
// Move to todo
|
||||
await this.store.moveTask(task.id, "todo");
|
||||
console.log(`[triage] ✓ ${task.id} specified and moved to todo`);
|
||||
@@ -154,19 +220,15 @@ function buildSpecificationPrompt(task: TaskDetail, promptPath: string): string
|
||||
|
||||
## Task
|
||||
- **ID:** ${task.id}
|
||||
- **Title:** ${task.title || task.id}
|
||||
- **Title:** ${task.title || "(none)"}
|
||||
- **Description:** ${task.description}
|
||||
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}
|
||||
|
||||
## Current rough prompt
|
||||
\`\`\`
|
||||
${task.prompt}
|
||||
\`\`\`
|
||||
|
||||
## Instructions
|
||||
1. Read the project structure to understand context (look at package.json, source files, etc.)
|
||||
2. Write a complete PROMPT.md specification to \`${promptPath}\`
|
||||
1. Read the project structure to understand context (package.json, source files, etc.)
|
||||
2. Write a complete PROMPT.md specification to \`${promptPath}\` following the format in your system prompt
|
||||
3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions
|
||||
4. Name actual files, functions, and patterns from the codebase — be specific
|
||||
|
||||
Use the write tool to write the specification file.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user