feat(HAI-002): complete React conversion with build fixes and project setup

- Fix Vite alias to resolve @hai/core to types-only module (avoids Node.js deps in browser bundle)
- Add tsconfig.app.json for client-side typecheck
- Fix server.ts return type annotation
- Update imports to use @hai/core (resolved via Vite alias)
- Add base project files needed for workspace
This commit is contained in:
Dustin Byrne
2026-03-25 18:29:50 -04:00
parent e0746fb387
commit 6a754cf199
31 changed files with 1534 additions and 3 deletions

22
packages/cli/package.json Normal file
View 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"
}
}

89
packages/cli/src/bin.ts Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
import { runDashboard } from "./commands/dashboard.js";
import { runTaskCreate, runTaskList, runTaskMove } 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
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;
}
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();

View 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();
});
}

View File

@@ -0,0 +1,78 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column } 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 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();
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View 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"
}
}

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 } from "./types.js";
export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

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

@@ -0,0 +1,217 @@
import { EventEmitter } from "node:events";
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 } 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];
}
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 },
): 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;
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;
}
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,52 @@
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 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: [],
};

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -9,7 +9,7 @@
"build": "vite build && tsc",
"build:client": "vite build",
"dev:client": "vite",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@hai/core": "workspace:*",

View File

@@ -0,0 +1 @@
export { createServer } from "./server.js";

View File

@@ -0,0 +1,92 @@
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 });
}
});
// 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;
}

View File

@@ -7,7 +7,7 @@ import { createSSE } from "./sse.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export function createServer(store: TaskStore) {
export function createServer(store: TaskStore): ReturnType<typeof express> {
const app = express();
app.use(express.json());

View File

@@ -0,0 +1,46 @@
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`);
};
store.on("task:created", onCreated);
store.on("task:moved", onMoved);
store.on("task:updated", onUpdated);
store.on("task:deleted", onDeleted);
// 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);
});
};
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true
},
"include": ["app"]
}

View File

@@ -5,5 +5,5 @@
"rootDir": "src",
"jsx": "react-jsx"
},
"include": ["src", "app"]
"include": ["src"]
}

View File

@@ -1,9 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
root: "app",
plugins: [react()],
resolve: {
alias: {
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
},
},
build: {
outDir: "../dist/client",
emptyOutDir: true,

View 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"
}
}

View File

@@ -0,0 +1,226 @@
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);
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.`;
}

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

View 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);
}
}
}

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

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}