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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user