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,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;
}

View File

@@ -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();

View File

@@ -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";

View File

@@ -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");

View File

@@ -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;
}

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).`;
}

View File

@@ -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.`;
}