feat(HAI-002): create worktree grouping utility

This commit is contained in:
Dustin Byrne
2026-03-25 18:52:40 -04:00
parent cd5223e9f3
commit 16458c59b5

View File

@@ -0,0 +1,102 @@
import type { Task } from "@hai/core";
import { resolveDependencyOrder } from "@hai/core";
export interface WorktreeGroupData {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
}
/**
* Extract a clean display name from a worktree path.
* e.g. ".worktrees/HAI-001" → "HAI-001", "/path/to/hai/hai-001" → "hai-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
// Take the last segment of the path
const segments = worktreePath.replace(/\/+$/, "").split("/");
return segments[segments.length - 1] || worktreePath;
}
/**
* Group in-progress tasks by worktree and distribute queued todo tasks
* as visual previews across the worktree groups.
*/
export function groupByWorktree(
inProgressTasks: Task[],
allTasks: Task[],
maxConcurrent: number,
): WorktreeGroupData[] {
// Separate assigned vs unassigned in-progress tasks
const assigned = inProgressTasks.filter((t) => t.worktree);
const unassigned = inProgressTasks.filter((t) => !t.worktree);
// Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>();
for (const task of assigned) {
const key = task.worktree!;
const list = worktreeMap.get(key) || [];
list.push(task);
worktreeMap.set(key, list);
}
// Find queued todo tasks: "todo" tasks with all deps satisfied (done or in-review)
const taskById = new Map(allTasks.map((t) => [t.id, t]));
const todoTasks = allTasks.filter((t) => t.column === "todo");
const eligible = todoTasks.filter((t) =>
t.dependencies.every((depId) => {
const dep = taskById.get(depId);
return dep && (dep.column === "done" || dep.column === "in-review");
}),
);
// Order eligible tasks by dependency order
const orderedIds = resolveDependencyOrder(eligible);
const orderedEligible = orderedIds
.map((id) => taskById.get(id))
.filter((t): t is Task => t !== undefined && eligible.includes(t));
// Build groups from worktree map
const groups: WorktreeGroupData[] = [];
const worktreeKeys = Array.from(worktreeMap.keys());
for (const key of worktreeKeys) {
groups.push({
label: getWorktreeLabel(key),
activeTasks: worktreeMap.get(key)!,
queuedTasks: [],
});
}
// Add unassigned group if needed
if (unassigned.length > 0) {
groups.push({
label: "Unassigned",
activeTasks: unassigned,
queuedTasks: [],
});
}
// Distribute queued tasks round-robin across active worktree groups (one per group)
const activeGroups = groups.filter((g) => g.label !== "Unassigned");
let queueIdx = 0;
if (activeGroups.length > 0 && orderedEligible.length > 0) {
for (let i = 0; i < activeGroups.length && queueIdx < orderedEligible.length; i++) {
activeGroups[i].queuedTasks.push(orderedEligible[queueIdx++]);
}
}
// Remaining queued tasks go into "Up Next" overflow group
if (queueIdx < orderedEligible.length) {
const remaining = orderedEligible.slice(queueIdx, queueIdx + maxConcurrent);
if (remaining.length > 0) {
groups.push({
label: "Up Next",
activeTasks: [],
queuedTasks: remaining,
});
}
}
return groups;
}