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