feat(KB-036): add worktreeNaming setting for configurable worktree directory names

- Add worktreeNaming setting type with 'random' | 'task-id' | 'task-title' options
- Update executor to generate worktree names based on setting value
- Create worktree-names.ts utility module with generateWorktreeName function
- Add comprehensive executor tests for all naming strategies
- Update AGENTS.md documentation with setting description and usage
- Include changeset for patch release
This commit is contained in:
gsxdsm
2026-03-29 20:48:50 -07:00
parent 5e59c0419e
commit 919af827e0
7 changed files with 262 additions and 10 deletions

View File

@@ -15,9 +15,13 @@ vi.mock("./merger.js", async (importOriginal) => {
findWorktreeUser: vi.fn().mockResolvedValue(null),
};
});
vi.mock("./worktree-names.js", () => ({
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
}));
vi.mock("./worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
return {
...actual,
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
};
});
// Mock node modules used by executor
vi.mock("node:child_process", () => ({
@@ -33,7 +37,7 @@ import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
import { execSync } from "node:child_process";
import { findWorktreeUser, aiMergeTask } from "./merger.js";
import { WorktreePool } from "./worktree-pool.js";
import { generateWorktreeName } from "./worktree-names.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import type { Column, Task, TaskDetail } from "@kb/core";
const mockedCreateHaiAgent = vi.mocked(createKbAgent);
@@ -360,8 +364,8 @@ describe("TaskExecutor worktreeInitCommand", () => {
describe("TaskExecutor worktree naming", () => {
const makeTask = (id = "KB-030", worktree?: string) => ({
id,
title: "Test",
description: "Test",
title: "Test Task Title",
description: "Test description for task",
column: "in-progress" as const,
dependencies: [],
steps: [],
@@ -427,6 +431,158 @@ describe("TaskExecutor worktree naming", () => {
// Should NOT generate a new name — reuse the stored path
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
});
describe("worktreeNaming setting", () => {
it("uses task ID as worktree name when worktreeNaming is 'task-id'", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeNaming: "task-id",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask("KB-042"));
// Should use task ID (lowercase) as worktree name
expect(store.updateTask).toHaveBeenCalledWith("KB-042", {
worktree: "/tmp/test/.worktrees/kb-042",
});
// Should NOT call generateWorktreeName when using task-id
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
});
it("uses slugified task title as worktree name when worktreeNaming is 'task-title'", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeNaming: "task-title",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
...makeTask("KB-043"),
title: "Fix login bug with OAuth",
});
// Should use slugified title as worktree name
const expectedSlug = slugify("Fix login bug with OAuth");
expect(store.updateTask).toHaveBeenCalledWith("KB-043", {
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
});
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
});
it("falls back to description when title is empty for 'task-title' mode", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeNaming: "task-title",
});
const executor = new TaskExecutor(store, "/tmp/test");
const taskDescription = "Implement user authentication flow";
await executor.execute({
...makeTask("KB-044"),
title: "",
description: taskDescription,
});
// Should slugify the first 60 chars of description when title is empty
const expectedSlug = slugify(taskDescription.slice(0, 60));
expect(store.updateTask).toHaveBeenCalledWith("KB-044", {
worktree: `/tmp/test/.worktrees/${expectedSlug}`,
});
});
it("uses generateWorktreeName when worktreeNaming is 'random'", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeNaming: "random",
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask("KB-045"));
// Should use generateWorktreeName for random mode
expect(store.updateTask).toHaveBeenCalledWith("KB-045", {
worktree: "/tmp/test/.worktrees/swift-falcon",
});
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
});
it("defaults to random naming when worktreeNaming is undefined", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
// worktreeNaming is not set (undefined)
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask("KB-046"));
// Should default to random naming
expect(store.updateTask).toHaveBeenCalledWith("KB-046", {
worktree: "/tmp/test/.worktrees/swift-falcon",
});
expect(mockedGenerateWorktreeName).toHaveBeenCalledWith("/tmp/test");
});
it("ignores worktreeNaming setting when using pooled worktree (recycle mode)", async () => {
const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/pooled-warm-wt");
// Pool path exists on disk, task worktree path does not (not a resume)
mockedExistsSync.mockImplementation(
(p) => p === "/tmp/test/.worktrees/pooled-warm-wt",
);
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
recycleWorktrees: true,
worktreeNaming: "task-id", // This should be ignored for pooled worktrees
});
const executor = new TaskExecutor(store, "/tmp/test", { pool });
await executor.execute(makeTask("KB-047"));
// Should acquire from pool, ignoring the task-id naming preference
expect(store.updateTask).toHaveBeenCalledWith("KB-047", {
worktree: "/tmp/test/.worktrees/pooled-warm-wt",
});
// Should NOT call generateWorktreeName when using pooled worktree
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
// Should log pool acquisition
expect(store.logEntry).toHaveBeenCalledWith(
"KB-047",
expect.stringContaining("Acquired worktree from pool"),
);
});
});
});
describe("TaskExecutor dependency-based worktree creation", () => {

View File

@@ -3,7 +3,7 @@ import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings } from "@kb/core";
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName } from "./worktree-names.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createKbAgent } from "./pi.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
@@ -292,8 +292,32 @@ export class TaskExecutor {
executorLog.log(`Starting ${task.id}: ${task.title || task.description.slice(0, 60)}`);
// Fetch settings early — needed for worktree naming and later configuration
const settings = await this.store.getSettings();
// Hoist worktreePath so it's accessible in the catch block for dep-abort cleanup
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
// Determine worktree name based on settings
let worktreePath: string;
if (task.worktree) {
worktreePath = task.worktree;
} else {
const naming = settings.worktreeNaming || "random";
let worktreeName: string;
switch (naming) {
case "task-id":
worktreeName = task.id.toLowerCase();
break;
case "task-title":
worktreeName = slugify(task.title || task.description.slice(0, 60));
break;
case "random":
default:
worktreeName = generateWorktreeName(this.rootDir);
break;
}
worktreePath = join(this.rootDir, ".worktrees", worktreeName);
}
try {
// Check dependencies
@@ -314,7 +338,6 @@ export class TaskExecutor {
// instead of task.id, so worktrees are named like ".worktrees/swift-falcon"
let isResume = existsSync(worktreePath);
let acquiredFromPool = false;
const settings = await this.store.getSettings();
// Resolve the base branch — set by the scheduler when a dep is in-review
const baseBranch = task.baseBranch || null;

View File

@@ -28,6 +28,23 @@ export const NOUNS = [
"thorn", "tiger", "trail", "trout", "wren",
];
/**
* Convert a string to a URL-friendly slug.
*
* - Lowercase
* - Replace spaces, underscores, and special chars with hyphens
* - Collapse multiple hyphens
* - Trim leading/trailing hyphens
*/
export function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and hyphens
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Collapse multiple hyphens
.replace(/^-|-$/g, ""); // Trim leading/trailing hyphens
}
/**
* Generate a random, human-friendly worktree directory name.
*