feat: Auto-branch initiative system with per-project default branches

Planning tasks (research, discuss, plan, detail, refine) now run on
the project's defaultBranch instead of hardcoded 'main'. Execution
tasks (execute, verify, merge, review) auto-generate an initiative
branch (cw/<slug>) on first dispatch. Branch configuration removed
from initiative creation — it's now fully automatic.

- Add PLANNING_CATEGORIES/EXECUTION_CATEGORIES to branch-naming
- Dispatch manager splits logic by task category
- ProcessManager uses per-project defaultBranch fallback
- Phase dispatch uses project.defaultBranch for ensureBranch base
- Remove mergeTarget from createInitiative input
- Rename updateInitiativeMergeConfig → updateInitiativeConfig
- Add defaultBranch field to registerProject + UI
- Rename mergeTarget → branch across all frontend components
This commit is contained in:
Lukas May
2026-02-10 10:53:35 +01:00
parent 0407f05332
commit ca548c1eaa
13 changed files with 93 additions and 58 deletions

View File

@@ -33,7 +33,6 @@ export function CreateInitiativeDialog({
const [name, setName] = useState("");
const [projectIds, setProjectIds] = useState<string[]>([]);
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
const [mergeTarget, setMergeTarget] = useState("");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -48,7 +47,7 @@ export function CreateInitiativeDialog({
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeRequiresApproval: true,
mergeTarget: 'main',
branch: null,
projects: [],
};
utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]);
@@ -73,7 +72,6 @@ export function CreateInitiativeDialog({
setName("");
setProjectIds([]);
setExecutionMode("review_per_phase");
setMergeTarget("");
setError(null);
}
}, [open]);
@@ -85,7 +83,6 @@ export function CreateInitiativeDialog({
name: name.trim(),
projectIds: projectIds.length > 0 ? projectIds : undefined,
executionMode,
mergeTarget: mergeTarget.trim() || null,
});
}
@@ -123,20 +120,6 @@ export function CreateInitiativeDialog({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="merge-target">
Merge Target Branch{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Input
id="merge-target"
placeholder="e.g. feat/auth"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
Projects{" "}

View File

@@ -13,7 +13,7 @@ export interface InitiativeHeaderProps {
name: string;
status: string;
executionMode?: string;
mergeTarget?: string | null;
branch?: string | null;
};
projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void;
@@ -74,10 +74,10 @@ export function InitiativeHeader({
{initiative.executionMode === "yolo" ? "YOLO" : "REVIEW"}
</Badge>
)}
{initiative.mergeTarget && (
{initiative.branch && (
<Badge variant="outline" className="gap-1 text-[10px] font-mono">
<GitBranch className="h-3 w-3" />
{initiative.mergeTarget}
{initiative.branch}
</Badge>
)}
{!editing && projects && projects.length > 0 && (

View File

@@ -24,6 +24,7 @@ export function RegisterProjectDialog({
}: RegisterProjectDialogProps) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [defaultBranch, setDefaultBranch] = useState("main");
const [error, setError] = useState<string | null>(null);
const registerMutation = trpc.registerProject.useMutation({
@@ -40,6 +41,7 @@ export function RegisterProjectDialog({
if (open) {
setName("");
setUrl("");
setDefaultBranch("main");
setError(null);
}
}, [open]);
@@ -50,6 +52,7 @@ export function RegisterProjectDialog({
registerMutation.mutate({
name: name.trim(),
url: url.trim(),
defaultBranch: defaultBranch.trim() || undefined,
});
}
@@ -87,6 +90,15 @@ export function RegisterProjectDialog({
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="default-branch">Default Branch</Label>
<Input
id="default-branch"
placeholder="main"
value={defaultBranch}
onChange={(e) => setDefaultBranch(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button

View File

@@ -49,7 +49,7 @@ describe('writeInputFiles', () => {
name: 'Test Initiative',
status: 'active',
mergeRequiresApproval: true,
mergeTarget: 'main',
branch: 'cw/test-initiative',
executionMode: 'review_per_phase',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-02'),

View File

@@ -125,7 +125,7 @@ export function writeInputFiles(options: WriteInputFilesOptions): void {
name: ini.name,
status: ini.status,
mergeRequiresApproval: ini.mergeRequiresApproval,
mergeTarget: ini.mergeTarget,
branch: ini.branch,
},
'',
);

View File

@@ -222,7 +222,7 @@ export class MultiProviderAgentManager implements AgentManager {
let agentCwd: string;
if (initiativeId) {
log.debug({ alias, initiativeId, baseBranch, branchName }, 'creating initiative-based worktrees');
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch ?? 'main', branchName);
agentCwd = await this.processManager.createProjectWorktrees(alias, initiativeId, baseBranch, branchName);
// Log projects linked to the initiative
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);

View File

@@ -54,7 +54,7 @@ export class ProcessManager {
async createProjectWorktrees(
alias: string,
initiativeId: string,
baseBranch: string = 'main',
baseBranch?: string,
branchName?: string,
): Promise<string> {
const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId);
@@ -78,7 +78,8 @@ export class ProcessManager {
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
const worktreeManager = new SimpleGitWorktreeManager(clonePath, undefined, agentWorkdir);
const worktree = await worktreeManager.create(project.name, branchName ?? `agent/${alias}`, baseBranch);
const effectiveBaseBranch = baseBranch ?? project.defaultBranch;
const worktree = await worktreeManager.create(project.name, branchName ?? `agent/${alias}`, effectiveBaseBranch);
const worktreePath = worktree.path;
const pathExists = existsSync(worktreePath);

View File

@@ -22,7 +22,7 @@ import type { InitiativeRepository } from '../db/repositories/initiative-reposit
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
import type { Task } from '../db/schema.js';
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { phaseBranchName, taskBranchName, isPlanningCategory, generateInitiativeBranch } from '../git/branch-naming.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('dispatch');
@@ -327,13 +327,23 @@ export class DefaultDispatchManager implements DispatchManager {
let baseBranch: string | undefined;
let branchName: string | undefined;
if (task.phaseId && task.initiativeId && this.initiativeRepository && this.phaseRepository) {
if (task.initiativeId && this.initiativeRepository) {
try {
if (isPlanningCategory(task.category)) {
// Planning tasks run on project default branches — no initiative branch needed.
// baseBranch and branchName remain undefined; ProcessManager uses per-project defaults.
} else if (task.phaseId && this.phaseRepository) {
// Execution task — ensure initiative has a branch
const initiative = await this.initiativeRepository.findById(task.initiativeId);
const phase = await this.phaseRepository.findById(task.phaseId);
if (initiative?.mergeTarget && phase) {
const initBranch = initiativeBranchName(initiative.mergeTarget);
if (initiative) {
let initBranch = initiative.branch;
if (!initBranch) {
initBranch = generateInitiativeBranch(initiative.name);
await this.initiativeRepository.update(initiative.id, { branch: initBranch });
}
const phase = await this.phaseRepository.findById(task.phaseId);
if (phase) {
if (task.category === 'merge') {
// Merge tasks work directly on the phase branch
baseBranch = initBranch;
@@ -343,6 +353,8 @@ export class DefaultDispatchManager implements DispatchManager {
branchName = taskBranchName(initBranch, task.id);
}
}
}
}
} catch {
// Non-fatal: fall back to default branching
}

View File

@@ -20,7 +20,7 @@ import type { InitiativeRepository } from '../db/repositories/initiative-reposit
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { BranchManager } from '../git/branch-manager.js';
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
import { initiativeBranchName, phaseBranchName } from '../git/branch-naming.js';
import { phaseBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
@@ -172,13 +172,13 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
if (this.initiativeRepository && this.projectRepository && this.branchManager && this.workspaceRoot) {
try {
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (initiative?.mergeTarget) {
const initBranch = initiativeBranchName(initiative.mergeTarget);
if (initiative?.branch) {
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
await this.branchManager.ensureBranch(clonePath, initBranch, 'main');
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);
await this.branchManager.ensureBranch(clonePath, phBranch, initBranch);
}
log.info({ phaseId: nextPhase.phaseId, phBranch, initBranch }, 'phase branch created');

View File

@@ -19,7 +19,7 @@ import type { InitiativeRepository } from '../db/repositories/initiative-reposit
import type { ProjectRepository } from '../db/repositories/project-repository.js';
import type { PhaseDispatchManager } from '../dispatch/types.js';
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
import { initiativeBranchName, phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { phaseBranchName, taskBranchName } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import { createModuleLogger } from '../logger/index.js';
@@ -64,7 +64,7 @@ export class ExecutionOrchestrator {
if (!task?.phaseId || !task.initiativeId) return;
const initiative = await this.initiativeRepository.findById(task.initiativeId);
if (!initiative?.mergeTarget) return;
if (!initiative?.branch) return;
const phase = await this.phaseRepository.findById(task.phaseId);
if (!phase) return;
@@ -72,7 +72,7 @@ export class ExecutionOrchestrator {
// Skip merge tasks — they already work on the phase branch directly
if (task.category === 'merge') return;
const initBranch = initiativeBranchName(initiative.mergeTarget);
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const tBranch = taskBranchName(initBranch, task.id);
@@ -149,7 +149,7 @@ export class ExecutionOrchestrator {
if (!phase) return;
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative?.mergeTarget) return;
if (!initiative?.branch) return;
if (initiative.executionMode === 'yolo') {
await this.mergePhaseIntoInitiative(phaseId);
@@ -178,9 +178,9 @@ export class ExecutionOrchestrator {
if (!phase) return;
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
if (!initiative?.mergeTarget) return;
if (!initiative?.branch) return;
const initBranch = initiativeBranchName(initiative.mergeTarget);
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);

View File

@@ -5,6 +5,23 @@
* in the initiative → phase → task branch hierarchy.
*/
/**
* Task categories that run on the project's default branch (no initiative branch needed).
*/
export const PLANNING_CATEGORIES = ['research', 'discuss', 'plan', 'detail', 'refine'] as const;
/**
* Task categories that require an initiative branch.
*/
export const EXECUTION_CATEGORIES = ['execute', 'verify', 'merge', 'review'] as const;
/**
* Check if a task category is a planning category (runs on default branch).
*/
export function isPlanningCategory(category: string): boolean {
return (PLANNING_CATEGORIES as readonly string[]).includes(category);
}
/**
* Convert a name to a URL/branch-safe slug.
* Lowercase, replace non-alphanumeric runs with single hyphens, trim hyphens.
@@ -17,11 +34,19 @@ export function slugify(name: string): string {
}
/**
* Compute the initiative branch name.
* Returns the mergeTarget as-is (it's already a branch name), or 'main' if unset.
* Generate an initiative branch name from the initiative name.
* Format: `cw/<slugified-name>`
*/
export function initiativeBranchName(mergeTarget: string | null): string {
return mergeTarget ?? 'main';
export function generateInitiativeBranch(name: string): string {
return `cw/${slugify(name)}`;
}
/**
* Compute the initiative branch name.
* Returns the branch as-is if set, or null if unset.
*/
export function initiativeBranchName(branch: string | null): string | null {
return branch;
}
/**

View File

@@ -14,7 +14,6 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
name: z.string().min(1),
projectIds: z.array(z.string().min(1)).min(1).optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
mergeTarget: z.string().nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
@@ -36,7 +35,6 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
name: input.name,
status: 'active',
...(input.executionMode && { executionMode: input.executionMode }),
...(input.mergeTarget !== undefined && { mergeTarget: input.mergeTarget }),
});
if (input.projectIds && input.projectIds.length > 0) {
@@ -102,11 +100,10 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return repo.update(id, data);
}),
updateInitiativeMergeConfig: publicProcedure
updateInitiativeConfig: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
mergeRequiresApproval: z.boolean().optional(),
mergeTarget: z.string().nullable().optional(),
executionMode: z.enum(['yolo', 'review_per_phase']).optional(),
}))
.mutation(async ({ ctx, input }) => {

View File

@@ -17,13 +17,18 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
.input(z.object({
name: z.string().min(1),
url: z.string().min(1),
defaultBranch: z.string().min(1).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireProjectRepository(ctx);
let project;
try {
project = await repo.create({ name: input.name, url: input.url });
project = await repo.create({
name: input.name,
url: input.url,
...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
});
} catch (error) {
const msg = (error as Error).message;
if (msg.includes('UNIQUE') || msg.includes('unique')) {