feat: hai board — core, dashboard, cli, engine

This commit is contained in:
Dustin Byrne
2026-03-25 18:32:10 -04:00
parent 517c963200
commit 961203692d
41 changed files with 6383 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import type { Column, Task } from "./types.js";
import { VALID_TRANSITIONS } from "./types.js";
export function canTransition(from: Column, to: Column): boolean {
return VALID_TRANSITIONS[from].includes(to);
}
export function getValidTransitions(column: Column): Column[] {
return [...VALID_TRANSITIONS[column]];
}
/**
* Resolve dependency order for a set of tasks.
* Returns task IDs in execution order — tasks with no unmet deps first.
*/
export function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const ordered: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(id: string): void {
if (visited.has(id)) return;
if (visiting.has(id)) return; // circular — skip
visiting.add(id);
const task = taskMap.get(id);
if (task) {
for (const depId of task.dependencies) {
if (taskMap.has(depId)) visit(depId);
}
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
}
for (const task of tasks) visit(task.id);
return ordered;
}

View File

@@ -0,0 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS } from "./types.js";
export type { Column, Task, TaskCreateInput, TaskDetail, BoardConfig, MergeResult } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

335
packages/core/src/store.ts Normal file
View File

@@ -0,0 +1,335 @@
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, BoardConfig, Column, MergeResult } from "./types.js";
import { VALID_TRANSITIONS } from "./types.js";
export interface TaskStoreEvents {
"task:created": [task: Task];
"task:moved": [data: { task: Task; from: Column; to: Column }];
"task:updated": [task: Task];
"task:deleted": [task: Task];
"task:merged": [result: MergeResult];
}
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private haiDir: string;
private tasksDir: string;
private configPath: string;
constructor(private rootDir: string) {
super();
this.haiDir = join(rootDir, ".hai");
this.tasksDir = join(this.haiDir, "tasks");
this.configPath = join(this.haiDir, "config.json");
}
async init(): Promise<void> {
await mkdir(this.tasksDir, { recursive: true });
if (!existsSync(this.configPath)) {
await this.writeConfig({ nextId: 1 });
}
}
private async readConfig(): Promise<BoardConfig> {
const data = await readFile(this.configPath, "utf-8");
return JSON.parse(data);
}
private async writeConfig(config: BoardConfig): Promise<void> {
await writeFile(this.configPath, JSON.stringify(config, null, 2));
}
private async allocateId(): Promise<string> {
const config = await this.readConfig();
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
config.nextId++;
await this.writeConfig(config);
return id;
}
private taskDir(id: string): string {
return join(this.tasksDir, id);
}
async createTask(input: TaskCreateInput): Promise<Task> {
const id = await this.allocateId();
const now = new Date().toISOString();
const task: Task = {
id,
title: input.title,
description: input.description || "",
column: input.column || "triage",
dependencies: input.dependencies || [],
createdAt: now,
updatedAt: now,
};
const dir = this.taskDir(id);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
const prompt = task.column === "triage"
? `# ${id}: ${task.title}\n\n${task.description}\n`
: this.generateSpecifiedPrompt(task);
await writeFile(join(dir, "PROMPT.md"), prompt);
this.emit("task:created", task);
return task;
}
async getTask(id: string): Promise<TaskDetail> {
const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8");
const task = JSON.parse(data) as Task;
let prompt = "";
const promptPath = join(dir, "PROMPT.md");
if (existsSync(promptPath)) {
prompt = await readFile(promptPath, "utf-8");
}
return { ...task, prompt };
}
async listTasks(): Promise<Task[]> {
if (!existsSync(this.tasksDir)) return [];
const entries = await readdir(this.tasksDir, { withFileTypes: true });
const tasks: Task[] = [];
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith("HAI-")) {
try {
const data = await readFile(
join(this.tasksDir, entry.name, "task.json"),
"utf-8",
);
tasks.push(JSON.parse(data));
} catch {
// skip invalid task dirs
}
}
}
return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
async moveTask(id: string, toColumn: Column): Promise<Task> {
const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8");
const task = JSON.parse(data) as Task;
const validTargets = VALID_TRANSITIONS[task.column];
if (!validTargets.includes(toColumn)) {
throw new Error(
`Invalid transition: '${task.column}' → '${toColumn}'. ` +
`Valid targets: ${validTargets.join(", ") || "none"}`,
);
}
const fromColumn = task.column;
task.column = toColumn;
task.updatedAt = new Date().toISOString();
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
this.emit("task:moved", { task, from: fromColumn, to: toColumn });
return task;
}
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: 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;
if (updates.title !== undefined) task.title = updates.title;
if (updates.description !== undefined) task.description = updates.description;
if (updates.worktree !== undefined) task.worktree = updates.worktree;
task.updatedAt = new Date().toISOString();
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
if (updates.prompt !== undefined) {
await writeFile(join(dir, "PROMPT.md"), updates.prompt);
}
this.emit("task:updated", task);
return task;
}
async deleteTask(id: 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;
const { rm } = await import("node:fs/promises");
await rm(dir, { recursive: true });
this.emit("task:deleted", task);
return task;
}
/**
* Merge an in-review task's branch into the current branch,
* clean up the worktree, and move the task to done.
*/
async mergeTask(id: string): Promise<MergeResult> {
const dir = this.taskDir(id);
const data = await readFile(join(dir, "task.json"), "utf-8");
const task = JSON.parse(data) as Task;
if (task.column !== "in-review") {
throw new Error(
`Cannot merge ${id}: task is in '${task.column}', must be in 'in-review'`,
);
}
const branch = `hai/${id.toLowerCase()}`;
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", id);
const result: MergeResult = {
task,
branch,
merged: false,
worktreeRemoved: false,
branchDeleted: false,
};
// 1. Check the branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
} catch {
// No branch — might have been manually merged. Just move to done.
result.error = `Branch '${branch}' not found — moving to done without merge`;
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
}
// 2. Merge the branch
try {
execSync(`git merge "${branch}" --no-edit`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.merged = true;
} catch (err: any) {
// Merge conflict — abort and report
try {
execSync("git merge --abort", { cwd: this.rootDir, stdio: "pipe" });
} catch {
// already clean
}
throw new Error(
`Merge conflict merging '${branch}'. Resolve manually:\n` +
` cd ${this.rootDir}\n` +
` git merge ${branch}\n` +
` # resolve conflicts, then: hai task move ${id} done`,
);
}
// 3. Remove worktree
if (existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch {
// Non-fatal — worktree may already be gone
}
}
// 4. Delete the branch
try {
execSync(`git branch -d "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Branch might not be fully merged in some edge cases; try force
try {
execSync(`git branch -D "${branch}"`, {
cwd: this.rootDir,
stdio: "pipe",
});
result.branchDeleted = true;
} catch {
// Non-fatal
}
}
// 5. Move task to done
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
this.emit("task:merged", result);
return result;
}
private async moveToDone(task: Task, dir: string): Promise<void> {
task.column = "done";
task.worktree = undefined;
task.updatedAt = new Date().toISOString();
await writeFile(join(dir, "task.json"), JSON.stringify(task, null, 2));
this.emit("task:moved", { task, from: "in-review" as Column, to: "done" as Column });
}
getRootDir(): string {
return this.rootDir;
}
private generateSpecifiedPrompt(task: Task): string {
const deps =
task.dependencies.length > 0
? task.dependencies.map((d) => `- **Task:** ${d}`).join("\n")
: "- **None**";
return `# ${task.id}: ${task.title}
**Created:** ${task.createdAt.split("T")[0]}
**Size:** M
## Mission
${task.description || task.title}
## Dependencies
${deps}
## Steps
### Step 1: Implementation
- [ ] Implement the required changes
- [ ] Verify changes work correctly
### Step 2: Testing & Verification
- [ ] All tests pass
- [ ] No regressions introduced
### Step 3: Documentation & Delivery
- [ ] Update relevant documentation
- [ ] .DONE created
## Acceptance Criteria
- [ ] All steps complete
- [ ] All tests passing
`;
}
}

View File

@@ -0,0 +1,61 @@
export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"] as const;
export type Column = (typeof COLUMNS)[number];
export interface Task {
id: string;
title: string;
description: string;
column: Column;
dependencies: string[];
worktree?: string;
createdAt: string;
updatedAt: string;
}
export interface TaskDetail extends Task {
prompt: string;
}
export interface TaskCreateInput {
title: string;
description?: string;
column?: Column;
dependencies?: string[];
}
export interface BoardConfig {
nextId: number;
}
export interface MergeResult {
task: Task;
branch: string;
merged: boolean;
worktreeRemoved: boolean;
branchDeleted: boolean;
error?: string;
}
export const COLUMN_LABELS: Record<Column, string> = {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
};
export const COLUMN_DESCRIPTIONS: Record<Column, string> = {
triage: "Raw ideas — AI will specify these",
todo: "Specified and ready to start",
"in-progress": "AI is working on this in a worktree",
"in-review": "Complete — ready to merge",
done: "Merged and closed",
};
export const VALID_TRANSITIONS: Record<Column, Column[]> = {
triage: ["todo"],
todo: ["in-progress", "triage"],
"in-progress": ["in-review"],
"in-review": ["done", "in-progress"],
done: [],
};