feat(HAI-078): add configurable task prefix to settings

- Add taskPrefix field to Settings type and use it in allocateId
- Update store listTasks and handleFsChange to support any task prefix
- Update triage duplicate regex to match configurable prefix
- Add Task Prefix input to dashboard SettingsModal UI
- Add tests for prefix-aware task allocation and settings modal
This commit is contained in:
Dustin Byrne
2026-03-26 00:48:38 -04:00
parent 40099be89e
commit 5dea073d22
6 changed files with 143 additions and 10 deletions

View File

@@ -401,6 +401,42 @@ describe("TaskStore", () => {
});
});
// ── Task prefix tests ──────────────────────────────────────────
describe("taskPrefix setting", () => {
it("default prefix produces HAI-001 IDs", async () => {
const task = await store.createTask({ description: "Default prefix" });
expect(task.id).toBe("HAI-001");
});
it("custom prefix produces PROJ-001 IDs", async () => {
await store.updateSettings({ taskPrefix: "PROJ" });
const task = await store.createTask({ description: "Custom prefix" });
expect(task.id).toBe("PROJ-001");
});
it("prefix change mid-stream continues sequence", async () => {
const t1 = await store.createTask({ description: "First" });
const t2 = await store.createTask({ description: "Second" });
expect(t1.id).toBe("HAI-001");
expect(t2.id).toBe("HAI-002");
await store.updateSettings({ taskPrefix: "PROJ" });
const t3 = await store.createTask({ description: "Third" });
expect(t3.id).toBe("PROJ-003");
});
it("listTasks returns tasks regardless of prefix", async () => {
await store.createTask({ description: "HAI task" });
await store.updateSettings({ taskPrefix: "PROJ" });
await store.createTask({ description: "PROJ task" });
const tasks = await store.listTasks();
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.id).sort()).toEqual(["HAI-001", "PROJ-002"]);
});
});
describe("agent log persistence", () => {
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
const task = await createTestTask();

View File

@@ -160,7 +160,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
private async allocateId(): Promise<string> {
return this.withConfigLock(async () => {
const config = await this.readConfig();
const id = `HAI-${String(config.nextId).padStart(3, "0")}`;
const prefix = config.settings?.taskPrefix || "HAI";
const id = `${prefix}-${String(config.nextId).padStart(3, "0")}`;
config.nextId++;
await this.writeConfig(config);
return id;
@@ -235,7 +236,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const tasks: Task[] = [];
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith("HAI-")) {
if (entry.isDirectory() && /^[A-Z]+-\d+$/.test(entry.name)) {
try {
tasks.push(await this.readTaskJson(join(this.tasksDir, entry.name)));
} catch {
@@ -655,7 +656,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const taskId = normalizedParts[0];
const file = normalizedParts[normalizedParts.length - 1];
if (file !== "task.json") return;
if (!taskId.startsWith("HAI-")) return;
if (!/^[A-Z]+-\d+$/.test(taskId)) return;
const fullPath = join(this.tasksDir, taskId, "task.json");

View File

@@ -87,6 +87,10 @@ 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;
/** Prefix for generated task IDs (e.g. `"HAI"` produces `HAI-001`).
* Defaults to `"HAI"`. Only affects new tasks — existing tasks retain
* their original IDs. */
taskPrefix?: string;
}
export const DEFAULT_SETTINGS: Settings = {
@@ -97,6 +101,7 @@ export const DEFAULT_SETTINGS: Settings = {
autoMerge: false,
worktreeInitCommand: undefined,
recycleWorktrees: false,
taskPrefix: undefined,
};
export interface BoardConfig {

View File

@@ -12,6 +12,7 @@ import type { ToastType } from "../hooks/useToast";
* 2. Add a corresponding case in renderSectionFields()
*/
const SETTINGS_SECTIONS = [
{ id: "general", label: "General" },
{ id: "scheduling", label: "Scheduling" },
{ id: "worktrees", label: "Worktrees" },
{ id: "commands", label: "Commands" },
@@ -29,6 +30,7 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
const [form, setForm] = useState<Settings & { worktreeInitCommand?: string }>({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, recycleWorktrees: false, worktreeInitCommand: "" });
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<SectionId>(SETTINGS_SECTIONS[0].id);
const [prefixError, setPrefixError] = useState<string | null>(null);
useEffect(() => {
fetchSettings()
@@ -58,10 +60,12 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
);
const handleSave = useCallback(async () => {
if (prefixError) return;
try {
const payload = {
...form,
worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined,
taskPrefix: form.taskPrefix?.trim() || undefined,
};
await updateSettings(payload);
addToast("Settings saved", "success");
@@ -69,10 +73,36 @@ export function SettingsModal({ onClose, addToast }: SettingsModalProps) {
} catch (err: any) {
addToast(err.message, "error");
}
}, [form, onClose, addToast]);
}, [form, prefixError, onClose, addToast]);
const renderSectionFields = () => {
switch (activeSection) {
case "general":
return (
<>
<h4 className="settings-section-heading">General</h4>
<div className="form-group">
<label htmlFor="taskPrefix">Task Prefix</label>
<input
id="taskPrefix"
type="text"
placeholder="HAI"
value={form.taskPrefix || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, taskPrefix: val || undefined }));
if (val && !/^[A-Z]{1,10}$/.test(val)) {
setPrefixError("Prefix must be 1–10 uppercase letters");
} else {
setPrefixError(null);
}
}}
/>
{prefixError && <small className="field-error" style={{ color: "var(--color-error, #e74c3c)" }}>{prefixError}</small>}
{!prefixError && <small>Prefix for new task IDs (e.g. HAI, PROJ)</small>}
</div>
</>
);
case "scheduling":
return (
<>

View File

@@ -35,6 +35,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Each label appears in the sidebar nav
expect(screen.getAllByText("General").length).toBeGreaterThanOrEqual(1);
const nav = screen.getAllByText("Scheduling");
expect(nav.length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Worktrees").length).toBeGreaterThanOrEqual(1);
@@ -42,21 +43,25 @@ describe("SettingsModal", () => {
expect(screen.getAllByText("Merge").length).toBeGreaterThanOrEqual(1);
});
it("shows Scheduling fields by default", async () => {
it("shows General fields by default", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Fields from other sections should not be visible
expect(screen.queryByLabelText("Max Concurrent Tasks")).toBeNull();
expect(screen.queryByLabelText("Max Worktrees")).toBeNull();
expect(screen.queryByLabelText("Test Command")).toBeNull();
});
it("switches section when clicking sidebar item", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.queryByLabelText("Task Prefix")).toBeNull();
// Click Commands
fireEvent.click(screen.getByText("Commands"));
expect(screen.getByLabelText("Test Command")).toBeTruthy();
@@ -68,7 +73,11 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Scheduling (default)
// General (default)
expect(screen.getByLabelText("Task Prefix")).toBeTruthy();
// Scheduling
fireEvent.click(screen.getByText("Scheduling"));
expect(screen.getByLabelText("Max Concurrent Tasks")).toBeTruthy();
expect(screen.getByLabelText("Poll Interval (ms)")).toBeTruthy();
@@ -113,10 +122,62 @@ describe("SettingsModal", () => {
expect(payload.recycleWorktrees).toBe(true);
});
it("Task Prefix field saves correctly when set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "PROJ" } });
expect(input.value).toBe("PROJ");
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.taskPrefix).toBe("PROJ");
});
it("Task Prefix field submits undefined when empty (uses default)", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.taskPrefix).toBeUndefined();
});
it("Task Prefix shows validation error for invalid input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "bad" } });
expect(screen.getByText("Prefix must be 1–10 uppercase letters")).toBeTruthy();
});
it("Task Prefix validation error prevents save", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const input = screen.getByLabelText("Task Prefix") as HTMLInputElement;
fireEvent.change(input, { target: { value: "bad" } });
fireEvent.click(screen.getByText("Save"));
// Should not have called updateSettings due to validation error
expect(updateSettings).not.toHaveBeenCalled();
});
it("groupOverlappingFiles input has type checkbox", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const checkbox = screen.getByLabelText("Serialize tasks with overlapping files");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");

View File

@@ -275,7 +275,7 @@ export class TriageProcessor {
const written = await readFile(
join(this.rootDir, promptPath), "utf-8",
).catch(() => "");
const dupMatch = written.match(/^DUPLICATE:\s*(HAI-\d+)/i);
const dupMatch = written.match(/^DUPLICATE:\s*([A-Z]+-\d+)/i);
if (dupMatch) {
const dupId = dupMatch[1];