feat: hai board — core, dashboard, cli, engine
This commit is contained in:
22
packages/cli/package.json
Normal file
22
packages/cli/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "hai",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"hai": "./dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/bin.ts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"@hai/dashboard": "workspace:*",
|
||||
"@hai/engine": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
99
packages/cli/src/bin.ts
Normal file
99
packages/cli/src/bin.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runDashboard } from "./commands/dashboard.js";
|
||||
import { runTaskCreate, runTaskList, runTaskMove, runTaskMerge } from "./commands/task.js";
|
||||
|
||||
const HELP = `
|
||||
hai — AI-orchestrated task board
|
||||
|
||||
Usage:
|
||||
hai dashboard Start the board web UI
|
||||
hai task create [title] 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
|
||||
|
||||
Options:
|
||||
--port, -p <port> Dashboard port (default: 4040)
|
||||
--engine Enable AI engine (auto-specify + execute tasks)
|
||||
--help, -h Show this help
|
||||
|
||||
Columns: triage, todo, in-progress, in-review, done
|
||||
|
||||
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
|
||||
Requires configured API keys — run "pi" first to set up authentication.
|
||||
`.trim();
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
console.log(HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "dashboard": {
|
||||
const portIdx = args.indexOf("--port");
|
||||
const portIdxShort = args.indexOf("-p");
|
||||
const pi = portIdx !== -1 ? portIdx : portIdxShort;
|
||||
const port = pi !== -1 ? parseInt(args[pi + 1], 10) : 4040;
|
||||
const engine = args.includes("--engine");
|
||||
await runDashboard(port, { engine });
|
||||
break;
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "create": {
|
||||
const title = args.slice(2).join(" ");
|
||||
await runTaskCreate(title || undefined);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
case "ls":
|
||||
await runTaskList();
|
||||
break;
|
||||
case "move": {
|
||||
const id = args[2];
|
||||
const column = args[3];
|
||||
if (!id || !column) {
|
||||
console.error("Usage: hai task move <id> <column>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskMove(id, column);
|
||||
break;
|
||||
}
|
||||
case "merge": {
|
||||
const id = args[2];
|
||||
if (!id) {
|
||||
console.error("Usage: hai task merge <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskMerge(id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown subcommand: task ${subcommand || ""}`);
|
||||
console.log("Try: hai task create | list | move");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.log(HELP);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
60
packages/cli/src/commands/dashboard.ts
Normal file
60
packages/cli/src/commands/dashboard.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { TaskStore } from "@hai/core";
|
||||
import { createServer } from "@hai/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler } from "@hai/engine";
|
||||
|
||||
export async function runDashboard(port: number, opts: { engine?: boolean } = {}) {
|
||||
const cwd = process.cwd();
|
||||
const store = new TaskStore(cwd);
|
||||
await store.init();
|
||||
|
||||
// Start the web server
|
||||
const app = createServer(store);
|
||||
|
||||
// Optionally start the AI engine
|
||||
if (opts.engine) {
|
||||
const triage = new TriageProcessor(store, cwd, {
|
||||
onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`),
|
||||
onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`),
|
||||
onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
||||
});
|
||||
|
||||
const executor = new TaskExecutor(store, cwd, {
|
||||
onStart: (t, p) => console.log(`[engine] Executing ${t.id} in ${p}`),
|
||||
onComplete: (t) => console.log(`[engine] ✓ ${t.id} → in-review`),
|
||||
onError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
maxConcurrent: 2,
|
||||
onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`),
|
||||
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
|
||||
});
|
||||
|
||||
triage.start();
|
||||
scheduler.start();
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
triage.stop();
|
||||
scheduler.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log();
|
||||
console.log(` hai board`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://localhost:${port}`);
|
||||
console.log();
|
||||
console.log(` Tasks stored in .hai/tasks/`);
|
||||
if (opts.engine) {
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` • triage: auto-specifying tasks`);
|
||||
console.log(` • scheduler: dependency-aware execution`);
|
||||
} else {
|
||||
console.log(` AI engine: off (use --engine to enable)`);
|
||||
}
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
101
packages/cli/src/commands/task.ts
Normal file
101
packages/cli/src/commands/task.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult } from "@hai/core";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
async function getStore(): Promise<TaskStore> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function runTaskCreate(titleArg?: string) {
|
||||
let title = titleArg;
|
||||
|
||||
if (!title) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
title = await rl.question("Task title: ");
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!title?.trim()) {
|
||||
console.error("Title is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const task = await store.createTask({ title: title.trim() });
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Created ${task.id}: ${task.title}`);
|
||||
console.log(` Column: triage`);
|
||||
console.log(` Path: .hai/tasks/${task.id}/`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskList() {
|
||||
const store = await getStore();
|
||||
const tasks = await store.listTasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log("\n No tasks yet. Create one with: hai task create\n");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
const colTasks = tasks.filter((t) => t.column === col);
|
||||
if (colTasks.length === 0) continue;
|
||||
|
||||
const label = COLUMN_LABELS[col];
|
||||
const dot =
|
||||
col === "triage" ? "●" :
|
||||
col === "todo" ? "●" :
|
||||
col === "in-progress" ? "●" :
|
||||
col === "in-review" ? "●" : "○";
|
||||
|
||||
console.log(` ${dot} ${label} (${colTasks.length})`);
|
||||
for (const t of colTasks) {
|
||||
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
|
||||
console.log(` ${t.id} ${t.title}${deps}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskMerge(id: string) {
|
||||
const store = await getStore();
|
||||
|
||||
try {
|
||||
const result = await store.mergeTask(id);
|
||||
|
||||
console.log();
|
||||
if (result.merged) {
|
||||
console.log(` ✓ Merged ${result.task.id}`);
|
||||
console.log(` Branch: ${result.branch}`);
|
||||
console.log(` Worktree: ${result.worktreeRemoved ? "removed" : "not found"}`);
|
||||
console.log(` Branch: ${result.branchDeleted ? "deleted" : "kept"}`);
|
||||
} else {
|
||||
console.log(` ✓ Closed ${result.task.id} (${result.error})`);
|
||||
}
|
||||
console.log(` Status: done`);
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`\n ✗ ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskMove(id: string, column: string) {
|
||||
if (!COLUMNS.includes(column as Column)) {
|
||||
console.error(`Invalid column: ${column}`);
|
||||
console.error(`Valid columns: ${COLUMNS.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const task = await store.moveTask(id, column as Column);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`);
|
||||
console.log();
|
||||
}
|
||||
8
packages/cli/tsconfig.json
Normal file
8
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
packages/core/package.json
Normal file
15
packages/core/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@hai/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
41
packages/core/src/board.ts
Normal file
41
packages/core/src/board.ts
Normal 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;
|
||||
}
|
||||
4
packages/core/src/index.ts
Normal file
4
packages/core/src/index.ts
Normal 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
335
packages/core/src/store.ts
Normal 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
|
||||
`;
|
||||
}
|
||||
}
|
||||
61
packages/core/src/types.ts
Normal file
61
packages/core/src/types.ts
Normal 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: [],
|
||||
};
|
||||
8
packages/core/tsconfig.json
Normal file
8
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
packages/dashboard/package.json
Normal file
20
packages/dashboard/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hai/dashboard",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
361
packages/dashboard/public/board.js
Normal file
361
packages/dashboard/public/board.js
Normal file
@@ -0,0 +1,361 @@
|
||||
// hai board — client
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done"];
|
||||
const COLUMN_LABELS = {
|
||||
triage: "Triage",
|
||||
todo: "Todo",
|
||||
"in-progress": "In Progress",
|
||||
"in-review": "In Review",
|
||||
done: "Done",
|
||||
};
|
||||
const TRANSITIONS = {
|
||||
triage: ["todo"],
|
||||
todo: ["in-progress", "triage"],
|
||||
"in-progress": ["in-review"],
|
||||
"in-review": ["done", "in-progress"],
|
||||
done: [],
|
||||
};
|
||||
|
||||
let tasks = [];
|
||||
let eventSource = null;
|
||||
|
||||
// ── API ──
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
function render() {
|
||||
for (const col of COLUMNS) {
|
||||
const body = document.querySelector(`[data-drop="${col}"]`);
|
||||
const colTasks = tasks.filter((t) => t.column === col);
|
||||
|
||||
document.querySelector(`[data-count="${col}"]`).textContent =
|
||||
colTasks.length;
|
||||
|
||||
if (colTasks.length === 0) {
|
||||
body.innerHTML = '<div class="empty-column">No tasks</div>';
|
||||
} else {
|
||||
body.innerHTML = colTasks.map(cardHTML).join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Attach drag handlers
|
||||
document.querySelectorAll(".card").forEach((card) => {
|
||||
card.addEventListener("dragstart", onDragStart);
|
||||
card.addEventListener("dragend", onDragEnd);
|
||||
card.addEventListener("click", () => showDetail(card.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
function cardHTML(task) {
|
||||
const deps =
|
||||
task.dependencies && task.dependencies.length
|
||||
? `<div class="card-meta"><span class="card-dep-badge">⛓ ${task.dependencies.length} dep${task.dependencies.length > 1 ? "s" : ""}</span></div>`
|
||||
: "";
|
||||
return `<div class="card" data-id="${task.id}" draggable="true">
|
||||
<span class="card-id">${task.id}</span>
|
||||
<div class="card-title">${escapeHtml(task.title)}</div>
|
||||
${deps}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Drag & Drop ──
|
||||
function onDragStart(e) {
|
||||
e.dataTransfer.setData("text/plain", e.currentTarget.dataset.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.currentTarget.classList.add("dragging");
|
||||
}
|
||||
|
||||
function onDragEnd(e) {
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
document
|
||||
.querySelectorAll(".column")
|
||||
.forEach((c) => c.classList.remove("drag-over"));
|
||||
}
|
||||
|
||||
function setupDropZones() {
|
||||
document.querySelectorAll(".column").forEach((column) => {
|
||||
column.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
column.classList.add("drag-over");
|
||||
});
|
||||
|
||||
column.addEventListener("dragleave", (e) => {
|
||||
// Only remove if actually leaving the column
|
||||
if (!column.contains(e.relatedTarget)) {
|
||||
column.classList.remove("drag-over");
|
||||
}
|
||||
});
|
||||
|
||||
column.addEventListener("drop", async (e) => {
|
||||
e.preventDefault();
|
||||
column.classList.remove("drag-over");
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
const toColumn = column.dataset.column;
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
|
||||
if (!task || task.column === toColumn) return;
|
||||
|
||||
try {
|
||||
await api(`/tasks/${taskId}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column: toColumn }),
|
||||
});
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Task Detail ──
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const task = await api(`/tasks/${id}`);
|
||||
const modal = document.getElementById("detail-modal");
|
||||
|
||||
document.getElementById("detail-id").textContent = task.id;
|
||||
document.getElementById("detail-title").textContent = task.title;
|
||||
|
||||
const badge = document.getElementById("detail-column");
|
||||
badge.textContent = COLUMN_LABELS[task.column];
|
||||
badge.className = `detail-column-badge badge-${task.column}`;
|
||||
|
||||
document.getElementById("detail-meta").textContent =
|
||||
`Created ${new Date(task.createdAt).toLocaleDateString()} · ` +
|
||||
`Updated ${new Date(task.updatedAt).toLocaleDateString()}`;
|
||||
|
||||
document.getElementById("detail-prompt").textContent =
|
||||
task.prompt || "(no prompt)";
|
||||
|
||||
// Dependencies
|
||||
const depsEl = document.getElementById("detail-deps");
|
||||
if (task.dependencies && task.dependencies.length) {
|
||||
depsEl.innerHTML =
|
||||
"<h4>Dependencies</h4><ul class='detail-dep-list'>" +
|
||||
task.dependencies.map((d) => `<li>${d}</li>`).join("") +
|
||||
"</ul>";
|
||||
} else {
|
||||
depsEl.innerHTML = "";
|
||||
}
|
||||
|
||||
// Actions: move buttons for valid transitions + merge for in-review
|
||||
const actionsEl = document.getElementById("detail-actions");
|
||||
const transitions = TRANSITIONS[task.column] || [];
|
||||
const buttons = [
|
||||
`<button class="btn btn-danger btn-sm" onclick="window.__deleteTask('${task.id}')">Delete</button>`,
|
||||
'<div style="flex:1"></div>',
|
||||
];
|
||||
|
||||
if (task.column === "in-review") {
|
||||
buttons.push(
|
||||
`<button class="btn btn-sm" onclick="window.__moveTask('${task.id}','in-progress')">Back to In Progress</button>`,
|
||||
`<button class="btn btn-primary btn-sm" onclick="window.__mergeTask('${task.id}')">Merge & Close</button>`,
|
||||
);
|
||||
} else {
|
||||
transitions.forEach((col) => {
|
||||
buttons.push(
|
||||
`<button class="btn btn-sm" onclick="window.__moveTask('${task.id}','${col}')">Move to ${COLUMN_LABELS[col]}</button>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
actionsEl.innerHTML = buttons.join("");
|
||||
|
||||
openModal("detail-modal");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Global action handlers (used by onclick in dynamic HTML)
|
||||
window.__moveTask = async (id, column) => {
|
||||
try {
|
||||
await api(`/tasks/${id}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column }),
|
||||
});
|
||||
closeModal("detail-modal");
|
||||
toast(`Moved to ${COLUMN_LABELS[column]}`, "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.__mergeTask = async (id) => {
|
||||
if (!confirm(`Merge ${id} into the current branch?`)) return;
|
||||
try {
|
||||
const result = await api(`/tasks/${id}/merge`, { method: "POST" });
|
||||
closeModal("detail-modal");
|
||||
const msg = result.merged
|
||||
? `Merged ${id} (branch: ${result.branch})`
|
||||
: `Closed ${id} (${result.error || "no branch to merge"})`;
|
||||
toast(msg, "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
window.__deleteTask = async (id) => {
|
||||
if (!confirm(`Delete ${id}?`)) return;
|
||||
try {
|
||||
await api(`/tasks/${id}`, { method: "DELETE" });
|
||||
closeModal("detail-modal");
|
||||
toast(`Deleted ${id}`, "info");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Create Task ──
|
||||
function setupCreateForm() {
|
||||
const form = document.getElementById("create-form");
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const title = document.getElementById("task-title").value.trim();
|
||||
const description = document.getElementById("task-desc").value.trim();
|
||||
const depsRaw = document.getElementById("task-deps").value.trim();
|
||||
const dependencies = depsRaw
|
||||
? depsRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const task = await api("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, description, dependencies }),
|
||||
});
|
||||
closeModal("create-modal");
|
||||
form.reset();
|
||||
toast(`Created ${task.id}`, "success");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── SSE ──
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("task:created", (e) => {
|
||||
const task = JSON.parse(e.data);
|
||||
tasks.push(task);
|
||||
render();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("task:moved", (e) => {
|
||||
const { task } = JSON.parse(e.data);
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx !== -1) tasks[idx] = task;
|
||||
render();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("task:updated", (e) => {
|
||||
const task = JSON.parse(e.data);
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx !== -1) tasks[idx] = task;
|
||||
render();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("task:deleted", (e) => {
|
||||
const task = JSON.parse(e.data);
|
||||
tasks = tasks.filter((t) => t.id !== task.id);
|
||||
render();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("task:merged", (e) => {
|
||||
const { task } = JSON.parse(e.data);
|
||||
const idx = tasks.findIndex((t) => t.id === task.id);
|
||||
if (idx !== -1) tasks[idx] = task;
|
||||
render();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("error", () => {
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) connectSSE();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Modals ──
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.add("open");
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove("open");
|
||||
}
|
||||
|
||||
function setupModals() {
|
||||
// Close buttons
|
||||
document.querySelectorAll("[data-close]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => closeModal(btn.dataset.close));
|
||||
});
|
||||
|
||||
// Click overlay to close
|
||||
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) overlay.classList.remove("open");
|
||||
});
|
||||
});
|
||||
|
||||
// Escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal-overlay.open").forEach((m) => {
|
||||
m.classList.remove("open");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add task button
|
||||
document.getElementById("add-task-btn").addEventListener("click", () => {
|
||||
openModal("create-modal");
|
||||
setTimeout(() => document.getElementById("task-title").focus(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Toasts ──
|
||||
function toast(message, type = "info") {
|
||||
const container = document.getElementById("toasts");
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast toast-${type}`;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
async function init() {
|
||||
try {
|
||||
tasks = await api("/tasks");
|
||||
} catch {
|
||||
tasks = [];
|
||||
}
|
||||
render();
|
||||
setupDropZones();
|
||||
setupModals();
|
||||
setupCreateForm();
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
122
packages/dashboard/public/index.html
Normal file
122
packages/dashboard/public/index.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>hai — board</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="logo">hai</h1>
|
||||
<span class="logo-sub">board</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="add-task-btn">+ New Task</button>
|
||||
</header>
|
||||
|
||||
<main class="board" id="board">
|
||||
<div class="column" data-column="triage">
|
||||
<div class="column-header">
|
||||
<div class="column-dot dot-triage"></div>
|
||||
<h2>Triage</h2>
|
||||
<span class="column-count" data-count="triage">0</span>
|
||||
</div>
|
||||
<p class="column-desc">Raw ideas — AI will specify these</p>
|
||||
<div class="column-body" data-drop="triage"></div>
|
||||
</div>
|
||||
<div class="column" data-column="todo">
|
||||
<div class="column-header">
|
||||
<div class="column-dot dot-todo"></div>
|
||||
<h2>Todo</h2>
|
||||
<span class="column-count" data-count="todo">0</span>
|
||||
</div>
|
||||
<p class="column-desc">Specified and ready to start</p>
|
||||
<div class="column-body" data-drop="todo"></div>
|
||||
</div>
|
||||
<div class="column" data-column="in-progress">
|
||||
<div class="column-header">
|
||||
<div class="column-dot dot-in-progress"></div>
|
||||
<h2>In Progress</h2>
|
||||
<span class="column-count" data-count="in-progress">0</span>
|
||||
</div>
|
||||
<p class="column-desc">AI is working in a worktree</p>
|
||||
<div class="column-body" data-drop="in-progress"></div>
|
||||
</div>
|
||||
<div class="column" data-column="in-review">
|
||||
<div class="column-header">
|
||||
<div class="column-dot dot-in-review"></div>
|
||||
<h2>In Review</h2>
|
||||
<span class="column-count" data-count="in-review">0</span>
|
||||
</div>
|
||||
<p class="column-desc">Complete — ready to merge</p>
|
||||
<div class="column-body" data-drop="in-review"></div>
|
||||
</div>
|
||||
<div class="column" data-column="done">
|
||||
<div class="column-header">
|
||||
<div class="column-dot dot-done"></div>
|
||||
<h2>Done</h2>
|
||||
<span class="column-count" data-count="done">0</span>
|
||||
</div>
|
||||
<p class="column-desc">Merged and closed</p>
|
||||
<div class="column-body" data-drop="done"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Create Task Modal -->
|
||||
<div class="modal-overlay" id="create-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>New Task</h3>
|
||||
<button class="modal-close" data-close="create-modal">×</button>
|
||||
</div>
|
||||
<form id="create-form">
|
||||
<div class="form-group">
|
||||
<label for="task-title">Title</label>
|
||||
<input type="text" id="task-title" placeholder="What needs to be done?" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-desc">Description <span class="optional">(optional)</span></label>
|
||||
<textarea id="task-desc" rows="4" placeholder="Add context, requirements, or rough notes..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-deps">Dependencies <span class="optional">(comma-separated IDs)</span></label>
|
||||
<input type="text" id="task-deps" placeholder="HAI-001, HAI-002">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" data-close="create-modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create in Triage</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Detail Modal -->
|
||||
<div class="modal-overlay" id="detail-modal">
|
||||
<div class="modal modal-lg">
|
||||
<div class="modal-header">
|
||||
<div class="detail-title-row">
|
||||
<span class="detail-id" id="detail-id"></span>
|
||||
<span class="detail-column-badge" id="detail-column"></span>
|
||||
</div>
|
||||
<button class="modal-close" data-close="detail-modal">×</button>
|
||||
</div>
|
||||
<div class="detail-body">
|
||||
<h2 id="detail-title" class="detail-title"></h2>
|
||||
<div class="detail-meta" id="detail-meta"></div>
|
||||
<div class="detail-section">
|
||||
<h4>PROMPT.md</h4>
|
||||
<pre class="detail-prompt" id="detail-prompt"></pre>
|
||||
</div>
|
||||
<div class="detail-deps" id="detail-deps"></div>
|
||||
</div>
|
||||
<div class="modal-actions" id="detail-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div class="toast-container" id="toasts"></div>
|
||||
|
||||
<script src="/board.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
414
packages/dashboard/public/style.css
Normal file
414
packages/dashboard/public/style.css
Normal file
@@ -0,0 +1,414 @@
|
||||
/* === Reset & Tokens === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--card: #21262d;
|
||||
--card-hover: #282e36;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--text-dim: #484f58;
|
||||
|
||||
--triage: #d29922;
|
||||
--todo: #58a6ff;
|
||||
--in-progress: #bc8cff;
|
||||
--in-review: #3fb950;
|
||||
--done: #8b949e;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Header === */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: baseline; gap: 8px; }
|
||||
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
/* === Buttons === */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn:hover { background: var(--card-hover); border-color: var(--text-muted); }
|
||||
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
border-color: #2ea043;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
|
||||
.btn-danger {
|
||||
background: #da3633;
|
||||
border-color: #f85149;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { background: #f85149; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* === Board === */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
height: calc(100vh - 57px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.column.drag-over {
|
||||
border-color: var(--todo);
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 0;
|
||||
}
|
||||
|
||||
.column-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot-triage { background: var(--triage); }
|
||||
.dot-todo { background: var(--todo); }
|
||||
.dot-in-progress { background: var(--in-progress); }
|
||||
.dot-in-review { background: var(--in-review); }
|
||||
.dot-done { background: var(--done); }
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.column-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 14px 10px;
|
||||
}
|
||||
|
||||
.column-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.column-body::-webkit-scrollbar { width: 4px; }
|
||||
.column-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.column-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
/* === Cards === */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
cursor: grab;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.15s, opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.card:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
|
||||
.card-id {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.card-dep-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
/* === Modals === */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-lg { width: 640px; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 15px; font-weight: 600; }
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* === Forms === */
|
||||
.form-group { padding: 0 20px; margin-top: 16px; }
|
||||
.form-group:last-of-type { margin-bottom: 8px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.optional { font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--todo);
|
||||
}
|
||||
.form-group textarea { resize: vertical; }
|
||||
|
||||
/* === Detail Modal === */
|
||||
.detail-title-row { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.detail-id {
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-column-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-triage { background: rgba(210,153,34,0.15); color: var(--triage); }
|
||||
.badge-todo { background: rgba(88,166,255,0.15); color: var(--todo); }
|
||||
.badge-in-progress { background: rgba(188,140,255,0.15); color: var(--in-progress); }
|
||||
.badge-in-review { background: rgba(63,185,80,0.15); color: var(--in-review); }
|
||||
.badge-done { background: rgba(139,148,158,0.15); color: var(--done); }
|
||||
|
||||
.detail-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-section { margin-top: 16px; }
|
||||
.detail-section h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-prompt {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-muted);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-deps { margin-top: 16px; }
|
||||
.detail-deps h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.detail-dep-list {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-dep-list li {
|
||||
padding: 4px 0;
|
||||
color: var(--todo);
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* === Toasts === */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
animation: toast-in 0.25s ease-out;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toast-success { background: #238636; }
|
||||
.toast-error { background: #da3633; }
|
||||
.toast-info { background: #1f6feb; }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* === Empty state === */
|
||||
.empty-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin: 4px;
|
||||
}
|
||||
1
packages/dashboard/src/index.ts
Normal file
1
packages/dashboard/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createServer } from "./server.js";
|
||||
105
packages/dashboard/src/routes.ts
Normal file
105
packages/dashboard/src/routes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Router } from "express";
|
||||
import type { TaskStore, Column } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
|
||||
export function createApiRoutes(store: TaskStore): Router {
|
||||
const router = Router();
|
||||
|
||||
// List all tasks
|
||||
router.get("/tasks", async (_req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
res.json(tasks);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
}
|
||||
});
|
||||
|
||||
// Create task
|
||||
router.post("/tasks", async (req, res) => {
|
||||
try {
|
||||
const { title, description, column, dependencies } = req.body;
|
||||
if (!title || typeof title !== "string") {
|
||||
res.status(400).json({ error: "title is required" });
|
||||
return;
|
||||
}
|
||||
const task = await store.createTask({
|
||||
title,
|
||||
description,
|
||||
column,
|
||||
dependencies,
|
||||
});
|
||||
res.status(201).json(task);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Move task to column
|
||||
router.post("/tasks/:id/move", async (req, res) => {
|
||||
try {
|
||||
const { column } = req.body;
|
||||
if (!column || !COLUMNS.includes(column as Column)) {
|
||||
res.status(400).json({
|
||||
error: `Invalid column. Must be one of: ${COLUMNS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const task = await store.moveTask(req.params.id, column as Column);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Invalid transition") ? 400 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update task
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const { title, description, prompt } = req.body;
|
||||
const task = await store.updateTask(req.params.id, {
|
||||
title,
|
||||
description,
|
||||
prompt,
|
||||
});
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Merge task (in-review → done, merges branch + cleans worktree)
|
||||
router.post("/tasks/:id/merge", async (req, res) => {
|
||||
try {
|
||||
const result = await store.mergeTask(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Cannot merge") ? 400
|
||||
: err.message.includes("Merge conflict") ? 409
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete task
|
||||
router.delete("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const task = await store.deleteTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
28
packages/dashboard/src/server.ts
Normal file
28
packages/dashboard/src/server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import express from "express";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TaskStore } from "@hai/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function createServer(store: TaskStore) {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(join(__dirname, "..", "public")));
|
||||
|
||||
// SSE endpoint
|
||||
app.get("/api/events", createSSE(store));
|
||||
|
||||
// REST API
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// SPA fallback
|
||||
app.get("/{*splat}", (_req, res) => {
|
||||
res.sendFile(join(__dirname, "..", "public", "index.html"));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
51
packages/dashboard/src/sse.ts
Normal file
51
packages/dashboard/src/sse.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { TaskStore } from "@hai/core";
|
||||
|
||||
export function createSSE(store: TaskStore) {
|
||||
return (_req: Request, res: Response) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
|
||||
// Send initial heartbeat
|
||||
res.write(": connected\n\n");
|
||||
|
||||
const onCreated = (task: any) => {
|
||||
res.write(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMoved = (data: any) => {
|
||||
res.write(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
const onUpdated = (task: any) => {
|
||||
res.write(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onDeleted = (task: any) => {
|
||||
res.write(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
|
||||
};
|
||||
const onMerged = (result: any) => {
|
||||
res.write(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
|
||||
};
|
||||
|
||||
store.on("task:created", onCreated);
|
||||
store.on("task:moved", onMoved);
|
||||
store.on("task:updated", onUpdated);
|
||||
store.on("task:deleted", onDeleted);
|
||||
store.on("task:merged", onMerged);
|
||||
|
||||
// Heartbeat every 30s to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
_req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
store.off("task:created", onCreated);
|
||||
store.off("task:moved", onMoved);
|
||||
store.off("task:updated", onUpdated);
|
||||
store.off("task:deleted", onDeleted);
|
||||
store.off("task:merged", onMerged);
|
||||
});
|
||||
};
|
||||
}
|
||||
8
packages/dashboard/tsconfig.json
Normal file
8
packages/dashboard/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
20
packages/engine/package.json
Normal file
20
packages/engine/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hai/engine",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"@mariozechner/pi-coding-agent": "^0.62.0",
|
||||
"@mariozechner/pi-ai": "^0.62.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
229
packages/engine/src/executor.ts
Normal file
229
packages/engine/src/executor.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
|
||||
const EXECUTOR_SYSTEM_PROMPT = `You are a task execution agent for "hai", an AI-orchestrated task board.
|
||||
|
||||
You are working in a git worktree isolated from the main branch. Your job is to implement the task described in the PROMPT.md specification you're given.
|
||||
|
||||
## How to work
|
||||
1. Read the PROMPT.md carefully — it contains your mission, steps, file scope, and acceptance criteria
|
||||
2. Work through each step in order
|
||||
3. Write clean, production-quality code
|
||||
4. Test your changes
|
||||
5. Commit at meaningful boundaries (step completion)
|
||||
|
||||
## 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\`
|
||||
- Do NOT commit broken or half-implemented code
|
||||
|
||||
## 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
|
||||
|
||||
## Completion
|
||||
When all steps are complete and tests pass, create a \`.DONE\` file in the task directory to signal completion.`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class TaskExecutor {
|
||||
private activeWorktrees = new Map<string, string>();
|
||||
private executing = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
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) =>
|
||||
console.error(`[executor] Failed to start ${task.id}:`, err),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
try {
|
||||
// Check dependencies
|
||||
const allTasks = await this.store.listTasks();
|
||||
const unmetDeps = task.dependencies.filter((depId) => {
|
||||
const dep = allTasks.find((t) => t.id === depId);
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review";
|
||||
});
|
||||
|
||||
if (unmetDeps.length > 0) {
|
||||
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.activeWorktrees.set(task.id, worktreePath);
|
||||
|
||||
// Persist worktree path to task.json so merge can find it
|
||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||
|
||||
this.options.onStart?.(task, worktreePath);
|
||||
|
||||
// Read the task's PROMPT.md
|
||||
const detail = await this.store.getTask(task.id);
|
||||
|
||||
// Create a 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);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const agentPrompt = buildExecutionPrompt(detail);
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
// Check if the agent signaled completion (.DONE file)
|
||||
const doneFile = join(
|
||||
worktreePath,
|
||||
".hai",
|
||||
"tasks",
|
||||
task.id,
|
||||
".DONE",
|
||||
);
|
||||
const doneCwd = join(worktreePath, ".DONE");
|
||||
|
||||
if (existsSync(doneFile) || existsSync(doneCwd)) {
|
||||
await this.store.moveTask(task.id, "in-review");
|
||||
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.moveTask(task.id, "in-review");
|
||||
console.log(
|
||||
`[executor] ⚠ ${task.id} agent finished without .DONE → in-review for inspection`,
|
||||
);
|
||||
this.options.onComplete?.(task);
|
||||
}
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[executor] ✗ ${task.id} execution failed:`, err.message);
|
||||
this.options.onError?.(task, err);
|
||||
} finally {
|
||||
this.executing.delete(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
private createWorktree(branch: string, path: string): void {
|
||||
if (existsSync(path)) {
|
||||
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",
|
||||
});
|
||||
} catch {
|
||||
// Branch might already exist — try attaching
|
||||
try {
|
||||
execSync(`git worktree add "${path}" "${branch}"`, {
|
||||
cwd: this.rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw new Error(`Failed to create worktree: ${e.message}`);
|
||||
}
|
||||
}
|
||||
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",
|
||||
});
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getWorktreePath(taskId: string): string | undefined {
|
||||
return this.activeWorktrees.get(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildExecutionPrompt(task: TaskDetail): string {
|
||||
return `Execute this task. The PROMPT.md specification follows.
|
||||
|
||||
## Task Info
|
||||
- **ID:** ${task.id}
|
||||
- **Title:** ${task.title}
|
||||
${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.`;
|
||||
}
|
||||
4
packages/engine/src/index.ts
Normal file
4
packages/engine/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
|
||||
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
|
||||
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
||||
export { createHaiAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
83
packages/engine/src/pi.ts
Normal file
83
packages/engine/src/pi.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Shared pi SDK setup for hai engine agents.
|
||||
*
|
||||
* Uses the user's existing pi auth (API keys / OAuth from ~/.pi/agent/auth.json).
|
||||
* Provides factory functions for creating triage and executor agent sessions.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthStorage,
|
||||
createAgentSession,
|
||||
createCodingTools,
|
||||
createReadOnlyTools,
|
||||
DefaultResourceLoader,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type AgentSession,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
}
|
||||
|
||||
export interface AgentOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: "coding" | "readonly";
|
||||
onText?: (delta: string) => void;
|
||||
onToolStart?: (name: string) => void;
|
||||
onToolEnd?: (name: string, isError: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pi agent session configured for hai.
|
||||
* Reuses the user's existing pi auth and model configuration.
|
||||
*/
|
||||
export async function createHaiAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const tools =
|
||||
options.tools === "readonly"
|
||||
? createReadOnlyTools(options.cwd)
|
||||
: createCodingTools(options.cwd);
|
||||
|
||||
const settingsManager = SettingsManager.inMemory({
|
||||
compaction: { enabled: true },
|
||||
retry: { enabled: true, maxRetries: 3 },
|
||||
});
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
settingsManager,
|
||||
systemPromptOverride: () => options.systemPrompt,
|
||||
appendSystemPromptOverride: () => [],
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: options.cwd,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
tools,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
// Wire up event listeners
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
||||
options.onText?.(event.assistantMessageEvent.delta);
|
||||
}
|
||||
if (event.type === "tool_execution_start") {
|
||||
options.onToolStart?.(event.toolName);
|
||||
}
|
||||
if (event.type === "tool_execution_end") {
|
||||
options.onToolEnd?.(event.toolName, event.isError);
|
||||
}
|
||||
});
|
||||
|
||||
return { session };
|
||||
}
|
||||
99
packages/engine/src/scheduler.ts
Normal file
99
packages/engine/src/scheduler.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { resolveDependencyOrder, type TaskStore, type Task } from "@hai/core";
|
||||
|
||||
export interface SchedulerOptions {
|
||||
/** Max concurrent in-progress tasks. Default: 2 */
|
||||
maxConcurrent?: number;
|
||||
/** Milliseconds between scheduling polls. Default: 15000 */
|
||||
pollIntervalMs?: number;
|
||||
/** Called when scheduler starts a task */
|
||||
onSchedule?: (task: Task) => void;
|
||||
/** Called when a task is blocked by deps */
|
||||
onBlocked?: (task: Task, blockedBy: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler watches the "todo" column and moves tasks to "in-progress"
|
||||
* when their dependencies are satisfied and concurrency allows.
|
||||
*
|
||||
* It respects:
|
||||
* - Dependency ordering (tasks depending on others wait)
|
||||
* - Concurrency limits (max N tasks in-progress at once)
|
||||
*/
|
||||
export class Scheduler {
|
||||
private running = false;
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private options: SchedulerOptions = {},
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
const interval = this.options.pollIntervalMs ?? 15_000;
|
||||
this.pollInterval = setInterval(() => this.schedule(), interval);
|
||||
this.schedule();
|
||||
console.log(
|
||||
`[scheduler] Started (max concurrent: ${this.options.maxConcurrent ?? 2})`,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
console.log("[scheduler] Stopped");
|
||||
}
|
||||
|
||||
/** Run one scheduling pass. */
|
||||
async schedule(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const maxConcurrent = this.options.maxConcurrent ?? 2;
|
||||
|
||||
const inProgress = tasks.filter((t) => t.column === "in-progress");
|
||||
const available = maxConcurrent - inProgress.length;
|
||||
if (available <= 0) return;
|
||||
|
||||
const todo = tasks.filter((t) => t.column === "todo");
|
||||
if (todo.length === 0) return;
|
||||
|
||||
// Resolve dependency order among todo tasks
|
||||
const ordered = resolveDependencyOrder(todo);
|
||||
let started = 0;
|
||||
|
||||
for (const taskId of ordered) {
|
||||
if (started >= available) break;
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId)!;
|
||||
|
||||
// Check all deps are satisfied (done or in-review)
|
||||
const unmetDeps = task.dependencies.filter((depId) => {
|
||||
const dep = tasks.find((t) => t.id === depId);
|
||||
return dep && dep.column !== "done" && dep.column !== "in-review";
|
||||
});
|
||||
|
||||
if (unmetDeps.length > 0) {
|
||||
this.options.onBlocked?.(task, unmetDeps);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dependencies met — move to in-progress
|
||||
console.log(
|
||||
`[scheduler] Starting ${task.id}: ${task.title} (deps satisfied)`,
|
||||
);
|
||||
await this.store.moveTask(task.id, "in-progress");
|
||||
this.options.onSchedule?.(task);
|
||||
started++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[scheduler] Scheduling error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/engine/src/triage.ts
Normal file
160
packages/engine/src/triage.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { TaskStore, Task, TaskDetail } from "@hai/core";
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
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
|
||||
|
||||
## Guidelines
|
||||
- Read the project structure and relevant source files to understand context before writing the spec
|
||||
- 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
|
||||
|
||||
## Output format
|
||||
Write the PROMPT.md content 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;
|
||||
}
|
||||
|
||||
export class TriageProcessor {
|
||||
private running = false;
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
private rootDir: string,
|
||||
private options: TriageProcessorOptions = {},
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
const interval = this.options.pollIntervalMs ?? 10_000;
|
||||
this.pollInterval = setInterval(() => this.poll(), interval);
|
||||
this.poll();
|
||||
console.log("[triage] Processor started");
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
console.log("[triage] Processor stopped");
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
const triageTasks = tasks.filter(
|
||||
(t) => t.column === "triage" && !this.processing.has(t.id),
|
||||
);
|
||||
|
||||
for (const task of triageTasks) {
|
||||
// Process one at a time to avoid overwhelming the API
|
||||
await this.specifyTask(task);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[triage] Poll error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async specifyTask(task: Task): Promise<void> {
|
||||
if (this.processing.has(task.id)) return;
|
||||
this.processing.add(task.id);
|
||||
|
||||
console.log(`[triage] Specifying ${task.id}: ${task.title}`);
|
||||
this.options.onSpecifyStart?.(task);
|
||||
|
||||
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,
|
||||
tools: "coding",
|
||||
onText: (delta) => this.options.onAgentText?.(task.id, delta),
|
||||
onToolStart: (name) =>
|
||||
console.log(`[triage] ${task.id} tool: ${name}`),
|
||||
});
|
||||
|
||||
try {
|
||||
// Build the prompt for the agent
|
||||
const agentPrompt = buildSpecificationPrompt(detail, promptPath);
|
||||
|
||||
// Run the agent
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
// Move to todo
|
||||
await this.store.moveTask(task.id, "todo");
|
||||
console.log(`[triage] ✓ ${task.id} specified and moved to todo`);
|
||||
this.options.onSpecifyComplete?.(task);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[triage] ✗ ${task.id} specification failed:`, err.message);
|
||||
this.options.onSpecifyError?.(task, err);
|
||||
} finally {
|
||||
this.processing.delete(task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpecificationPrompt(task: TaskDetail, promptPath: string): string {
|
||||
return `Specify this task and write the result to \`${promptPath}\`.
|
||||
|
||||
## Task
|
||||
- **ID:** ${task.id}
|
||||
- **Title:** ${task.title}
|
||||
${task.description ? `- **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}\`
|
||||
3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions
|
||||
|
||||
Use the write tool to write the specification file.`;
|
||||
}
|
||||
8
packages/engine/tsconfig.json
Normal file
8
packages/engine/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user