feat(HAI-094): add includeTaskIdInCommit setting to control commit scope

- Add includeTaskIdInCommit boolean to Settings type with default true
- Refactor merger to dynamically build system prompt based on setting
- Update fallback commit message to respect the toggle
- Add checkbox to Settings UI under Merge section
- Add unit tests for merger and SettingsModal behavior
This commit is contained in:
Dustin Byrne
2026-03-26 19:29:45 -04:00
parent 121b509686
commit 30593e5f6b
5 changed files with 211 additions and 24 deletions

View File

@@ -185,6 +185,113 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
});
});
describe("aiMergeTask — includeTaskIdInCommit setting", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("includes task ID in system prompt by default (includeTaskIdInCommit: true)", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("the task ID");
});
it("omits task ID scope in system prompt when includeTaskIdInCommit is false", async () => {
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
const agentCall = mockedCreateHaiAgent.mock.calls[0][0] as any;
expect(agentCall.systemPrompt).toContain("<type>: <summary>");
expect(agentCall.systemPrompt).not.toContain("<type>(<scope>): <summary>");
expect(agentCall.systemPrompt).toContain("Do NOT include a scope");
});
it("fallback commit includes task ID when includeTaskIdInCommit is true", async () => {
// Make staged check return "1" so fallback is triggered
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat(HAI-050):");
});
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "1" as any;
if (cmdStr.includes("git commit")) return Buffer.from("");
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
const store = createMockStore(
{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050" },
[{ id: "HAI-050", worktree: "/tmp/root/.worktrees/HAI-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
includeTaskIdInCommit: false,
});
await aiMergeTask(store, "/tmp/root", "HAI-050");
const commitCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat: merge");
expect(String(commitCall![0])).not.toContain("feat(HAI-050)");
});
});
describe("aiMergeTask — agent log persistence", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -5,7 +5,52 @@ import { createHaiAgent } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
/**
* Build the merge system prompt. When `includeTaskId` is true (default),
* the commit format uses `<type>(<scope>): <summary>` where scope is the
* task ID. When false, it uses `<type>: <summary>` with no scope.
*/
function buildMergeSystemPrompt(includeTaskId: boolean): string {
const commitFormat = includeTaskId
? `\`\`\`
git commit -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 squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
Example:
\`\`\`
git commit -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"
\`\`\``
: `\`\`\`
git commit -m "<type>: <summary>" -m "<body>"
\`\`\`
Message format:
- **Type:** feat, fix, refactor, docs, test, chore
- **Summary:** one line describing what the squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
Do NOT include a scope in the commit message type.
Example:
\`\`\`
git commit -m "feat: 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"
\`\`\``;
return `You are a merge agent for "hai", an AI-orchestrated task board.
Your job is to finalize a squash merge: resolve any conflicts and write a good commit message.
All changes from the branch are squashed into a single commit.
@@ -23,27 +68,11 @@ If there are merge conflicts:
After all conflicts are resolved (or if there were none), write and execute the squash commit.
Look at the branch commits and diff to understand what was done, then run:
\`\`\`
git commit -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 squash brings in (imperative mood)
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
Example:
\`\`\`
git commit -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"
\`\`\`
${commitFormat}
Do NOT use generic messages like "merge branch" or "resolve conflicts".
Base the message on the ACTUAL work done in the branch commits.`;
}
/**
* Check if any non-done task (other than `excludeTaskId`) references the given
@@ -114,7 +143,11 @@ export async function aiMergeTask(
console.warn(`[merger] ${taskId}: no worktree path set — skipping worktree cleanup`);
}
// 2. Check branch exists
// 2. Read settings early (reused later for recycleWorktrees)
const settings = await store.getSettings();
const includeTaskId = settings.includeTaskIdInCommit !== false;
// 3. Check branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
cwd: rootDir,
@@ -200,7 +233,7 @@ export async function aiMergeTask(
const { session } = await createHaiAgent({
cwd: rootDir,
systemPrompt: MERGE_SYSTEM_PROMPT,
systemPrompt: buildMergeSystemPrompt(includeTaskId),
tools: "coding",
onText: agentLogger.onText,
onToolStart: agentLogger.onToolStart,
@@ -219,8 +252,9 @@ export async function aiMergeTask(
if (staged !== "0") {
console.log("[merger] Agent didn't commit — committing with fallback message");
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "feat(${taskId}): merge ${branch}" -m "${escapedLog}"`,
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
}
@@ -255,7 +289,7 @@ export async function aiMergeTask(
if (otherUser) {
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
result.worktreeRemoved = false;
} else if (options.pool && (await store.getSettings()).recycleWorktrees) {
} else if (options.pool && settings.recycleWorktrees) {
options.pool.release(worktreePath);
result.worktreeRemoved = false;
} else {