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 1d2706351b
commit 6f43bdcdf4
7 changed files with 262 additions and 10 deletions

View File

@@ -201,6 +201,13 @@ export interface Settings {
* of being deleted. New tasks acquire a warm worktree from the pool,
* preserving build caches (node_modules, target/, dist/). Default: false. */
recycleWorktrees?: boolean;
/** Controls how worktree directory names are generated when creating fresh worktrees.
* Only applies when recycleWorktrees is NOT enabled (pooled worktrees retain their existing names).
* - "random": Human-friendly adjective-noun names (e.g., swift-falcon) — default
* - "task-id": Use the task ID (e.g., kb-042)
* - "task-title": Use a slugified version of the task title (e.g., fix-login-bug)
* Default: "random". */
worktreeNaming?: "random" | "task-id" | "task-title";
/** Prefix for generated task IDs (e.g. `"KB"` produces `KB-001`).
* Defaults to `"KB"`. Only affects new tasks — existing tasks retain
* their original IDs. */
@@ -261,6 +268,7 @@ export const DEFAULT_SETTINGS: Settings = {
autoMerge: true,
worktreeInitCommand: undefined,
recycleWorktrees: false,
worktreeNaming: "random",
taskPrefix: undefined,
includeTaskIdInCommit: true,
defaultProvider: undefined,

View File

@@ -63,7 +63,7 @@ export function SettingsModal({
onThemeModeChange,
onColorThemeChange,
}: SettingsModalProps) {
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, includeTaskIdInCommit: true, worktreeInitCommand: "" });
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: true, recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "" });
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<SectionId>(initialSection ?? SETTINGS_SECTIONS[0].id);
const [prefixError, setPrefixError] = useState<string | null>(null);
@@ -425,6 +425,26 @@ export function SettingsModal({
</label>
<small>When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup</small>
</div>
<div className="form-group">
<label htmlFor="worktreeNaming">Worktree Naming Style</label>
<select
id="worktreeNaming"
value={form.worktreeNaming || "random"}
onChange={(e) =>
setForm((f) => ({ ...f, worktreeNaming: e.target.value as "random" | "task-id" | "task-title" }))
}
disabled={form.recycleWorktrees}
>
<option value="random">Random names (e.g., swift-falcon)</option>
<option value="task-id">Task ID (e.g., kb-042)</option>
<option value="task-title">Task title (e.g., fix-login-bug)</option>
</select>
<small>
{form.recycleWorktrees
? "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names"
: "How to name fresh worktree directories. Only applies when recycling is off."}
</small>
</div>
</>
);
case "commands":

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.
*