feat(HAI-075): add project commands to buildExecutionPrompt

- Inject test and build commands from settings into the execution prompt
- Add Project Commands section with testCommand and buildCommand when configured
- Hoist getSettings call above the isResume check so commands are always available
- Add unit tests for project command propagation in buildExecutionPrompt
- Update integration test assertions for the hoisted getSettings call
This commit is contained in:
Dustin Byrne
2026-03-26 00:42:28 -04:00
parent 0b7e8e874a
commit 40099be89e
3 changed files with 110 additions and 9 deletions

View File

@@ -344,8 +344,8 @@ describe("TaskExecutor worktreeInitCommand", () => {
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask());
// getSettings should NOT have been called (skipped entire !isResume block)
expect(store.getSettings).not.toHaveBeenCalled();
// getSettings is called (for project commands in execution prompt) but init command should not run
expect(store.getSettings).toHaveBeenCalled();
});
});
@@ -704,4 +704,93 @@ describe("buildExecutionPrompt", () => {
expect(result).not.toContain("## Attachments");
});
it("includes Project Commands section with test command when settings.testCommand is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Test:** `pnpm test`");
expect(result).not.toContain("- **Build:**");
});
it("includes Project Commands section with build command when settings.buildCommand is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
buildCommand: "pnpm build",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Build:** `pnpm build`");
expect(result).not.toContain("- **Test:**");
});
it("includes both commands when both are set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {
testCommand: "pnpm test",
buildCommand: "pnpm build",
} as any);
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Test:** `pnpm test`");
expect(result).toContain("- **Build:** `pnpm build`");
});
it("omits Project Commands section when neither command is set", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/home/user/project", {} as any);
expect(result).not.toContain("## Project Commands");
});
it("omits Project Commands section when settings is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Project Commands");
});
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
testCommand: "npm test",
buildCommand: "npm run build",
});
const mockPrompt = vi.fn().mockResolvedValue(undefined);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: mockPrompt,
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "HAI-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(mockPrompt).toHaveBeenCalledOnce();
const agentPrompt = mockPrompt.mock.calls[0][0];
expect(agentPrompt).toContain("## Project Commands");
expect(agentPrompt).toContain("- **Test:** `npm test`");
expect(agentPrompt).toContain("- **Build:** `npm run build`");
});
});

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@hai/core";
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -246,9 +246,9 @@ export class TaskExecutor {
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
let isResume = existsSync(worktreePath);
let acquiredFromPool = false;
const settings = await this.store.getSettings();
if (!isResume) {
const settings = await this.store.getSettings();
// Try acquiring a warm worktree from the pool
if (this.options.pool && settings.recycleWorktrees) {
@@ -365,7 +365,7 @@ export class TaskExecutor {
});
try {
const agentPrompt = buildExecutionPrompt(detail, this.rootDir);
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
await session.prompt(agentPrompt);
if (taskDone) {
@@ -612,7 +612,10 @@ export class TaskExecutor {
}
}
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string): string {
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
// This ensures the executor agent always sees the authoritative commands from settings,
// even if the PROMPT.md was written manually or before commands were configured.
export function buildExecutionPrompt(task: TaskDetail, rootDir?: string, settings?: Settings): string {
const reviewMatch = task.prompt.match(/##\s*Review Level[:\s]*(\d)/);
const reviewLevel = reviewMatch ? parseInt(reviewMatch[1], 10) : 0;
@@ -660,6 +663,15 @@ git log --oneline
attachmentsSection = "\n" + lines.join("\n") + "\n";
}
// Build project commands section from settings
let commandsSection = "";
if (settings?.testCommand || settings?.buildCommand) {
const lines = ["## Project Commands"];
if (settings.testCommand) lines.push(`- **Test:** \`${settings.testCommand}\``);
if (settings.buildCommand) lines.push(`- **Build:** \`${settings.buildCommand}\``);
commandsSection = "\n" + lines.join("\n") + "\n";
}
return `Execute this task.
## Task: ${task.id}
@@ -669,7 +681,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${attachmentsSection}${progressSection}
${attachmentsSection}${commandsSection}${progressSection}
## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}

View File

@@ -228,8 +228,8 @@ describe("In-progress task resume after restart", () => {
await executor.resumeOrphaned();
await new Promise((r) => setTimeout(r, 50));
// getSettings should NOT have been called (skipped entire !isResume block)
expect(store.getSettings).not.toHaveBeenCalled();
// getSettings is called (for project commands in execution prompt) but init command should not run
expect(store.getSettings).toHaveBeenCalled();
// No init command calls
const initCalls = mockedExecSync.mock.calls.filter(