feat: hai board — core, dashboard, cli, engine
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user