feat: AI-powered merge via pi agent
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { TaskStore } from "@hai/core";
|
||||
import { createServer } from "@hai/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler } from "@hai/engine";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, aiMergeTask } from "@hai/engine";
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const cmd =
|
||||
@@ -16,8 +16,15 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
const store = new TaskStore(cwd);
|
||||
await store.init();
|
||||
|
||||
// Start the web server
|
||||
const app = createServer(store);
|
||||
// AI-powered merge handler
|
||||
const onMerge = (taskId: string) =>
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
onAgentTool: (name) => console.log(`[merger] tool: ${name}`),
|
||||
});
|
||||
|
||||
// Start the web server with AI merge wired in
|
||||
const app = createServer(store, { onMerge });
|
||||
|
||||
// Optionally start the AI engine
|
||||
if (opts.engine) {
|
||||
@@ -56,6 +63,7 @@ export async function runDashboard(port: number, opts: { engine?: boolean; open?
|
||||
console.log(` → http://localhost:${port}`);
|
||||
console.log();
|
||||
console.log(` Tasks stored in .hai/tasks/`);
|
||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||
if (opts.engine) {
|
||||
console.log(` AI engine: ✓ active`);
|
||||
console.log(` • triage: auto-specifying tasks`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult } from "@hai/core";
|
||||
import { aiMergeTask } from "@hai/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
async function getStore(): Promise<TaskStore> {
|
||||
@@ -63,10 +64,16 @@ export async function runTaskList() {
|
||||
}
|
||||
|
||||
export async function runTaskMerge(id: string) {
|
||||
const cwd = process.cwd();
|
||||
const store = await getStore();
|
||||
|
||||
console.log(`\n Merging ${id} with AI...\n`);
|
||||
|
||||
try {
|
||||
const result = await store.mergeTask(id);
|
||||
const result = await aiMergeTask(store, cwd, id, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
onAgentTool: (name) => console.log(` [merge] tool: ${name}`),
|
||||
});
|
||||
|
||||
console.log();
|
||||
if (result.merged) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { createServer } from "./server.js";
|
||||
export { createServer, type ServerOptions } from "./server.js";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import type { TaskStore, Column } from "@hai/core";
|
||||
import type { TaskStore, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
|
||||
export function createApiRoutes(store: TaskStore): Router {
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
// List all tasks
|
||||
@@ -54,13 +55,15 @@ export function createApiRoutes(store: TaskStore): Router {
|
||||
});
|
||||
|
||||
// Merge task (in-review → done, merges branch + cleans worktree)
|
||||
// Uses AI merge handler if provided, falls back to store.mergeTask
|
||||
router.post("/tasks/:id/merge", async (req, res) => {
|
||||
try {
|
||||
const result = await store.mergeTask(req.params.id);
|
||||
const merge = options?.onMerge ?? ((id: string) => store.mergeTask(id));
|
||||
const result = await merge(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Cannot merge") ? 400
|
||||
: err.message.includes("Merge conflict") ? 409
|
||||
: err.message.includes("conflict") ? 409
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -2,13 +2,18 @@ import express from "express";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TaskStore } from "@hai/core";
|
||||
import type { TaskStore, MergeResult } 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): ReturnType<typeof express> {
|
||||
export interface ServerOptions {
|
||||
/** Custom merge handler — when provided, used instead of store.mergeTask */
|
||||
onMerge?: (taskId: string) => Promise<MergeResult>;
|
||||
}
|
||||
|
||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
@@ -25,7 +30,7 @@ export function createServer(store: TaskStore): ReturnType<typeof express> {
|
||||
app.get("/api/events", createSSE(store));
|
||||
|
||||
// REST API
|
||||
app.use("/api", createApiRoutes(store));
|
||||
app.use("/api", createApiRoutes(store, options));
|
||||
|
||||
// SPA fallback
|
||||
app.get("/{*splat}", (_req, res) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
|
||||
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
|
||||
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||
export { createHaiAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
|
||||
272
packages/engine/src/merger.ts
Normal file
272
packages/engine/src/merger.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { TaskStore, Task, MergeResult } from "@hai/core";
|
||||
import { createHaiAgent } from "./pi.js";
|
||||
|
||||
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
|
||||
|
||||
Your job is to finalize a git merge: resolve any conflicts and write a good commit message.
|
||||
|
||||
## Conflict resolution
|
||||
If there are merge conflicts:
|
||||
1. Run \`git diff --name-only --diff-filter=U\` to list conflicted files
|
||||
2. Read each conflicted file — look for the <<<<<<< / ======= / >>>>>>> markers
|
||||
3. Understand the intent of BOTH sides, then edit the file to produce the correct merged result
|
||||
4. Remove ALL conflict markers — the result must be clean, compilable code
|
||||
5. Run \`git add <file>\` for each resolved file
|
||||
6. Do NOT change anything beyond what's needed to resolve the conflict
|
||||
|
||||
## Commit message
|
||||
After all conflicts are resolved (or if there were none), write and execute the merge commit.
|
||||
|
||||
Look at the branch commits and diff to understand what was done, then run:
|
||||
\`\`\`
|
||||
git commit --no-edit -m "<type>(<scope>): <summary>" -m "<body>"
|
||||
\`\`\`
|
||||
|
||||
Message format:
|
||||
- **Type:** feat, fix, refactor, docs, test, chore
|
||||
- **Scope:** the task ID (e.g., HAI-001)
|
||||
- **Summary:** one line describing what the merge brings in (imperative mood)
|
||||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
git commit --no-edit -m "feat(HAI-003): add user profile page" -m "- Add /profile route with avatar upload
|
||||
- Create ProfileCard and EditProfileForm components
|
||||
- Add profile image resizing via sharp
|
||||
- Update nav bar with profile link
|
||||
- Add profile e2e tests"
|
||||
\`\`\`
|
||||
|
||||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||||
Base the message on the ACTUAL work done in the commits.`;
|
||||
|
||||
export interface MergerOptions {
|
||||
/** Called with agent text output */
|
||||
onAgentText?: (delta: string) => void;
|
||||
/** Called with agent tool usage */
|
||||
onAgentTool?: (toolName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-powered merge: resolves conflicts with a pi agent and
|
||||
* writes a commit message that summarizes the branch's work.
|
||||
*/
|
||||
export async function aiMergeTask(
|
||||
store: TaskStore,
|
||||
rootDir: string,
|
||||
taskId: string,
|
||||
options: MergerOptions = {},
|
||||
): Promise<MergeResult> {
|
||||
// 1. Validate task state
|
||||
const task = await store.getTask(taskId);
|
||||
if (task.column !== "in-review") {
|
||||
throw new Error(
|
||||
`Cannot merge ${taskId}: task is in '${task.column}', must be in 'in-review'`,
|
||||
);
|
||||
}
|
||||
|
||||
const branch = `hai/${taskId.toLowerCase()}`;
|
||||
const worktreePath = task.worktree || join(rootDir, ".worktrees", taskId);
|
||||
const result: MergeResult = {
|
||||
task,
|
||||
branch,
|
||||
merged: false,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
|
||||
// 2. Check branch exists
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${branch}"`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Gather context for the agent
|
||||
let commitLog = "";
|
||||
let diffStat = "";
|
||||
try {
|
||||
commitLog = execSync(`git log main..${branch} --format="- %s"`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
commitLog = "(unable to read commit log)";
|
||||
}
|
||||
try {
|
||||
diffStat = execSync(`git diff main..${branch} --stat`, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
diffStat = "(unable to read diff)";
|
||||
}
|
||||
|
||||
// 4. Start the merge (--no-commit so the agent controls the message)
|
||||
let hasConflicts = false;
|
||||
try {
|
||||
execSync(`git merge "${branch}" --no-commit --no-ff`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Conflicts or other merge issue — check if it's conflicts
|
||||
try {
|
||||
const conflicted = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
hasConflicts = conflicted.length > 0;
|
||||
|
||||
if (!hasConflicts) {
|
||||
// Not conflicts — some other merge failure. Abort and throw.
|
||||
try {
|
||||
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`Merge failed for branch '${branch}'`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message.includes("Merge failed")) throw e;
|
||||
// git diff itself failed — abort
|
||||
try {
|
||||
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`Merge failed for branch '${branch}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Spawn pi agent to resolve conflicts (if any) and write commit message
|
||||
console.log(
|
||||
`[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`,
|
||||
);
|
||||
|
||||
const { session } = await createHaiAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MERGE_SYSTEM_PROMPT,
|
||||
tools: "coding",
|
||||
onText: (delta) => options.onAgentText?.(delta),
|
||||
onToolStart: (name) => options.onAgentTool?.(name),
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// 6. Verify the commit happened
|
||||
try {
|
||||
// Check if HEAD moved (merge was committed)
|
||||
const mergeHead = execSync("git rev-parse MERGE_HEAD 2>/dev/null || true", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (mergeHead) {
|
||||
// Agent didn't commit — do it ourselves as fallback
|
||||
console.log("[merger] Agent didn't commit — committing with default message");
|
||||
execSync(
|
||||
`git commit --no-edit -m "feat(${taskId}): merge ${branch}" -m "${commitLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// MERGE_HEAD doesn't exist = commit was made successfully
|
||||
}
|
||||
|
||||
result.merged = true;
|
||||
} catch (err: any) {
|
||||
// Agent failed — try to abort the merge
|
||||
console.error(`[merger] Agent failed: ${err.message}`);
|
||||
try {
|
||||
execSync("git merge --abort", { cwd: rootDir, stdio: "pipe" });
|
||||
} catch { /* */ }
|
||||
throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
|
||||
// 7. Clean up worktree
|
||||
if (existsSync(worktreePath)) {
|
||||
try {
|
||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
result.worktreeRemoved = true;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 8. Delete branch
|
||||
try {
|
||||
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||
result.branchDeleted = true;
|
||||
} catch {
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||
result.branchDeleted = true;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 9. Move task to done
|
||||
await completeTask(store, taskId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function completeTask(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
result: MergeResult,
|
||||
): Promise<void> {
|
||||
// Use moveTask for proper event emission
|
||||
const task = await store.moveTask(taskId, "done");
|
||||
result.task = task;
|
||||
store.emit("task:merged", result);
|
||||
}
|
||||
|
||||
function buildMergePrompt(
|
||||
taskId: string,
|
||||
branch: string,
|
||||
commitLog: string,
|
||||
diffStat: string,
|
||||
hasConflicts: boolean,
|
||||
): string {
|
||||
const parts = [
|
||||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
||||
"",
|
||||
"## Branch commits",
|
||||
"```",
|
||||
commitLog,
|
||||
"```",
|
||||
"",
|
||||
"## Files changed",
|
||||
"```",
|
||||
diffStat,
|
||||
"```",
|
||||
];
|
||||
|
||||
if (hasConflicts) {
|
||||
parts.push(
|
||||
"",
|
||||
"## ⚠️ There are merge conflicts",
|
||||
"Run `git diff --name-only --diff-filter=U` to see which files.",
|
||||
"Resolve each conflict, then `git add` the resolved files.",
|
||||
"After resolving all conflicts, write and run the commit command.",
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
"",
|
||||
"## No conflicts",
|
||||
"The merge applied cleanly. All changes are staged.",
|
||||
"Write and run the `git commit` command with a good message summarizing the work.",
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user