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

@@ -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,20 +327,32 @@ 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 {
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 (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);
if (initiative) {
let initBranch = initiative.branch;
if (!initBranch) {
initBranch = generateInitiativeBranch(initiative.name);
await this.initiativeRepository.update(initiative.id, { branch: initBranch });
}
if (task.category === 'merge') {
// Merge tasks work directly on the phase branch
baseBranch = initBranch;
branchName = phaseBranchName(initBranch, phase.name);
} else {
baseBranch = phaseBranchName(initBranch, phase.name);
branchName = taskBranchName(initBranch, task.id);
const phase = await this.phaseRepository.findById(task.phaseId);
if (phase) {
if (task.category === 'merge') {
// Merge tasks work directly on the phase branch
baseBranch = initBranch;
branchName = phaseBranchName(initBranch, phase.name);
} else {
baseBranch = phaseBranchName(initBranch, phase.name);
branchName = taskBranchName(initBranch, task.id);
}
}
}
}
} catch {

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')) {