feat(team): add teammate worktree isolation controls

This commit is contained in:
777genius 2026-04-21 22:35:18 +03:00
parent 28b64ec467
commit 339fb072e5
29 changed files with 952 additions and 63 deletions

View file

@ -1274,6 +1274,10 @@ async function validateProvisioningRequest(
if (workflow !== undefined && typeof workflow !== 'string') {
return { valid: false, error: 'member workflow must be string' };
}
const isolation = (member as { isolation?: unknown }).isolation;
if (isolation !== undefined && isolation !== 'worktree') {
return { valid: false, error: 'member isolation must be "worktree" when provided' };
}
const providerValidation = parseOptionalMemberProviderId(
(member as { providerId?: unknown }).providerId
);
@ -1295,6 +1299,7 @@ async function validateProvisioningRequest(
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
@ -1572,6 +1577,7 @@ async function handleLaunchTeam(
name: m.name,
role: m.role,
workflow: m.workflow,
isolation: m.isolation,
providerId: m.providerId,
model: m.model,
effort: m.effort,
@ -2760,6 +2766,10 @@ async function handleCreateConfig(
if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'member workflow must be string' };
}
const isolation = (member as { isolation?: unknown }).isolation;
if (isolation !== undefined && isolation !== 'worktree') {
return { success: false, error: 'member isolation must be "worktree" when provided' };
}
const providerValidation = parseOptionalMemberProviderId(
(member as { providerId?: unknown }).providerId
);
@ -2781,6 +2791,7 @@ async function handleCreateConfig(
name: memberName,
role: typeof role === 'string' ? role.trim() : undefined,
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
@ -3189,10 +3200,11 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
const { name, role, workflow, providerId, model } = payload as {
const { name, role, workflow, isolation, providerId, model } = payload as {
name?: unknown;
role?: unknown;
workflow?: unknown;
isolation?: unknown;
providerId?: unknown;
model?: unknown;
effort?: unknown;
@ -3205,6 +3217,9 @@ async function handleAddMember(
if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'workflow must be a string' };
}
if (isolation !== undefined && isolation !== 'worktree') {
return { success: false, error: 'isolation must be "worktree" when provided' };
}
const providerValidation = parseOptionalMemberProviderId(providerId);
if (!providerValidation.valid) {
return { success: false, error: providerValidation.error };
@ -3227,6 +3242,7 @@ async function handleAddMember(
name: memberName,
role: role,
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
model: typeof model === 'string' ? model.trim() || undefined : undefined,
effort: effortValidation.value,
@ -3252,6 +3268,7 @@ async function handleAddMember(
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
...(isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(providerValidation.value ? { providerId: providerValidation.value } : {}),
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...(effortValidation.value ? { effort: effortValidation.value } : {}),
@ -3285,6 +3302,7 @@ async function handleReplaceMembers(
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -3297,6 +3315,7 @@ async function handleReplaceMembers(
name?: unknown;
role?: unknown;
workflow?: unknown;
isolation?: unknown;
providerId?: unknown;
model?: unknown;
effort?: unknown;
@ -3312,6 +3331,9 @@ async function handleReplaceMembers(
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
return { success: false, error: 'member workflow must be string' };
}
if (m.isolation !== undefined && m.isolation !== 'worktree') {
return { success: false, error: 'member isolation must be "worktree" when provided' };
}
const providerValidation = parseOptionalMemberProviderId(
(m as { providerId?: unknown }).providerId
);
@ -3332,6 +3354,7 @@ async function handleReplaceMembers(
name,
role: typeof m.role === 'string' ? m.role.trim() : undefined,
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: providerValidation.value,
model: typeof m.model === 'string' ? m.model.trim() || undefined : undefined,
effort: effortValidation.value,

View file

@ -98,6 +98,10 @@ interface LedgerEvent {
operation: 'create' | 'modify' | 'delete';
confidence: LedgerConfidence;
workspaceRoot: string;
worktreePath?: string;
worktreeBranch?: string;
baseWorkspaceRoot?: string;
dirtyLeaderWarning?: string;
filePath: string;
relativePath: string;
timestamp: string;
@ -192,6 +196,10 @@ interface LedgerSummaryScopeV2 {
confidenceBreakdown?: TaskChangeScope['confidenceBreakdown'];
visibleFileCount: number;
contributors: LedgerSummaryContributorV2[];
worktreePaths?: string[];
worktreeBranches?: string[];
baseWorkspaceRoots?: string[];
dirtyLeaderWarnings?: string[];
}
interface LedgerSummaryFileV2 {
@ -217,6 +225,10 @@ interface LedgerSummaryFileV2 {
contentAvailability: 'full-text' | 'hash-only' | 'metadata-only';
reviewability: 'full-text' | 'partial-text' | 'metadata-only';
relation?: LedgerChangeRelation;
worktreePath?: string;
worktreeBranch?: string;
baseWorkspaceRoot?: string;
dirtyLeaderWarning?: string;
primaryActorKey?: string;
agentIds: string[];
memberNames?: string[];
@ -785,7 +797,11 @@ export class TaskChangeLedgerReader {
if (params.bundle) {
files = params.bundle.files.map((file) => {
const groupKey = this.groupKeyForFileSummary(file.filePath, file.relation);
const groupKey = this.groupKeyForFileSummary(
file.filePath,
file.relation,
file.worktreePath
);
const entry = groupedSnippets.get(groupKey);
return {
...this.mapV2SummaryFile(file, params.projectPath),
@ -968,13 +984,17 @@ export class TaskChangeLedgerReader {
...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}),
...(file.memberNames ? { memberNames: file.memberNames } : {}),
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
...(file.worktreeBranch ? { worktreeBranch: file.worktreeBranch } : {}),
...(file.baseWorkspaceRoot ? { baseWorkspaceRoot: file.baseWorkspaceRoot } : {}),
...(file.dirtyLeaderWarning ? { dirtyLeaderWarning: file.dirtyLeaderWarning } : {}),
},
};
}
private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string {
if (file.relation) {
return `${file.relation.kind}:${normalizePathForComparison(file.relation.oldPath)}->${normalizePathForComparison(file.relation.newPath)}`;
return this.relationChangeKey(file.relation, file.worktreePath);
}
const slashNormalized = file.changeKey.replace(/\\/g, '/');
const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized);
@ -1015,6 +1035,10 @@ export class TaskChangeLedgerReader {
...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}),
...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}),
...(scope.contributors ? { contributors: scope.contributors } : {}),
...(scope.worktreePaths ? { worktreePaths: scope.worktreePaths } : {}),
...(scope.worktreeBranches ? { worktreeBranches: scope.worktreeBranches } : {}),
...(scope.baseWorkspaceRoots ? { baseWorkspaceRoots: scope.baseWorkspaceRoots } : {}),
...(scope.dirtyLeaderWarnings ? { dirtyLeaderWarnings: scope.dirtyLeaderWarnings } : {}),
};
}
@ -1076,6 +1100,10 @@ export class TaskChangeLedgerReader {
executionSeq: event.executionSeq,
linesAdded: event.linesAdded,
linesRemoved: event.linesRemoved,
worktreePath: event.worktreePath,
worktreeBranch: event.worktreeBranch,
baseWorkspaceRoot: event.baseWorkspaceRoot,
dirtyLeaderWarning: event.dirtyLeaderWarning,
textAvailability:
beforeContent !== null && afterContent !== null
? 'full-text'
@ -1169,6 +1197,7 @@ export class TaskChangeLedgerReader {
linesRemoved += removed;
}
const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets);
const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger;
files.push({
filePath: displayPath,
relativePath: this.relativePath(displayPath, projectPath),
@ -1181,7 +1210,7 @@ export class TaskChangeLedgerReader {
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create'
),
changeKey: relation
? `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`
? this.relationChangeKey(relation, worktreeLedger?.worktreePath)
: `path:${normalizePathForComparison(displayPath)}`,
diffStatKnown: true,
ledgerSummary: {
@ -1189,6 +1218,16 @@ export class TaskChangeLedgerReader {
latestOperation:
entry.snippets[entry.snippets.length - 1]?.ledger?.operation ??
(entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'),
...(worktreeLedger?.worktreePath ? { worktreePath: worktreeLedger.worktreePath } : {}),
...(worktreeLedger?.worktreeBranch
? { worktreeBranch: worktreeLedger.worktreeBranch }
: {}),
...(worktreeLedger?.baseWorkspaceRoot
? { baseWorkspaceRoot: worktreeLedger.baseWorkspaceRoot }
: {}),
...(worktreeLedger?.dirtyLeaderWarning
? { dirtyLeaderWarning: worktreeLedger.dirtyLeaderWarning }
: {}),
},
timeline: this.buildTimeline(displayPath, entry.snippets),
});
@ -1206,6 +1245,22 @@ export class TaskChangeLedgerReader {
): TaskChangeScope {
const primaryMemberName = events.find((event) => event.memberName)?.memberName;
const primaryAgentId = events.find((event) => event.agentId)?.agentId;
const worktreePaths = [
...new Set(events.flatMap((event) => (event.worktreePath ? [event.worktreePath] : []))),
].sort();
const worktreeBranches = [
...new Set(events.flatMap((event) => (event.worktreeBranch ? [event.worktreeBranch] : []))),
].sort();
const baseWorkspaceRoots = [
...new Set(
events.flatMap((event) => (event.baseWorkspaceRoot ? [event.baseWorkspaceRoot] : []))
),
].sort();
const dirtyLeaderWarnings = [
...new Set(
events.flatMap((event) => (event.dirtyLeaderWarning ? [event.dirtyLeaderWarning] : []))
),
].sort();
return {
taskId,
memberName: primaryMemberName ?? primaryAgentId ?? '',
@ -1240,6 +1295,10 @@ export class TaskChangeLedgerReader {
},
}
: {}),
...(worktreePaths.length > 0 ? { worktreePaths } : {}),
...(worktreeBranches.length > 0 ? { worktreeBranches } : {}),
...(baseWorkspaceRoots.length > 0 ? { baseWorkspaceRoots } : {}),
...(dirtyLeaderWarnings.length > 0 ? { dirtyLeaderWarnings } : {}),
};
}
@ -1310,16 +1369,31 @@ export class TaskChangeLedgerReader {
}
private groupKeyForSnippet(snippet: SnippetDiff): string {
return this.groupKeyForFileSummary(snippet.filePath, snippet.ledger?.relation);
return this.groupKeyForFileSummary(
snippet.filePath,
snippet.ledger?.relation,
snippet.ledger?.worktreePath
);
}
private groupKeyForFileSummary(filePath: string, relation?: LedgerChangeRelation): string {
private groupKeyForFileSummary(
filePath: string,
relation?: LedgerChangeRelation,
worktreePath?: string
): string {
if (relation) {
return `${relation.kind}:${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`;
return this.relationChangeKey(relation, worktreePath);
}
return `path:${normalizePathForComparison(filePath)}`;
}
private relationChangeKey(relation: LedgerChangeRelation, worktreePath?: string): string {
const pathPart = `${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`;
return worktreePath
? `${relation.kind}:${normalizePathForComparison(worktreePath)}:${pathPart}`
: `${relation.kind}:${pathPart}`;
}
private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
}

View file

@ -1216,6 +1216,7 @@ export class TeamDataService {
name: configMember.name.trim(),
role: configMember.role,
workflow: configMember.workflow,
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
agentType: configMember.agentType ?? 'general-purpose',
color: configMember.color,
joinedAt: configMember.joinedAt ?? Date.now(),
@ -1277,6 +1278,7 @@ export class TeamDataService {
name,
role: request.role?.trim() || undefined,
workflow: request.workflow?.trim() || undefined,
isolation: request.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId:
request.providerId === 'codex' || request.providerId === 'gemini'
? request.providerId
@ -1316,6 +1318,7 @@ export class TeamDataService {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: TeamMember['effort'];
@ -1358,6 +1361,7 @@ export class TeamDataService {
name,
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
@ -2435,6 +2439,7 @@ export class TeamDataService {
})(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,

View file

@ -128,6 +128,7 @@ export class TeamMemberResolver {
agentType?: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: TeamMember['effort'];
@ -147,6 +148,7 @@ export class TeamMemberResolver {
agentType: configMember.agentType,
role: configMember.role,
workflow: configMember.workflow,
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId,
model: configMember.model,
effort: configMember.effort,
@ -164,6 +166,7 @@ export class TeamMemberResolver {
agentType?: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: TeamMember['effort'];
@ -179,6 +182,7 @@ export class TeamMemberResolver {
agentType: member.agentType,
role: member.role,
workflow: member.workflow,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
model: member.model,
effort: member.effort,
@ -232,6 +236,7 @@ export class TeamMemberResolver {
agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow,
isolation: configMember?.isolation ?? metaMember?.isolation,
providerId: configMember?.providerId ?? metaMember?.providerId,
model: configMember?.model ?? metaMember?.model,
effort: configMember?.effort ?? metaMember?.effort,

View file

@ -35,6 +35,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
name: trimmedName,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,

View file

@ -1203,7 +1203,7 @@ interface PendingMemberRestartContext {
requestedAt: string;
desired: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
'name' | 'role' | 'workflow' | 'isolation' | 'providerId' | 'model' | 'effort'
>;
}
@ -1650,10 +1650,11 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
: '';
const modelPart = member.model?.trim() ? ` [model: ${member.model.trim()}]` : '';
const effortPart = member.effort ? ` [effort: ${member.effort}]` : '';
const isolationPart = member.isolation === 'worktree' ? ' [isolation: worktree]' : '';
const workflowPart = member.workflow?.trim()
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
: '';
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${workflowPart}`;
return `- ${member.name}${rolePart}${providerPart}${modelPart}${effortPart}${isolationPart}${workflowPart}`;
})
.join('\n');
}
@ -2000,13 +2001,29 @@ ${indentMultiline(buildMemberReviewFlowReminder(), ' ')}
- If you have no tasks, wait for new assignments.`;
}
function buildAgentToolArgsSuffix(
member: Pick<
TeamCreateRequest['members'][number],
'providerId' | 'model' | 'effort' | 'isolation'
>
): string {
const providerPart =
member.providerId && member.providerId !== 'anthropic'
? `, provider="${member.providerId}"`
: '';
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
const isolationPart = member.isolation === 'worktree' ? ', isolation="worktree"' : '';
return `${providerPart}${modelPart}${effortPart}${isolationPart}`;
}
export function buildAddMemberSpawnMessage(
teamName: string,
displayName: string,
leadName: string,
member: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
>
): string {
const roleHint =
@ -2031,16 +2048,11 @@ export function buildAddMemberSpawnMessage(
teamName,
leadName
);
const providerPart =
member.providerId && member.providerId !== 'anthropic'
? `, provider="${member.providerId}"`
: '';
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
const agentArgs = buildAgentToolArgsSuffix(member);
return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
`Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below:${workflowHint}\n\n` +
`Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below:${workflowHint}\n\n` +
indentMultiline(prompt, ' ')
);
}
@ -2051,7 +2063,7 @@ export function buildRestartMemberSpawnMessage(
leadName: string,
member: Pick<
TeamCreateRequest['members'][number],
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort'
'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' | 'isolation'
>
): string {
const roleHint =
@ -2076,16 +2088,11 @@ export function buildRestartMemberSpawnMessage(
teamName,
leadName
);
const providerPart =
member.providerId && member.providerId !== 'anthropic'
? `, provider="${member.providerId}"`
: '';
const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : '';
const effortPart = member.effort ? `, effort="${member.effort}"` : '';
const agentArgs = buildAgentToolArgsSuffix(member);
return (
`Teammate "${member.name}"${roleHint} was restarted from the UI. ` +
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` +
`Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${agentArgs}, and the exact prompt below. ` +
`This is a restart of an existing persistent teammate, not a new teammate. ` +
`If the Agent tool returns duplicate_skipped with reason bootstrap_pending, treat that as a pending restart and wait for teammate check-in. ` +
`If it returns duplicate_skipped with reason already_running, do not report success - it means the previous runtime still appears active and the restart may not have applied.${workflowHint ? workflowHint : ''}\n\n` +
@ -2100,6 +2107,7 @@ interface RuntimeBootstrapMemberSpec {
model?: string;
provider?: TeamProviderId;
effort?: EffortLevel;
isolation?: 'worktree';
agentType?: string;
description?: string;
useSplitPane?: boolean;
@ -2176,6 +2184,7 @@ function buildDeterministicCreateBootstrapSpec(
...(member.model?.trim() ? { model: member.model.trim() } : {}),
...(member.providerId ? { provider: member.providerId } : {}),
...(member.effort ? { effort: member.effort } : {}),
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(member.role?.trim() ? { description: member.role.trim() } : {}),
})),
launch: {
@ -2223,6 +2232,7 @@ function buildDeterministicLaunchBootstrapSpec(
...(member.model?.trim() ? { model: member.model.trim() } : {}),
...(member.providerId ? { provider: member.providerId } : {}),
...(member.effort ? { effort: member.effort } : {}),
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(member.role?.trim() ? { role: member.role.trim() } : {}),
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
...(member.role?.trim() ? { description: member.role.trim() } : {}),
@ -5873,6 +5883,7 @@ export class TeamProvisioningService {
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
@ -5888,6 +5899,7 @@ export class TeamProvisioningService {
name: configuredMember.name,
role: configuredMember.role,
workflow: configuredMember.workflow,
isolation: configuredMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: configuredMember.providerId,
model: configuredMember.model,
effort: configuredMember.effort,
@ -7619,6 +7631,7 @@ export class TeamProvisioningService {
name: m.name.trim(),
role: m.role?.trim() || undefined,
workflow: m.workflow?.trim() || undefined,
isolation: m.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(m.providerId),
model: m.model?.trim() || undefined,
effort: isTeamEffortLevel(m.effort) ? m.effort : undefined,
@ -7780,6 +7793,7 @@ export class TeamProvisioningService {
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort:
@ -7893,6 +7907,7 @@ export class TeamProvisioningService {
name: member.name,
role: member.role,
workflow: member.workflow,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: 'opencode',
model: member.model ?? input.request.model,
effort: member.effort ?? input.request.effort,
@ -7992,6 +8007,7 @@ export class TeamProvisioningService {
name: member.name,
role: member.role,
workflow: member.workflow,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model,
effort: member.effort,
@ -8582,6 +8598,7 @@ export class TeamProvisioningService {
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
@ -10015,6 +10032,7 @@ export class TeamProvisioningService {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -10039,6 +10057,10 @@ export class TeamProvisioningService {
const role = metaMember?.role?.trim() || configuredMember?.role?.trim() || undefined;
const workflow =
metaMember?.workflow?.trim() || configuredMember?.workflow?.trim() || undefined;
const isolation =
metaMember?.isolation === 'worktree' || configuredMember?.isolation === 'worktree'
? 'worktree'
: undefined;
const providerId =
normalizeTeamMemberProviderId(metaMember?.providerId) ??
normalizeTeamMemberProviderId(configuredMember?.providerId);
@ -10056,6 +10078,7 @@ export class TeamProvisioningService {
name,
...(role ? { role } : {}),
...(workflow ? { workflow } : {}),
...(isolation ? { isolation } : {}),
...(providerId ? { providerId } : {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
@ -15336,6 +15359,7 @@ export class TeamProvisioningService {
name: member.name.trim(),
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model?.trim() || undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
@ -15378,18 +15402,20 @@ export class TeamProvisioningService {
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const workflow =
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model =
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
const prev = byName.get(name);
if (!prev) {
byName.set(name, { name, role, workflow, providerId, model, effort });
byName.set(name, { name, role, workflow, isolation, providerId, model, effort });
} else {
byName.set(name, {
...prev,
role: prev.role || role,
workflow: prev.workflow || workflow,
isolation: prev.isolation || isolation,
providerId: prev.providerId || providerId,
model: prev.model || model,
effort: prev.effort || effort,
@ -15450,13 +15476,14 @@ export class TeamProvisioningService {
name,
role: configMember?.role,
workflow: configMember?.workflow,
isolation: configMember?.isolation,
providerId: configMember?.providerId,
model: configMember?.model,
effort: configMember?.effort,
};
});
const memberOverridesUsed = members.some(
(member) => member.providerId || member.model || member.effort
(member) => member.providerId || member.model || member.effort || member.isolation
);
return {
members,
@ -15519,6 +15546,7 @@ export class TeamProvisioningService {
name?: string;
role?: string;
workflow?: string;
isolation?: string;
agentType?: string;
providerId?: string;
provider?: string;
@ -15541,6 +15569,7 @@ export class TeamProvisioningService {
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow:
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,

View file

@ -1,11 +1,13 @@
import type { TeamProviderId } from '@shared/types';
import type { EffortLevel, TeamProviderId } from '@shared/types';
export interface MemberDiffInput {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
removedAt?: number | string | null;
}
@ -14,8 +16,10 @@ export interface ReplaceMembersDiff {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
}[];
removed: string[];
updated: {
@ -67,8 +71,10 @@ export function buildReplaceMembersDiff(
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
}[]
): ReplaceMembersDiff {
const previousByName = new Map(
@ -80,8 +86,10 @@ export function buildReplaceMembersDiff(
name: member.name.trim(),
role: normalizeOptionalText(member.role),
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
},
])
);
@ -94,8 +102,10 @@ export function buildReplaceMembersDiff(
name: member.name.trim(),
role: normalizeOptionalText(member.role),
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
},
])
);
@ -118,6 +128,11 @@ export function buildReplaceMembersDiff(
const changes = [
describeRoleChange(previousMember.role, nextMember.role),
describeWorkflowChange(previousMember.workflow, nextMember.workflow),
previousMember.isolation !== nextMember.isolation
? nextMember.isolation === 'worktree'
? 'worktree isolation enabled'
: 'worktree isolation disabled'
: null,
].filter((value): value is string => value !== null);
if (changes.length === 0) {
return [];

View file

@ -15,6 +15,7 @@ export interface TeamRuntimeMemberSpec {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId: TeamRuntimeProviderId;
model?: string;
effort?: EffortLevel;

View file

@ -2839,6 +2839,7 @@ export const TeamDetailView = ({
name: entry.name,
role: entry.role,
workflow: entry.workflow,
isolation: entry.isolation,
providerId: entry.providerId,
model: entry.model,
effort: entry.effort,

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getNextSuggestedMemberName } from '@renderer/components/team/members/memberNameSets';
import {
@ -25,6 +25,7 @@ export interface AddMemberEntry {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -41,14 +42,36 @@ interface AddMemberDialogProps {
/** Project path for @file mentions in workflow field. */
projectPath?: string | null;
/** Existing team members with their colors — used so new drafts get the next available color */
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
existingMembers?: readonly {
name: string;
color?: string;
isolation?: 'worktree';
removedAt?: number | string | null;
}[];
}
const DIALOG_WIDTH = 'w-[720px]';
function buildInitialDrafts(existingNames: string[]): MemberDraft[] {
function deriveExistingWorktreeDefault(
existingMembers: AddMemberDialogProps['existingMembers']
): boolean {
const activeTeammates =
existingMembers?.filter(
(member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead'
) ?? [];
return (
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
);
}
function buildInitialDrafts(existingNames: string[], worktreeDefault = false): MemberDraft[] {
const suggestedName = getNextSuggestedMemberName(existingNames);
return [createMemberDraft({ name: suggestedName })];
return [
createMemberDraft({
name: suggestedName,
isolation: worktreeDefault ? 'worktree' : undefined,
}),
];
}
export const AddMemberDialog = ({
@ -61,8 +84,13 @@ export const AddMemberDialog = ({
projectPath,
existingMembers,
}: AddMemberDialogProps): React.JSX.Element => {
const [members, setMembers] = useState<MemberDraft[]>(() => buildInitialDrafts(existingNames));
const existingWorktreeDefault = deriveExistingWorktreeDefault(existingMembers);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(existingWorktreeDefault);
const [members, setMembers] = useState<MemberDraft[]>(() =>
buildInitialDrafts(existingNames, existingWorktreeDefault)
);
const [error, setError] = useState<string | null>(null);
const wasOpenRef = useRef(open);
// Combine existing names + names already in the draft list for duplicate validation
const allNames = useMemo(() => {
@ -120,6 +148,7 @@ export const AddMemberDialog = ({
name: m.name,
role: m.role,
workflow: m.workflow,
isolation: m.isolation,
providerId: m.providerId,
model: m.model,
effort: m.effort,
@ -129,24 +158,21 @@ export const AddMemberDialog = ({
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
setMembers(buildInitialDrafts(existingNames));
setMembers(buildInitialDrafts(existingNames, teammateWorktreeDefault));
setError(null);
onClose();
}
};
// Re-initialize drafts when the dialog opens with fresh suggested name
// (existingNames may have changed since last close)
useEffect(() => {
if (!open) return;
setMembers((prev) => {
const allEmpty = prev.every((m) => !m.name.trim());
if (prev.length === 0 || allEmpty) {
return buildInitialDrafts(existingNames);
}
return prev;
});
}, [open, existingNames]);
const wasOpen = wasOpenRef.current;
if (open && !wasOpen) {
setTeammateWorktreeDefault(existingWorktreeDefault);
setMembers(buildInitialDrafts(existingNames, existingWorktreeDefault));
setError(null);
}
wasOpenRef.current = open;
}, [existingNames, existingWorktreeDefault, open]);
const memberCount = members.filter((m) => m.name.trim() && !validateName(m.name)).length;
@ -169,6 +195,9 @@ export const AddMemberDialog = ({
draftKeyPrefix={`addMember:${teamName}`}
projectPath={projectPath}
existingMembers={existingMembers}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
/>
</div>

View file

@ -361,6 +361,8 @@ export const CreateTeamDialog = ({
setMembers,
syncModelsWithLead,
setSyncModelsWithLead,
teammateWorktreeDefault,
setTeammateWorktreeDefault,
cwdMode,
setCwdMode,
selectedProjectPath,
@ -919,6 +921,7 @@ export const CreateTeamDialog = ({
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '',
workflow: m.workflow,
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
providerId: normalizeOptionalTeamProviderId(m.providerId),
model: m.model ?? '',
effort: m.effort,
@ -927,6 +930,10 @@ export const CreateTeamDialog = ({
);
})
);
setTeammateWorktreeDefault(
initialData.members.length > 0 &&
initialData.members.every((member) => member.isolation === 'worktree')
);
setSyncModelsWithLead(
!initialData.members.some((member) => member.providerId || member.model || member.effort)
);
@ -1548,6 +1555,9 @@ export const CreateTeamDialog = ({
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
showWorktreeIsolationControls={!soloTeam}
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
disableGeminiOption={isGeminiUiFrozen()}
leadModelIssueText={leadModelIssueText}
leadFastModeNotice={anthropicRuntimeNotice}

View file

@ -72,6 +72,13 @@ function membersToDrafts(members: ResolvedTeamMember[]) {
return createMemberDraftsFromInputs(filterEditableMemberInputs(members));
}
function deriveTeammateWorktreeDefault(members: readonly ResolvedTeamMember[]): boolean {
const activeTeammates = filterEditableMemberInputs(members).filter((member) => !member.removedAt);
return (
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
);
}
function useEditTeamErrorReset(
setError: (value: string | null) => void,
setSaveOutcomeError: (value: string | null) => void
@ -146,6 +153,9 @@ export const EditTeamDialog = ({
const [description, setDescription] = useState(currentDescription);
const [color, setColor] = useState(currentColor);
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(() =>
deriveTeammateWorktreeDefault(currentMembers)
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveOutcomeError, setSaveOutcomeError] = useState<string | null>(null);
@ -187,6 +197,7 @@ export const EditTeamDialog = ({
setDescription(currentDescription);
setColor(currentColor);
setMembers(membersToDrafts(currentMembers));
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(currentMembers));
setError(null);
setSaveOutcomeError(null);
setMembersPendingRestartRetry({});
@ -293,7 +304,7 @@ export const EditTeamDialog = ({
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
? 'Saving will restart this teammate to apply role, workflow, provider, model, or effort changes.'
? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, or effort changes.'
: null,
])
);
@ -380,6 +391,7 @@ export const EditTeamDialog = ({
providerId: member.providerId,
model: member.model,
effort: member.effort,
isolation: member.isolation,
})) as ResolvedTeamMember[],
});
@ -558,6 +570,9 @@ export const EditTeamDialog = ({
}
existingMembers={currentMembers}
existingMemberColorMap={effectiveResolvedMemberColorMap}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
lockProviderModel={false}
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
@ -588,7 +603,7 @@ export const EditTeamDialog = ({
<p className="text-xs text-amber-300">
Saving will restart{' '}
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
apply role, workflow, provider, model, or effort changes:{' '}
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
{effectiveMembersToRestart.join(', ')}.
</p>
) : null}

View file

@ -261,6 +261,21 @@ function resolveResolvedMemberRuntime(
};
}
function deriveTeammateWorktreeDefault(
members: readonly {
name: string;
isolation?: 'worktree';
removedAt?: number | string | null;
}[]
): boolean {
const activeTeammates = members.filter(
(member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead'
);
return (
activeTeammates.length > 0 && activeTeammates.every((member) => member.isolation === 'worktree')
);
}
// =============================================================================
// Component
// =============================================================================
@ -351,6 +366,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
getStoredTeamModel(getStoredTeamProvider())
);
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
const [syncModelsWithLead, setSyncModelsWithLead] = useState(false);
const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
@ -742,6 +758,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
normalizeMemberDraftForProviderMode(member, multimodelEnabled)
)
);
setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource));
setSyncModelsWithLead(
!editableMembersSource.some((member) => member.providerId || member.model || member.effort)
);
@ -1004,19 +1021,35 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (
previousProvider === currentProviderId &&
previousModel === currentModel &&
(previousEffort ?? '') === (currentEffort ?? '')
(previousEffort ?? '') === (currentEffort ?? '') &&
(previousMember.isolation ?? '') === (member.isolation ?? '')
) {
continue;
}
const runtimeMessage =
previousProvider !== currentProviderId ||
previousModel !== currentModel ||
(previousEffort ?? '') !== (currentEffort ?? '')
? `${formatTeamModelSummary(
currentProviderId,
currentModel,
currentEffort
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`
: null;
const isolationMessage =
previousMember.isolation !== member.isolation
? `${member.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'} instead of ${
previousMember.isolation === 'worktree' ? 'separate worktree' : 'shared workspace'
}`
: null;
notes.push({
key: `member:${name.toLowerCase()}`,
memberName: name,
message: `${formatTeamModelSummary(
currentProviderId,
currentModel,
currentEffort
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`,
message: [runtimeMessage, isolationMessage]
.filter((part): part is string => Boolean(part))
.join('; '),
});
}
@ -1398,6 +1431,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const summary: string[] = [];
if (promptDraft.value.trim()) summary.push('Lead prompt');
const worktreeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.isolation === 'worktree'
).length;
if (worktreeMemberCount > 0) {
summary.push(
`${worktreeMemberCount} teammate worktree${worktreeMemberCount === 1 ? '' : 's'}`
);
}
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
if (selectedModel) summary.push(`Model: ${selectedModel}`);
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
@ -1414,6 +1455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return summary;
}, [
isLaunchMode,
effectiveMemberDrafts,
promptDraft.value,
selectedModel,
selectedProviderId,
@ -2030,6 +2072,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
showWorktreeIsolationControls
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
leadWarningText={leadRuntimeWarningText}
leadFastModeNotice={anthropicRuntimeNotice}
memberWarningById={memberRuntimeWarningById}

View file

@ -12,6 +12,7 @@ import type {
function normalizeRestartSensitiveMemberContract(member: {
role?: string;
workflow?: string;
isolation?: string;
providerId?: string;
model?: string;
effort?: string;
@ -21,13 +22,15 @@ function normalizeRestartSensitiveMemberContract(member: {
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
isolation?: 'worktree';
} {
const role = member.role?.trim() || undefined;
const workflow = member.workflow?.trim() || undefined;
const providerId = normalizeOptionalTeamProviderId(member.providerId);
const model = member.model?.trim() || undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
return { role, workflow, providerId, model, effort };
const isolation = member.isolation === 'worktree' ? 'worktree' : undefined;
return { role, workflow, providerId, model, effort, isolation };
}
export function getMemberRuntimeContractKey(member: {
@ -36,6 +39,7 @@ export function getMemberRuntimeContractKey(member: {
providerId?: string;
model?: string;
effort?: string;
isolation?: string;
}): string {
return JSON.stringify(normalizeRestartSensitiveMemberContract(member));
}
@ -68,7 +72,8 @@ export function getMembersRequiringRuntimeRestart(params: {
previousRuntime.workflow !== nextRuntime.workflow ||
previousRuntime.providerId !== nextRuntime.providerId ||
previousRuntime.model !== nextRuntime.model ||
previousRuntime.effort !== nextRuntime.effort
previousRuntime.effort !== nextRuntime.effort ||
previousRuntime.isolation !== nextRuntime.isolation
) {
membersToRestart.push(previousMember.name);
}
@ -124,6 +129,7 @@ function normalizeEditableMemberSnapshot(member: {
providerId?: string;
model?: string;
effort?: string;
isolation?: string;
removedAt?: number | string | null;
}): {
name: string;

View file

@ -10,7 +10,9 @@ import {
} from '@renderer/components/team/dialogs/TeamModelSelector';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
@ -20,7 +22,15 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColorByName } from '@shared/constants/memberColors';
import { AlertTriangle, ChevronDown, ChevronRight, Info, RotateCcw, Trash2 } from 'lucide-react';
import {
AlertTriangle,
ChevronDown,
ChevronRight,
GitBranch,
Info,
RotateCcw,
Trash2,
} from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
@ -65,6 +75,8 @@ interface MemberDraftRowProps {
warningText?: string | null;
disableGeminiOption?: boolean;
modelIssueText?: string | null;
showWorktreeIsolationControls?: boolean;
onWorktreeIsolationChange?: (id: string, enabled: boolean) => void;
lockedModelAction?: {
label: string;
description?: string;
@ -111,6 +123,8 @@ export const MemberDraftRow = ({
warningText,
disableGeminiOption = false,
modelIssueText,
showWorktreeIsolationControls = false,
onWorktreeIsolationChange,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme();
@ -327,6 +341,41 @@ export const MemberDraftRow = ({
) : null}
</Tooltip>
</div>
{showWorktreeIsolationControls ? (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs text-[var(--color-text-secondary)]',
isRemoved && 'cursor-not-allowed opacity-50'
)}
>
<Checkbox
id={`member-${member.id}-worktree-isolation`}
checked={member.isolation === 'worktree'}
disabled={isRemoved}
onCheckedChange={(checked) =>
onWorktreeIsolationChange?.(member.id, checked === true)
}
/>
<Label
htmlFor={`member-${member.id}-worktree-isolation`}
className={cn(
'flex cursor-pointer items-center gap-1.5 text-xs font-normal',
isRemoved && 'cursor-not-allowed'
)}
>
<GitBranch className="size-3.5 shrink-0" />
<span>Worktree</span>
</Label>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64 text-xs leading-relaxed">
Run this teammate in a separate git worktree. Apply/reject changes targets that
worktree, not the lead workspace.
</TooltipContent>
</Tooltip>
) : null}
{hideActionButton ? null : isRemoved ? (
<Button
variant="outline"

View file

@ -1,12 +1,13 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { Plus } from 'lucide-react';
import { GitBranch, Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -34,6 +35,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
if (role) obj.role = role;
const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow;
if (d.isolation === 'worktree') obj.isolation = 'worktree';
if (d.providerId) obj.providerId = d.providerId;
if (d.model?.trim()) obj.model = d.model.trim();
if (d.effort) obj.effort = d.effort;
@ -49,6 +51,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : '';
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
const isolation = item.isolation === 'worktree' ? 'worktree' : undefined;
const providerId = normalizeOptionalTeamProviderId(item.providerId);
const model = typeof item.model === 'string' ? item.model.trim() : '';
const effort: EffortLevel | undefined = isTeamEffortLevel(item.effort)
@ -61,6 +64,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: workflow || undefined,
isolation,
providerId,
model,
effort,
@ -111,6 +115,9 @@ export interface MembersEditorSectionProps {
memberModelIssueById?: Record<string, string | null | undefined>;
disableAddMember?: boolean;
addMemberLockReason?: string;
showWorktreeIsolationControls?: boolean;
teammateWorktreeDefault?: boolean;
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
}
export const MembersEditorSection = ({
@ -145,6 +152,9 @@ export const MembersEditorSection = ({
memberModelIssueById,
disableAddMember = false,
addMemberLockReason,
showWorktreeIsolationControls = false,
teammateWorktreeDefault = false,
onTeammateWorktreeDefaultChange,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
@ -236,6 +246,23 @@ export const MembersEditorSection = ({
);
};
const updateMemberIsolation = (memberId: string, enabled: boolean): void => {
onChange(
members.map((c) =>
c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c
)
);
};
const updateTeammateWorktreeDefault = (enabled: boolean): void => {
onTeammateWorktreeDefaultChange?.(enabled);
onChange(
members.map((member) =>
member.removedAt ? member : { ...member, isolation: enabled ? 'worktree' : undefined }
)
);
};
const removeMember = (memberId: string): void => {
if (!softDeleteMembers) {
onChange(members.filter((c) => c.id !== memberId));
@ -260,8 +287,15 @@ export const MembersEditorSection = ({
...members,
createMemberDraft(
inheritModelSettingsByDefault
? { name: suggestedName }
: { name: suggestedName, providerId: defaultProviderId }
? {
name: suggestedName,
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
}
: {
name: suggestedName,
providerId: defaultProviderId,
isolation: teammateWorktreeDefault ? 'worktree' : undefined,
}
),
]);
};
@ -274,6 +308,11 @@ export const MembersEditorSection = ({
() => buildMemberDraftColorMap(members, existingMembers, existingMemberColorMap),
[members, existingMembers, existingMemberColorMap]
);
const worktreeDefaultControlId = useMemo(
() =>
`teammate-worktree-default-${(draftKeyPrefix ?? 'default').replace(/[^a-zA-Z0-9_-]/g, '-')}`,
[draftKeyPrefix]
);
const mentionSuggestions = useMemo(
() => buildMemberDraftSuggestions(members, memberColorMap),
@ -308,6 +347,22 @@ export const MembersEditorSection = ({
{headerExtra}
{!hideContent && (
<>
{showWorktreeIsolationControls ? (
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2">
<Checkbox
id={worktreeDefaultControlId}
checked={teammateWorktreeDefault}
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
/>
<Label
htmlFor={worktreeDefaultControlId}
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate">Run teammates in separate worktrees</span>
</Label>
</div>
) : null}
{disableAddMember && addMemberLockReason ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
) : null}
@ -330,6 +385,8 @@ export const MembersEditorSection = ({
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
@ -374,6 +431,8 @@ export const MembersEditorSection = ({
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}

View file

@ -50,6 +50,9 @@ interface TeamRosterEditorSectionProps {
disableGeminiOption?: boolean;
leadModelIssueText?: string | null;
memberModelIssueById?: Record<string, string | null | undefined>;
showWorktreeIsolationControls?: boolean;
teammateWorktreeDefault?: boolean;
onTeammateWorktreeDefaultChange?: (enabled: boolean) => void;
}
export const TeamRosterEditorSection = ({
@ -95,6 +98,9 @@ export const TeamRosterEditorSection = ({
disableGeminiOption = false,
leadModelIssueText,
memberModelIssueById,
showWorktreeIsolationControls = false,
teammateWorktreeDefault = false,
onTeammateWorktreeDefaultChange,
}: TeamRosterEditorSectionProps): React.JSX.Element => {
return (
<MembersEditorSection
@ -122,6 +128,9 @@ export const TeamRosterEditorSection = ({
softDeleteMembers={softDeleteMembers}
disableGeminiOption={disableGeminiOption}
memberModelIssueById={memberModelIssueById}
showWorktreeIsolationControls={showWorktreeIsolationControls}
teammateWorktreeDefault={teammateWorktreeDefault}
onTeammateWorktreeDefaultChange={onTeammateWorktreeDefaultChange}
headerExtra={
<div className="space-y-3">
{headerTop}

View file

@ -9,6 +9,7 @@ export interface MemberDraft {
customRole: string;
workflow?: string;
workflowChips?: InlineChip[];
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;

View file

@ -32,6 +32,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
roleSelection: initial?.roleSelection ?? '',
customRole: initial?.customRole ?? '',
workflow: initial?.workflow,
isolation: initial?.isolation === 'worktree' ? 'worktree' : undefined,
providerId,
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
effort: initial?.effort,
@ -48,6 +49,7 @@ export function createMemberDraftsFromInputs(
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
isolation?: 'worktree';
removedAt?: number | string | null;
}[]
): MemberDraft[] {
@ -63,6 +65,7 @@ export function createMemberDraftsFromInputs(
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: member.workflow,
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model ?? '',
effort: normalizeDraftEffort(member.effort),
@ -237,6 +240,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
const result: TeamProvisioningMemberInput = { name, role };
const workflow = getWorkflowForExport(member);
if (workflow) result.workflow = workflow;
if (member.isolation === 'worktree') result.isolation = 'worktree';
const providerId = normalizeOptionalTeamProviderId(member.providerId);
if (providerId) {
result.providerId = providerId;

View file

@ -3,7 +3,7 @@ import React from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
import { ChevronDown, ChevronRight, FilePlus, GitBranch, Loader2, Save, Undo2 } from 'lucide-react';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
@ -179,6 +179,28 @@ export const FileSectionHeader = ({
</Tooltip>
)}
{file.ledgerSummary?.worktreePath && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] text-blue-300">
<GitBranch className="size-3" />
WORKTREE
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm">
<div className="space-y-1">
<div className="font-medium text-text">
{file.ledgerSummary.worktreeBranch ?? 'Isolated worktree'}
</div>
<div className="break-all text-text-muted">{file.ledgerSummary.worktreePath}</div>
{file.ledgerSummary.dirtyLeaderWarning && (
<div className="text-amber-300">{file.ledgerSummary.dirtyLeaderWarning}</div>
)}
</div>
</TooltipContent>
</Tooltip>
)}
{fileDecision && (
<span
className={`rounded px-1.5 py-0.5 text-[10px] ${

View file

@ -35,6 +35,8 @@ export interface UseCreateTeamDraftResult {
setMembers: (v: MemberDraft[]) => void;
syncModelsWithLead: boolean;
setSyncModelsWithLead: (v: boolean) => void;
teammateWorktreeDefault: boolean;
setTeammateWorktreeDefault: (v: boolean) => void;
cwdMode: 'project' | 'custom';
setCwdMode: (v: 'project' | 'custom') => void;
selectedProjectPath: string;
@ -66,12 +68,13 @@ const DEBOUNCE_MS = 400;
function serializeMembers(members: MemberDraft[]): SerializedMemberDraft[] {
return members.map(
({ id, name, roleSelection, customRole, workflow, providerId, model, effort }) => ({
({ id, name, roleSelection, customRole, workflow, isolation, providerId, model, effort }) => ({
id,
name,
roleSelection,
customRole,
workflow,
isolation,
providerId,
model,
effort,
@ -87,6 +90,7 @@ function deserializeMembers(serialized: SerializedMemberDraft[]): MemberDraft[]
roleSelection: m.roleSelection,
customRole: m.customRole,
workflow: m.workflow,
isolation: m.isolation === 'worktree' ? 'worktree' : undefined,
providerId: m.providerId,
model: m.model,
effort: m.effort,
@ -103,6 +107,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
const [teamName, setTeamNameState] = useState('');
const [members, setMembersState] = useState<MemberDraft[]>([]);
const [syncModelsWithLead, setSyncModelsWithLeadState] = useState(true);
const [teammateWorktreeDefault, setTeammateWorktreeDefaultState] = useState(false);
const [cwdMode, setCwdModeState] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPathState] = useState('');
const [customCwd, setCustomCwdState] = useState('');
@ -115,6 +120,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
const teamNameRef = useRef('');
const membersRef = useRef<MemberDraft[]>([]);
const syncModelsWithLeadRef = useRef(true);
const teammateWorktreeDefaultRef = useRef(false);
const cwdModeRef = useRef<'project' | 'custom'>('project');
const selectedProjectPathRef = useRef('');
const customCwdRef = useRef('');
@ -141,6 +147,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamName: teamNameRef.current,
members: serializeMembers(membersRef.current),
syncModelsWithLead: syncModelsWithLeadRef.current,
teammateWorktreeDefault: teammateWorktreeDefaultRef.current,
cwdMode: cwdModeRef.current,
selectedProjectPath: selectedProjectPathRef.current,
customCwd: customCwdRef.current,
@ -201,6 +208,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = snap.teamName;
membersRef.current = deserialized;
syncModelsWithLeadRef.current = snap.syncModelsWithLead ?? true;
teammateWorktreeDefaultRef.current = snap.teammateWorktreeDefault === true;
cwdModeRef.current = snap.cwdMode;
selectedProjectPathRef.current = snap.selectedProjectPath;
customCwdRef.current = snap.customCwd;
@ -211,6 +219,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState(snap.teamName);
setMembersState(deserialized);
setSyncModelsWithLeadState(snap.syncModelsWithLead ?? true);
setTeammateWorktreeDefaultState(snap.teammateWorktreeDefault === true);
setCwdModeState(snap.cwdMode);
setSelectedProjectPathState(snap.selectedProjectPath);
setCustomCwdState(snap.customCwd);
@ -285,6 +294,16 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
[scheduleSave]
);
const setTeammateWorktreeDefault = useCallback(
(v: boolean) => {
userTouchedRef.current = true;
teammateWorktreeDefaultRef.current = v;
setTeammateWorktreeDefaultState(v);
scheduleSave();
},
[scheduleSave]
);
const setCwdMode = useCallback(
(v: 'project' | 'custom') => {
userTouchedRef.current = true;
@ -360,6 +379,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
teamNameRef.current = '';
membersRef.current = [];
syncModelsWithLeadRef.current = true;
teammateWorktreeDefaultRef.current = false;
cwdModeRef.current = 'project';
selectedProjectPathRef.current = '';
customCwdRef.current = '';
@ -370,6 +390,7 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setTeamNameState('');
setMembersState([]);
setSyncModelsWithLeadState(true);
setTeammateWorktreeDefaultState(false);
setCwdModeState('project');
setSelectedProjectPathState('');
setCustomCwdState('');
@ -387,6 +408,8 @@ export function useCreateTeamDraft(): UseCreateTeamDraftResult {
setMembers,
syncModelsWithLead,
setSyncModelsWithLead,
teammateWorktreeDefault,
setTeammateWorktreeDefault,
cwdMode,
setCwdMode,
selectedProjectPath,

View file

@ -32,6 +32,7 @@ export interface SerializedMemberDraft {
roleSelection: string;
customRole: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -42,6 +43,7 @@ export interface CreateTeamDraftSnapshot {
teamName: string;
members: SerializedMemberDraft[];
syncModelsWithLead?: boolean;
teammateWorktreeDefault?: boolean;
cwdMode: 'project' | 'custom';
selectedProjectPath: string;
customCwd: string;
@ -69,6 +71,7 @@ function isValidMember(m: unknown): m is SerializedMemberDraft {
typeof obj.name === 'string' &&
typeof obj.roleSelection === 'string' &&
typeof obj.customRole === 'string' &&
(obj.isolation === undefined || obj.isolation === 'worktree') &&
(obj.providerId === undefined || isTeamProviderId(obj.providerId)) &&
(obj.model === undefined || typeof obj.model === 'string') &&
(obj.effort === undefined || isTeamEffortLevel(obj.effort))
@ -85,6 +88,8 @@ function isValidSnapshot(data: unknown): data is CreateTeamDraftSnapshot {
Array.isArray(obj.members) &&
obj.members.every(isValidMember) &&
(obj.syncModelsWithLead === undefined || typeof obj.syncModelsWithLead === 'boolean') &&
(obj.teammateWorktreeDefault === undefined ||
typeof obj.teammateWorktreeDefault === 'boolean') &&
(obj.cwdMode === 'project' || obj.cwdMode === 'custom') &&
typeof obj.selectedProjectPath === 'string' &&
typeof obj.customCwd === 'string' &&
@ -170,6 +175,7 @@ function emptySnapshot(): CreateTeamDraftSnapshot {
teamName: '',
members: [],
syncModelsWithLead: true,
teammateWorktreeDefault: false,
cwdMode: 'project',
selectedProjectPath: '',
customCwd: '',

View file

@ -51,6 +51,10 @@ export interface SnippetDiff {
linesAdded?: number;
linesRemoved?: number;
textAvailability?: 'patch-text' | 'full-text' | 'unavailable';
worktreePath?: string;
worktreeBranch?: string;
baseWorkspaceRoot?: string;
dirtyLeaderWarning?: string;
};
}
@ -96,6 +100,10 @@ export interface FileChangeSummary {
agentIds?: string[];
memberNames?: string[];
executionSeqRange?: { start: number; end: number };
worktreePath?: string;
worktreeBranch?: string;
baseWorkspaceRoot?: string;
dirtyLeaderWarning?: string;
};
/** Edit timeline for this file (Phase 4) */
timeline?: FileEditTimeline;
@ -261,6 +269,10 @@ export interface TaskChangeScope {
firstTimestamp: string;
lastTimestamp: string;
}>;
worktreePaths?: string[];
worktreeBranches?: string[];
baseWorkspaceRoots?: string[];
dirtyLeaderWarnings?: string[];
}
/** Результат парсинга всех границ задач из JSONL файла */

View file

@ -7,6 +7,8 @@ export interface TeamMember {
role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string;
/** Opt-in runtime isolation for persistent teammates. Omitted means shared workspace. */
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -714,6 +716,7 @@ export interface ResolvedTeamMember {
agentType?: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -762,6 +765,7 @@ export interface TeamMemberSnapshot {
agentType?: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -1065,6 +1069,8 @@ export interface TeamProvisioningMemberInput {
role?: string;
/** Per-agent workflow/instructions injected into spawn prompt. */
workflow?: string;
/** Opt-in: run this teammate in its own git worktree. */
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
@ -1228,6 +1234,7 @@ export interface AddMemberRequest {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;

View file

@ -254,6 +254,288 @@ describe('TaskChangeLedgerReader', () => {
expect(result?.files[0]?.linesRemoved).toBe(2);
});
it('preserves v2 worktree metadata from centralized ledger summaries', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: {},
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['tool-1'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
worktreePaths: ['/repo/.claude/worktrees/team-atlas-alice-12345678'],
worktreeBranches: ['worktree-team-atlas-alice-12345678'],
baseWorkspaceRoots: ['/repo'],
dirtyLeaderWarnings: ['Leader workspace had uncommitted changes.'],
},
files: [
{
changeKey: 'path:/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
filePath: '/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts',
relativePath: 'src/a.ts',
linesAdded: 1,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
worktreePath: '/repo/.claude/worktrees/team-atlas-alice-12345678',
worktreeBranch: 'worktree-team-atlas-alice-12345678',
baseWorkspaceRoot: '/repo',
dirtyLeaderWarning: 'Leader workspace had uncommitted changes.',
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.scope.worktreePaths).toEqual([
'/repo/.claude/worktrees/team-atlas-alice-12345678',
]);
expect(result?.files[0]?.ledgerSummary?.worktreePath).toBe(
'/repo/.claude/worktrees/team-atlas-alice-12345678'
);
expect(result?.files[0]?.ledgerSummary?.worktreeBranch).toBe(
'worktree-team-atlas-alice-12345678'
);
expect(result?.files[0]?.filePath).toBe(
'/repo/.claude/worktrees/team-atlas-alice-12345678/src/a.ts'
);
});
it('keeps identical relative rename relations isolated by worktree path', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: {},
integrity: 'ok',
eventCount: 2,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'alice',
agentIds: ['alice@team', 'bob@team'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:01:00.000Z',
toolUseIds: ['tool-1', 'tool-2'],
toolUseCount: 2,
phaseSet: ['work'],
visibleFileCount: 2,
contributors: [],
worktreePaths: ['/repo/.claude/worktrees/team-a', '/repo/.claude/worktrees/team-b'],
},
files: [
{
changeKey: 'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
relativePath: 'src/new.ts',
displayPath: 'src/new.ts',
linesAdded: 0,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['alice@team'],
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
worktreePath: '/repo/.claude/worktrees/team-a',
},
{
changeKey: 'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
relativePath: 'src/new.ts',
displayPath: 'src/new.ts',
linesAdded: 0,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:01:00.000Z',
lastTimestamp: '2026-03-01T10:01:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'full-text',
reviewability: 'full-text',
agentIds: ['bob@team'],
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
worktreePath: '/repo/.claude/worktrees/team-b',
},
],
totalLinesAdded: 0,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 2,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(2);
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
new Set([
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
])
);
});
it('keeps worktree relation keys isolated when rebuilding directly from journal events', async () => {
tmpDir = await fsTempDir();
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
await mkdir(eventsDir, { recursive: true });
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
[
{
schemaVersion: 1,
eventId: 'event-a',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-a',
agentId: 'alice@team',
toolUseId: 'tool-a',
source: 'shell_snapshot',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo/.claude/worktrees/team-a',
worktreePath: '/repo/.claude/worktrees/team-a',
filePath: '/repo/.claude/worktrees/team-a/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:00:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'export const a = 1;\n',
newString: 'export const a = 2;\n',
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
linesAdded: 1,
linesRemoved: 1,
},
{
schemaVersion: 1,
eventId: 'event-b',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 2,
sessionId: 'session-b',
agentId: 'bob@team',
toolUseId: 'tool-b',
source: 'shell_snapshot',
operation: 'modify',
confidence: 'high',
workspaceRoot: '/repo/.claude/worktrees/team-b',
worktreePath: '/repo/.claude/worktrees/team-b',
filePath: '/repo/.claude/worktrees/team-b/src/new.ts',
relativePath: 'src/new.ts',
timestamp: '2026-03-01T10:01:00.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'export const b = 1;\n',
newString: 'export const b = 2;\n',
relation: { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' },
linesAdded: 1,
linesRemoved: 1,
},
]
.map((entry) => JSON.stringify(entry))
.join('\n') + '\n',
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(2);
expect(new Set(result?.files.map((file) => file.changeKey))).toEqual(
new Set([
'rename:/repo/.claude/worktrees/team-a:src/old.ts->src/new.ts',
'rename:/repo/.claude/worktrees/team-b:src/old.ts->src/new.ts',
])
);
});
it('falls back to journal summary when bundle and freshness describe different generations', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');

View file

@ -588,6 +588,48 @@ describe('TeamDataService', () => {
);
});
it('persists teammate worktree isolation in replaceMembers', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {
getMembers: vi.fn(async () => []),
writeMembers,
} as never;
const service = new TeamDataService(
{ getConfig: vi.fn(), listTeams: vi.fn() } as never,
{ getTasks: vi.fn(async () => []) } as never,
{ listInboxNames: vi.fn(async () => []), getMessages: vi.fn(async () => []) } as never,
{} as never,
{} as never,
{ resolveMembers: vi.fn(() => []) } as never,
{
getState: vi.fn(async () => ({ teamName: 'runtime-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
membersMetaStore,
{ readMessages: vi.fn(async () => []) } as never
);
await service.replaceMembers('runtime-team', {
members: [
{ name: 'alice', role: 'Developer', isolation: 'worktree' },
{ name: 'bob', role: 'Reviewer' },
],
});
const [, writtenMembers] = writeMembers.mock.calls[0] as unknown as [
string,
Array<{
name: string;
isolation?: 'worktree';
}>,
];
expect(writtenMembers.find((member) => member.name === 'alice')).toMatchObject({
isolation: 'worktree',
});
expect(writtenMembers.find((member) => member.name === 'bob')?.isolation).toBeUndefined();
});
it('does not carry over agentId from a previously removed member with the same name', async () => {
const writeMembers = vi.fn(async () => {});
const membersMetaStore = {

View file

@ -332,6 +332,42 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId);
});
it('createTeam bootstrap spec includes worktree isolation only for selected teammates', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
const { runId } = await svc.createTeam(
{
teamName: 'worktree-mixed-team',
cwd: process.cwd(),
members: [
{ name: 'alice', role: 'developer', isolation: 'worktree' },
{ name: 'bob', role: 'reviewer' },
],
},
() => {}
);
const bootstrapSpec = extractBootstrapSpec();
expect(bootstrapSpec.members?.[0]).toEqual(
expect.objectContaining({ name: 'alice', isolation: 'worktree' })
);
expect(bootstrapSpec.members?.[1]).toEqual(expect.objectContaining({ name: 'bob' }));
expect(bootstrapSpec.members?.[1]).not.toHaveProperty('isolation');
await svc.cancelProvisioning(runId);
});
it('forwards codex provider launch overrides into createTeam runtime args', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
const { child } = createFakeChild();
@ -452,6 +488,24 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
);
});
it('add and restart teammate prompts include worktree isolation only when selected', () => {
const addMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice',
isolation: 'worktree',
});
const normalAddMessage = buildAddMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'bob',
});
const restartMessage = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice',
isolation: 'worktree',
});
expect(addMessage).toContain('isolation="worktree"');
expect(restartMessage).toContain('isolation="worktree"');
expect(normalAddMessage).not.toContain('isolation="worktree"');
});
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild();
@ -746,7 +800,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
members: [{ name: 'alice', role: 'developer', providerId: 'codex', isolation: 'worktree' }],
source: 'config-fallback',
warning: undefined,
}));
@ -810,7 +864,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
members: [{ name: 'alice', role: 'developer', providerId: 'codex', isolation: 'worktree' }],
source: 'config-fallback',
warning: undefined,
}));
@ -832,6 +886,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(launchArgs).toEqual(
expect.arrayContaining(['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'])
);
expect(extractBootstrapSpec().members?.[0]).toEqual(
expect.objectContaining({
name: 'alice',
provider: 'codex',
isolation: 'worktree',
})
);
await svc.cancelProvisioning(runId);
});

View file

@ -112,6 +112,36 @@ describe('getMembersRequiringRuntimeRestart', () => {
expect(result).toEqual([]);
});
it('returns existing teammates whose worktree isolation changed', () => {
const result = getMembersRequiringRuntimeRestart({
previousMembers: [
{
name: 'alice',
role: 'Reviewer',
isolation: undefined,
} as any,
{
name: 'bob',
role: 'Developer',
isolation: 'worktree',
} as any,
],
nextMembers: [
{
name: 'alice',
role: 'Reviewer',
isolation: 'worktree',
},
{
name: 'bob',
role: 'Developer',
},
],
});
expect(result).toEqual(['alice', 'bob']);
});
it('reports live rename and remove of existing teammates separately from runtime restarts', () => {
const result = getLiveRosterIdentityChanges({
previousMembers: [

View file

@ -92,6 +92,29 @@ describe('members editor editable input filtering', () => {
]);
});
it('preserves worktree isolation when importing and exporting member drafts', () => {
const drafts = createMemberDraftsFromInputs(
filterEditableMemberInputs([
{
name: 'alice',
agentType: 'developer',
isolation: 'worktree',
},
{
name: 'bob',
agentType: 'reviewer',
},
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>>)
);
const exported = buildMembersFromDrafts(drafts);
expect(drafts[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
expect(exported[0]).toMatchObject({ name: 'alice', isolation: 'worktree' });
expect(exported[1]).toMatchObject({ name: 'bob' });
expect(exported[1]).not.toHaveProperty('isolation');
});
it('reuses existing member colors for matching draft names', () => {
const existingMembers = [{ name: 'alice' }, { name: 'tom' }, { name: 'bob' }];
const drafts = existingMembers.map((member) => createMemberDraft({ name: member.name }));