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:
@@ -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{" "}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
'',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user