feat: implement member briefing functionality and enhance team provisioning

- Added new functions for member briefing, allowing retrieval of member-specific instructions and context based on team configuration and metadata.
- Enhanced the TeamProvisioningService to include prompts for new members, emphasizing the importance of calling the member briefing tool during onboarding.
- Updated tests to validate the new member briefing functionality and ensure proper handling of various member scenarios, including inbox presence and metadata resolution.
- Introduced environment variable support for enabling or disabling member briefing bootstrap prompts during team member provisioning.
This commit is contained in:
iliya 2026-03-13 15:58:51 +02:00
parent ba083c317b
commit afcb0fcc1a
14 changed files with 1021 additions and 88 deletions

View file

@ -4,6 +4,12 @@ const crypto = require('crypto');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
'cross_team_get_outbox',
]);
function nowIso() {
return new Date().toISOString();
@ -46,6 +52,39 @@ function assertSafePathSegment(label, value) {
return normalized;
}
function looksLikeQualifiedExternalRecipient(name) {
const trimmed = String(name || '').trim();
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return false;
const teamName = trimmed.slice(0, dot).trim();
const memberName = trimmed.slice(dot + 1).trim();
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
}
function looksLikeCrossTeamPseudoRecipient(name) {
const trimmed = String(name || '').trim();
const prefixes = [
'cross_team::',
'cross_team--',
'cross-team:',
'cross-team-',
'cross_team:',
'cross_team-',
];
for (const prefix of prefixes) {
if (!trimmed.startsWith(prefix)) continue;
const teamName = trimmed.slice(prefix.length).trim();
if (TEAM_NAME_PATTERN.test(teamName)) {
return true;
}
}
return false;
}
function looksLikeCrossTeamToolRecipient(name) {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(String(name || '').trim());
}
function getHomeDir() {
if (process.env.HOME) return process.env.HOME;
if (process.env.USERPROFILE) return process.env.USERPROFILE;
@ -86,23 +125,136 @@ function getPaths(flags, teamName) {
}
function inferLeadName(paths) {
const config = readTeamConfig(paths);
if (!config || !Array.isArray(config.members)) {
return 'team-lead';
}
const lead = config.members.find(
(member) => member && member.role && String(member.role).toLowerCase().includes('lead')
const resolved = resolveTeamMembers(paths);
const lead = resolved.members.find(
(member) =>
member &&
((typeof member.agentType === 'string' && member.agentType === 'team-lead') ||
(typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) ||
member.name === 'team-lead')
);
if (lead) {
return String(lead.name);
}
return config.members[0] ? String(config.members[0].name) : 'team-lead';
const config = resolved.config;
if (config && Array.isArray(config.members) && config.members[0]) {
return String(config.members[0].name);
}
return 'team-lead';
}
function readTeamConfig(paths) {
return readJson(path.join(paths.teamDir, 'config.json'), null);
}
function readMembersMeta(paths) {
let parsed;
try {
parsed = readJson(path.join(paths.teamDir, 'members.meta.json'), null);
} catch {
return [];
}
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.members)) {
return [];
}
return parsed.members.filter((member) => member && typeof member === 'object');
}
function listInboxMemberNames(paths) {
const inboxDir = path.join(paths.teamDir, 'inboxes');
let entries;
try {
entries = fs.readdirSync(inboxDir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((entry) => entry && entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name.slice(0, -5))
.map((name) => String(name || '').trim())
.filter((name) => name && name !== 'user')
.filter((name) => !looksLikeCrossTeamPseudoRecipient(name))
.filter((name) => !looksLikeCrossTeamToolRecipient(name));
}
function normalizeMemberRecord(member) {
if (!member || typeof member !== 'object') return null;
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) return null;
return {
name,
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
...(typeof member.workflow === 'string' && member.workflow.trim()
? { workflow: member.workflow.trim() }
: {}),
...(typeof member.agentType === 'string' && member.agentType.trim()
? { agentType: member.agentType.trim() }
: {}),
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}),
};
}
function mergeResolvedMember(target, source) {
if (!source) return target;
return {
...target,
...(source.name ? { name: source.name } : {}),
...(source.role ? { role: source.role } : {}),
...(source.workflow ? { workflow: source.workflow } : {}),
...(source.agentType ? { agentType: source.agentType } : {}),
...(source.color ? { color: source.color } : {}),
...(source.cwd ? { cwd: source.cwd } : {}),
...(source.removedAt != null ? { removedAt: source.removedAt } : {}),
};
}
function resolveTeamMembers(paths) {
const config = readTeamConfig(paths) || {};
const configMembers = Array.isArray(config.members) ? config.members : [];
const metaMembers = readMembersMeta(paths);
const inboxNames = listInboxMemberNames(paths);
const memberMap = new Map();
const removedNames = new Set();
for (const rawMember of configMembers) {
const normalized = normalizeMemberRecord(rawMember);
if (!normalized) continue;
memberMap.set(normalized.name.toLowerCase(), normalized);
}
for (const rawMember of metaMembers) {
const normalized = normalizeMemberRecord(rawMember);
if (!normalized) continue;
const key = normalized.name.toLowerCase();
if (normalized.removedAt != null) {
memberMap.delete(key);
removedNames.add(key);
continue;
}
removedNames.delete(key);
memberMap.set(key, mergeResolvedMember(memberMap.get(key) || { name: normalized.name }, normalized));
}
for (const inboxName of inboxNames) {
const normalized = String(inboxName || '').trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (!memberMap.has(key) && looksLikeQualifiedExternalRecipient(normalized)) continue;
if (removedNames.has(key) || memberMap.has(key)) continue;
memberMap.set(key, { name: normalized });
}
return {
config,
members: Array.from(memberMap.values()).sort((a, b) => a.name.localeCompare(b.name)),
removedNames,
inboxNames,
};
}
function resolveLeadSessionId(paths) {
const config = readTeamConfig(paths);
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
@ -302,7 +454,10 @@ module.exports = {
getPaths,
inferLeadName,
isProcessAlive,
listInboxMemberNames,
readMembersMeta,
readTeamConfig,
resolveTeamMembers,
resolveLeadSessionId,
saveTaskAttachmentFile,
};

View file

@ -1,6 +1,7 @@
const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const messages = require('./messages.js');
const processStore = require('./processStore.js');
const { wrapAgentBlock } = require('./agentBlocks.js');
function normalizeActorName(value) {
@ -299,6 +300,250 @@ async function taskBriefing(context, memberName) {
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
}
function getSystemLocale() {
const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : '';
if (!lang) return 'en';
return lang.split('.')[0].replace('_', '-');
}
function extractPrimaryLanguage(locale) {
const normalized = String(locale || '').trim();
const dash = normalized.indexOf('-');
return dash > 0 ? normalized.slice(0, dash) : normalized || 'en';
}
function resolveLanguageName(code, systemLocale) {
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code;
try {
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
const name = displayNames.of(effectiveCode);
if (name) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
} catch {
// Ignore Intl lookup failures and fall back to the raw code.
}
return effectiveCode;
}
function buildMemberLanguageInstruction(config) {
const configured =
config && typeof config.language === 'string' && config.language.trim()
? config.language.trim()
: '';
if (!configured) {
return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.';
}
const language = resolveLanguageName(configured, getSystemLocale());
return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`;
}
function buildMemberActionModeProtocol() {
return [
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):',
'- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.',
'- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.',
'- Never silently broaden permissions beyond the selected mode.',
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
'- Modes:',
' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.',
' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.',
' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.',
].join('\n');
}
function buildMemberTaskProtocol(teamName) {
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
0. IMPORTANT ID RULE:
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
- task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref.
- Human-facing summaries should use the short display label like #abcd1234 for readability.
1. If you are about to do implementation/fix work on a task yourself, make sure the owner reflects the actual implementer:
- If the task is unassigned or assigned to someone else, FIRST reassign it to yourself with MCP tool task_set_owner:
{ teamName: "${teamName}", taskId: "<taskId>", owner: "<your-name>" }
- Do this only when you are genuinely taking over the work.
- Reviewing, approving, or leaving comments does NOT require changing ownership.
2. Use MCP tool task_start to mark task started:
{ teamName: "${teamName}", taskId: "<taskId>" }
- Start the task ONLY when you are actually beginning work on it.
- Do NOT start multiple tasks at once unless the team lead explicitly directs parallel work.
3. Use MCP tool task_complete BEFORE sending your final reply:
{ teamName: "${teamName}", taskId: "<taskId>" }
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
5. If review fails and changes are needed, use MCP tool review_request_changes:
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" }
6. NEVER skip status updates. A task is NOT done until completed status is written.
- Never "bulk-complete" a batch of tasks at the end. Update status incrementally as you work.
7. To reply to a comment on a task, use MCP tool task_add_comment:
{ teamName: "${teamName}", taskId: "<taskId>", text: "<your reply>", from: "<your-name>" }
8. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates record them as a task comment:
{ teamName: "${teamName}", taskId: "<taskId>", text: "<summary of your finding or decision>", from: "<your-name>" }
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
9. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability.
10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234").
11. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) that will put the wrong task into APPROVED.
- Typical flow:
a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #X
12. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do ALL steps below skipping the board update or comment breaks traceability:
a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
b) STEP 2 THEN, add a task comment describing exactly what you need:
{ teamName: "${teamName}", taskId: "<taskId>", text: "question / blocker / missing info", from: "<your-name>" }
c) STEP 3 THEN, send a message to your team lead via SendMessage so they notice it promptly.
IMPORTANT: Always update the task board BEFORE sending the message. The flag + task comment are what make the request durable and visible on the board.
d) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
13. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
14. TASK QUEUE DISCIPLINE:
- Use task_briefing as a compact queue view of your assigned tasks.
- task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose.
- Finish existing in_progress tasks first.
- If you need more context for an in_progress task, you MAY call task_get, but it is not mandatory when task_briefing already gives enough detail.
- Before starting a needsFix or pending task, call task_get for that specific task first.
- If you are the one doing the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Then run task_start only when you truly begin.
- If you complete fixes for a needsFix task, mark it completed and then send it back through review_request when ready for another review pass.
Failure to follow this protocol means the task board will show incorrect status.`);
}
function buildMemberProcessProtocol(teamName) {
return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
1. Launch with & to get PID:
pnpm dev &
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
3. VERIFY registration succeeded (MANDATORY never skip this step) using MCP tool process_list:
{ teamName: "${teamName}" }
4. When stopping a process, use MCP tool process_stop:
{ teamName: "${teamName}", pid: <PID> }
If verification in step 3 fails or the process is missing from the list, re-register it.`);
}
function buildMemberFormattingProtocol() {
return wrapAgentBlock(`Hidden internal instructions rule (IMPORTANT):
- If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in:
<info_for_agent>
... hidden instructions only ...
</info_for_agent>
- Keep normal human-readable coordination outside the block.
- NEVER use agent-only blocks in messages to "user".`);
}
function normalizeMemberName(value) {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
}
async function memberBriefing(context, memberName) {
const requestedMemberName = String(memberName).trim();
const requestedMemberKey = normalizeMemberName(requestedMemberName);
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
const config = resolved.config || {};
if (!requestedMemberName) {
throw new Error('Missing member name');
}
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
}
const member =
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
null;
if (!member) {
throw new Error(
`Member not found in team metadata or inboxes: ${requestedMemberName}`
);
}
const leadName = runtimeHelpers.inferLeadName(context.paths);
const effectiveMember = member;
const role =
typeof effectiveMember.role === 'string' && effectiveMember.role.trim()
? effectiveMember.role.trim()
: typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim()
? effectiveMember.agentType.trim()
: 'team member';
const workflow =
typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim()
? effectiveMember.workflow.trim()
: '';
const cwd =
typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim()
? effectiveMember.cwd.trim()
: typeof config.projectPath === 'string' && config.projectPath.trim()
? config.projectPath.trim()
: '';
const activeProcesses = processStore
.listProcesses(context.paths)
.filter(
(entry) =>
entry &&
entry.alive &&
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
);
const taskQueue = await taskBriefing(context, requestedMemberName);
const lines = [
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
`Role: ${role}.`,
`Team lead: ${leadName}.`,
buildMemberLanguageInstruction(config),
`You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`,
];
if (workflow) {
lines.push('', 'Workflow:', workflow);
}
if (cwd) {
lines.push('', `Working directory: ${cwd}`);
}
lines.push(
'',
`Bootstrap flow:`,
`1. Use this briefing as your durable rules source.`,
`2. Use task_briefing as your compact queue view whenever you need to see assigned work.`,
`3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context.`,
`4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
`5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
);
lines.push(
'',
buildMemberActionModeProtocol(),
'',
buildMemberFormattingProtocol(),
'',
buildMemberTaskProtocol(context.teamName),
'',
buildMemberProcessProtocol(context.teamName)
);
if (activeProcesses.length > 0) {
lines.push('', 'Active registered processes owned by you:');
for (const entry of activeProcesses) {
const bits = [`- ${entry.label} (pid ${entry.pid})`];
if (entry.port != null) bits.push(`port ${entry.port}`);
if (entry.url) bits.push(`url ${entry.url}`);
if (entry.command) bits.push(`command ${entry.command}`);
lines.push(bits.join(', '));
}
}
lines.push('', taskQueue);
return lines.join('\n');
}
module.exports = {
addTaskAttachmentMeta,
addTaskComment,
@ -319,6 +564,7 @@ module.exports = {
setTaskStatus,
softDeleteTask,
startTask,
memberBriefing,
taskBriefing,
unlinkTask,
updateTask: (context, taskRef, updater) =>

View file

@ -123,6 +123,130 @@ describe('agent-teams-controller API', () => {
expect(typeof stopped.stoppedAt).toBe('string');
});
it('builds member briefing from team config language and known member metadata', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.language = 'en';
config.projectPath = '/tmp/project-x';
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', workflow: 'Implement carefully', cwd: '/tmp/project-x' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
controller.tasks.createTask({ subject: 'Queued task', owner: 'bob' });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain('Member briefing for bob on team "my-team" (my-team).');
expect(briefing).toContain('IMPORTANT: Communicate in English.');
expect(briefing).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(briefing).toContain('Workflow:');
expect(briefing).toContain('Implement carefully');
expect(briefing).toContain('Working directory: /tmp/project-x');
expect(briefing).toContain('Task briefing for bob:');
});
it('resolves member briefing from members.meta.json when config members are missing', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.language = 'en';
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'members.meta.json'),
JSON.stringify(
{
version: 1,
members: [{ name: 'bob', role: 'developer', workflow: 'Meta workflow' }],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
const briefing = await controller.tasks.memberBriefing('bob');
expect(briefing).toContain('Role: developer.');
expect(briefing).toContain('Meta workflow');
});
it('resolves member briefing from inbox presence when member metadata is not persisted yet', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
fs.mkdirSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes'), { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'carol.json'), '[]');
const controller = createController({ teamName: 'my-team', claudeDir });
const fromInboxBriefing = await controller.tasks.memberBriefing('carol');
expect(fromInboxBriefing).toContain('Member briefing for carol on team "my-team" (my-team).');
expect(fromInboxBriefing).toContain('Role: team member.');
});
it('rejects member briefing when member is unknown to config, members.meta, and inboxes', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('dave')).rejects.toThrow(
'Member not found in team metadata or inboxes: dave'
);
});
it('ignores pseudo-recipient inbox files when resolving members', async () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
delete config.members;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(path.join(inboxDir, 'cross-team:other-team.json'), '[]');
fs.writeFileSync(path.join(inboxDir, 'other-team.alice.json'), '[]');
fs.writeFileSync(path.join(inboxDir, 'cross_team_send.json'), '[]');
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('cross-team:other-team')).rejects.toThrow(
'Member not found in team metadata or inboxes: cross-team:other-team'
);
await expect(controller.tasks.memberBriefing('other-team.alice')).rejects.toThrow(
'Member not found in team metadata or inboxes: other-team.alice'
);
await expect(controller.tasks.memberBriefing('cross_team_send')).rejects.toThrow(
'Member not found in team metadata or inboxes: cross_team_send'
);
});
it('rejects member briefing for explicitly removed members', async () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'members.meta.json'),
JSON.stringify(
{
version: 1,
members: [{ name: 'carol', role: 'developer', removedAt: Date.now() }],
},
null,
2
)
);
const controller = createController({ teamName: 'my-team', claudeDir });
await expect(controller.tasks.memberBriefing('carol')).rejects.toThrow(
'Member is removed from the team: carol'
);
});
it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -25,6 +25,7 @@ declare module 'agent-teams-controller' {
setNeedsClarification(taskId: string, value: string | null): unknown;
linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
memberBriefing(memberName: string): Promise<string>;
taskBriefing(memberName: string): Promise<string>;
}

View file

@ -278,6 +278,23 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
),
});
server.addTool({
name: 'member_briefing',
description: 'Get bootstrap briefing for a team member',
parameters: z.object({
...toolContextSchema,
memberName: z.string().min(1),
}),
execute: async ({ teamName, claudeDir, memberName }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
},
],
}),
});
server.addTool({
name: 'task_briefing',
description: 'Get formatted task briefing for a member',

View file

@ -40,6 +40,7 @@ describe('agent-teams-mcp tools', () => {
'kanban_list_reviewers',
'kanban_remove_reviewer',
'kanban_set_column',
'member_briefing',
'message_send',
'process_list',
'process_register',
@ -76,6 +77,33 @@ describe('agent-teams-mcp tools', () => {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
}
function writeTeamConfig(
claudeDir: string,
teamName: string,
config: {
name?: string;
language?: string;
projectPath?: string;
members: Array<Record<string, unknown>>;
}
) {
const teamDir = path.join(claudeDir, 'teams', teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify(
{
name: config.name ?? teamName,
...(config.language ? { language: config.language } : {}),
...(config.projectPath ? { projectPath: config.projectPath } : {}),
members: config.members,
},
null,
2
)
);
}
async function startControlServer(
handler: (request: {
method?: string;
@ -269,6 +297,13 @@ describe('agent-teams-mcp tools', () => {
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'alpha';
writeTeamConfig(claudeDir, teamName, {
language: 'en',
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer' },
],
});
const attachmentPath = path.join(claudeDir, 'note.txt');
fs.writeFileSync(attachmentPath, 'ship it');
@ -476,11 +511,30 @@ describe('agent-teams-mcp tools', () => {
expect((briefing as { content: Array<{ text: string }> }).content[0]?.text).toContain(
'Review MCP adapter'
);
const memberBriefing = await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'alice',
});
const memberBriefingText = (memberBriefing as { content: Array<{ text: string }> }).content[0]
?.text;
expect(memberBriefingText).toContain('Member briefing for alice on team "alpha" (alpha).');
expect(memberBriefingText).toContain('Use task_briefing as your compact queue view');
expect(memberBriefingText).toContain('Review MCP adapter');
});
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
const claudeDir = makeClaudeDir();
const teamName = 'gamma';
writeTeamConfig(claudeDir, teamName, {
language: 'en',
projectPath: '/tmp/gamma-project',
members: [
{ name: 'lead', role: 'team-lead' },
{ name: 'alice', role: 'developer', workflow: 'Stay focused' },
],
});
const queuedTask = parseJsonToolResult(
await getTool('task_create').execute({
@ -578,6 +632,59 @@ describe('agent-teams-mcp tools', () => {
expect(briefingText).toContain('Completed:');
expect(briefingText).toContain(`#${completedTask.displayId}`);
expect(briefingText).not.toContain('Completed description should also stay compact');
const memberBriefing = (await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'alice',
})) as { content: Array<{ text: string }> };
const memberBriefingText = memberBriefing.content[0]?.text ?? '';
expect(memberBriefingText).toContain(
'You must NOT start work, claim tasks, or improvise task/process protocol'
);
expect(memberBriefingText).toContain('IMPORTANT: Communicate in English.');
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(memberBriefingText).toContain('Task briefing for alice:');
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'carol',
})) as { content: Array<{ text: string }> };
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
expect(inboxResolvedBriefingText).toContain('Role: team member.');
await expect(
getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'dave',
})
).rejects.toThrow('Member not found in team metadata or inboxes: dave');
await expect(
getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'cross_team_send',
})
).rejects.toThrow('Member not found in team metadata or inboxes: cross_team_send');
await expect(
getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'other-team.alice',
})
).rejects.toThrow('Member not found in team metadata or inboxes: other-team.alice');
expect(inboxResolvedBriefingText).not.toContain(
'Warning: Member metadata was not found in config.json, members.meta.json, or inbox files yet.'
);
});
it('covers review_request_changes and full process lifecycle tools', async () => {

View file

@ -81,6 +81,7 @@ import {
} from '../services/team/actionModeInstructions';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import {
@ -1950,14 +1951,24 @@ async function handleAddMember(
// If team is alive, notify the lead to spawn the new teammate
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const roleHint = typeof role === 'string' && role.trim() ? ` with role "${role.trim()}"` : '';
const workflowHint =
typeof workflow === 'string' && workflow.trim()
? ` Their workflow: ${workflow.trim()}`
: '';
const spawnMessage =
`A new teammate "${memberName}"${roleHint} has been added to the team. ` +
`Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".${workflowHint}`;
const teamDataService = getTeamDataService();
let leadName = 'team-lead';
let displayName = tn;
try {
const [resolvedLeadName, resolvedDisplayName] = await Promise.all([
teamDataService.getLeadMemberName(tn),
teamDataService.getTeamDisplayName(tn),
]);
leadName = resolvedLeadName || 'team-lead';
displayName = resolvedDisplayName || tn;
} catch {
// Best-effort: fall back to default lead and team names
}
const spawnMessage = buildAddMemberSpawnMessage(tn, displayName, leadName, {
name: memberName,
...(typeof role === 'string' ? { role } : {}),
...(typeof workflow === 'string' ? { workflow } : {}),
});
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
} catch {

View file

@ -1151,6 +1151,16 @@ export class TeamDataService {
}
}
async getTeamDisplayName(teamName: string): Promise<string> {
try {
const config = await this.configReader.getConfig(teamName);
const displayName = config?.name?.trim();
return displayName || teamName;
} catch {
return teamName;
}
}
async requestReview(teamName: string, taskId: string): Promise<void> {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
this.getController(teamName).review.requestReview(taskId, {

View file

@ -78,6 +78,12 @@ import type {
ToolCallMeta,
} from '@shared/types';
export const MEMBER_BRIEFING_BOOTSTRAP_ENV = 'CLAUDE_TEAM_ENABLE_MEMBER_BRIEFING_BOOTSTRAP';
export function isMemberBriefingBootstrapEnabled(): boolean {
return process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] === '1';
}
const logger = createLogger('Service:TeamProvisioning');
const { createController } = agentTeamsControllerModule;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
@ -387,7 +393,7 @@ function buildTeammateAgentBlockReminder(): string {
].join('\n');
}
function buildMemberSpawnPrompt(
function buildLegacyMemberSpawnPrompt(
member: TeamCreateRequest['members'][number],
displayName: string,
teamName: string,
@ -414,6 +420,172 @@ ${taskProtocol}
${processRegistration}`;
}
function buildMemberBootstrapPrompt(
member: TeamCreateRequest['members'][number],
displayName: string,
teamName: string,
leadName: string
): string {
const role = member.role?.trim() || 'team member';
const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
: '';
const actionModeProtocol = buildActionModeProtocol();
return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock}
${getAgentLanguageInstruction()}
Your FIRST action: call MCP tool member_briefing with:
{ teamName: "${teamName}", memberName: "${member.name}" }
Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.
If member_briefing fails, send a short message to your team lead "${leadName}" explaining that bootstrap failed, then wait.
After member_briefing succeeds:
- Introduce yourself briefly (name and role) and confirm you are ready.
- Then wait for task assignments.
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}`;
}
function buildLegacyReconnectMemberSpawnPrompt(
member: TeamCreateRequest['members'][number],
teamName: string,
hasTasks: boolean
): string {
const role = member.role?.trim() || 'team member';
const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
: '';
const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' ');
return ` For "${member.name}":
- prompt:
You are ${member.name}, a ${role} on team "${teamName}".${workflowBlock}
${getAgentLanguageInstruction()}
The team has been reconnected after a restart.
${hasTasks ? `You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.` : 'You have no assigned tasks currently.'}
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}
Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${teamName}", memberName: "${member.name}" }
Then:
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
- Before you start any needsFix or pending task, call task_get for that specific task.
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If you have no tasks, wait for new assignments.`;
}
function buildReconnectMemberBootstrapPrompt(
member: TeamCreateRequest['members'][number],
teamName: string,
leadName: string,
hasTasks: boolean
): string {
const role = member.role?.trim() || 'team member';
const workflowBlock = member.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, ' ')}`
: '';
const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' ');
return ` For "${member.name}":
- prompt:
You are ${member.name}, a ${role} on team "${teamName}" (${teamName}).${workflowBlock}
${getAgentLanguageInstruction()}
The team has been reconnected after a restart.
${
hasTasks
? 'You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.'
: 'You have no assigned tasks currently.'
}
Your FIRST action: call MCP tool member_briefing with:
{ teamName: "${teamName}", memberName: "${member.name}" }
Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.
If member_briefing fails, send a short message to your team lead "${leadName}" explaining that bootstrap failed, then wait.
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}
After member_briefing succeeds:
- Use task_briefing as your compact queue view.
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
- Before you start any needsFix or pending task, call task_get for that specific task.
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If you have no tasks, wait for new assignments.`;
}
function buildMemberSpawnPrompt(
member: TeamCreateRequest['members'][number],
displayName: string,
teamName: string,
leadName: string,
taskProtocol: string,
processRegistration: string
): string {
return isMemberBriefingBootstrapEnabled()
? buildMemberBootstrapPrompt(member, displayName, teamName, leadName)
: buildLegacyMemberSpawnPrompt(
member,
displayName,
teamName,
taskProtocol,
processRegistration
);
}
function buildReconnectMemberSpawnPrompt(
member: TeamCreateRequest['members'][number],
teamName: string,
leadName: string,
hasTasks: boolean
): string {
return isMemberBriefingBootstrapEnabled()
? buildReconnectMemberBootstrapPrompt(member, teamName, leadName, hasTasks)
: buildLegacyReconnectMemberSpawnPrompt(member, teamName, hasTasks);
}
export function buildAddMemberSpawnMessage(
teamName: string,
displayName: string,
leadName: string,
member: Pick<TeamCreateRequest['members'][number], 'name' | 'role' | 'workflow'>
): string {
const roleHint =
typeof member.role === 'string' && member.role.trim()
? ` with role "${member.role.trim()}"`
: '';
const workflowHint =
typeof member.workflow === 'string' && member.workflow.trim()
? ` Their workflow: ${member.workflow.trim()}`
: '';
if (!isMemberBriefingBootstrapEnabled()) {
return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
`Please spawn them immediately using the Task tool with team_name="${teamName}" and name="${member.name}".${workflowHint}`
);
}
const prompt = buildMemberBootstrapPrompt(
{
name: member.name,
...(member.role ? { role: member.role } : {}),
...(member.workflow ? { workflow: member.workflow } : {}),
},
displayName,
teamName,
leadName
);
return (
`A new teammate "${member.name}"${roleHint} has been added to the team. ` +
`Please spawn them immediately using the Task tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` +
indentMultiline(prompt, ' ')
);
}
function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
0. IMPORTANT ID RULE:
@ -444,14 +616,15 @@ function buildTaskStatusProtocol(teamName: string): string {
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
9. When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field for traceability.
10. In ALL human-facing or teammate-facing message text, when you mention a task reference, ALWAYS write it with a leading # (for example: #abcd1234, not abcd1234 or "task abcd1234").
11. Review workflow clarity (IMPORTANT):
11. In ALL human-facing or teammate-facing message text, when you mention a teammate, ALWAYS write their name with a leading @ (for example: @alice, not alice). When you mention another team, also use @ (for example: @signal-ops, not signal-ops).
12. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) that will put the wrong task into APPROVED.
- Typical flow:
a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #X
12. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
13. CLARIFICATION PROTOCOL (CRITICAL MANDATORY):
When you are blocked and need information to continue a task, you MUST do ALL steps below skipping the board update or comment breaks traceability:
a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" }
@ -463,10 +636,10 @@ function buildTaskStatusProtocol(teamName: string): string {
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user.
13. DEPENDENCY AWARENESS:
14. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
14. TASK QUEUE DISCIPLINE:
15. TASK QUEUE DISCIPLINE:
- Use task_briefing as a compact queue view of your assigned tasks.
- task_briefing may include full description/comments only for in_progress tasks; needsFix/pending/review/completed entries may be minimal on purpose.
- Finish existing in_progress tasks first.
@ -803,17 +976,20 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
? '2) Skip — this is a solo team with no teammates to spawn.'
: `2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown:
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
// below, even though the text is identical across members. This duplicates ~4K chars per member
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
// Extracting them once and telling the lead to “insert the protocol block” risks hallucination
// or omission — the lead may rephrase rules, skip items, or forget to include them.
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
// IMPORTANT: Use the exact prompt shown for each member.
// With member_briefing bootstrap enabled, the teammate will fetch durable task/process rules after spawn.
${request.members
.map(
(m) => ` For “${m.name}”:
- prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
${buildMemberSpawnPrompt(
m,
displayName,
request.teamName,
leadName,
taskProtocol,
processRegistration
)
.split('\n')
.map((line) => ` ${line}`)
.join('\n')}`
@ -860,9 +1036,7 @@ function buildLaunchPrompt(
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const bootstrapEnabled = isMemberBriefingBootstrapEnabled();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
@ -899,35 +1073,12 @@ function buildLaunchPrompt(
if (snapshot) memberTaskBlocks.set(m.name, snapshot);
}
// Build the teammate spawn prompt template with member-specific task injection
// Build the teammate spawn prompt template with member-specific task presence
const memberSpawnInstructions = members
.map((m) => {
const taskBlock = memberTaskBlocks.get(m.name) || '';
const hasTasks = Boolean(taskBlock);
const workflowBlock = m.workflow?.trim()
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}`
: '';
const actionModeProtocol = indentMultiline(buildActionModeProtocol(), ' ');
return ` For "${m.name}":
- prompt:
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".${workflowBlock}
${languageInstruction}
The team has been reconnected after a restart.
${hasTasks ? `You may have assigned tasks in states like in_progress, needsFix, pending, review, completed, or approved from the previous session.` : 'You have no assigned tasks currently.'}
${buildTeammateAgentBlockReminder()}
${actionModeProtocol}
Your FIRST action: call MCP tool task_briefing with:
{ teamName: "${request.teamName}", memberName: "${m.name}" }
Then:
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
- After that, prioritize tasks marked Needs fixes after review, then normal pending tasks.
- Before you start any needsFix or pending task, call task_get for that specific task.
- If you are the one about to do the implementation/fixes and the owner is missing or someone else, run task_set_owner to yourself immediately before task_start.
- Only then run task_start when you truly begin.
- If you have no tasks, wait for new assignments.`;
return buildReconnectMemberSpawnPrompt(m, request.teamName, leadName, hasTasks);
})
.join('\n\n');
@ -935,12 +1086,12 @@ ${actionModeProtocol}
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
Include the following agent-only instructions verbatim in each teammate's prompt:
${taskProtocol}
${processRegistration}
- IMPORTANT: Use the exact prompt shown for each member.
${
bootstrapEnabled
? 'With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.'
: 'This prompt includes the full durable teammate rules directly.'
}
Per-member spawn instructions:
${memberSpawnInstructions}

View file

@ -193,13 +193,11 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map<string, st
usedColors.add(color);
}
for (let i = 0; i < removed.length; i++) {
map.set(
removed[i].name,
removed[i].color
? normalizeMemberColorName(removed[i].color)
: getMemberColorByName(removed[i].name)
);
for (const member of removed) {
const color = member.color
? normalizeMemberColorName(member.color)
: getMemberColorByName(member.name);
map.set(member.name, color);
}
map.set('user', 'user');

View file

@ -19,6 +19,16 @@ describe('HTTP team runtime routes', () => {
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
const stopTeam = vi.fn<(teamName: string) => void>();
const getAliveTeams = vi.fn<() => string[]>();
const teamProvisioningService = {
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
} as Pick<
NonNullable<HttpServices['teamProvisioningService']>,
'launchTeam' | 'getRuntimeState' | 'getProvisioningStatus' | 'stopTeam' | 'getAliveTeams'
> as HttpServices['teamProvisioningService'];
const services = {
projectScanner: {} as HttpServices['projectScanner'],
@ -28,13 +38,7 @@ describe('HTTP team runtime routes', () => {
dataCache: {} as HttpServices['dataCache'],
updaterService: {} as HttpServices['updaterService'],
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
teamProvisioningService: {
launchTeam,
getRuntimeState,
getProvisioningStatus,
stopTeam,
getAliveTeams,
},
teamProvisioningService,
} satisfies HttpServices;
return {

View file

@ -1,5 +1,5 @@
import * as os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team';
vi.mock('electron', () => ({
@ -82,6 +82,7 @@ import {
registerTeamHandlers,
removeTeamHandlers,
} from '../../../src/main/ipc/teams';
import { MEMBER_BRIEFING_BOOTSTRAP_ENV } from '../../../src/main/services/team/TeamProvisioningService';
describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -93,6 +94,7 @@ describe('ipc teams handlers', () => {
handlers.delete(channel);
}),
};
let originalMemberBriefingBootstrapEnv: string | undefined;
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
@ -108,6 +110,7 @@ describe('ipc teams handlers', () => {
reconcileTeamArtifacts: vi.fn(async () => undefined),
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
@ -167,10 +170,20 @@ describe('ipc teams handlers', () => {
beforeEach(() => {
handlers.clear();
vi.clearAllMocks();
originalMemberBriefingBootstrapEnv = process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '1';
initializeTeamHandlers(service as never, provisioningService as never);
registerTeamHandlers(ipcMain as never);
});
afterEach(() => {
if (originalMemberBriefingBootstrapEnv === undefined) {
delete process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
} else {
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = originalMemberBriefingBootstrapEnv;
}
});
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
@ -254,6 +267,7 @@ describe('ipc teams handlers', () => {
'team-lead',
'Can you review the approach?',
undefined,
undefined,
undefined
);
});
@ -490,6 +504,56 @@ describe('ipc teams handlers', () => {
});
});
it('notifies a live lead to use member_briefing bootstrap for the new teammate', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
workflow: 'Focus on frontend polish',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('and the exact prompt below:')
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Do NOT start work, claim tasks, or improvise workflow/task/process rules')
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('You are alice, a developer on team "My Team" (my-team).')
);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Their workflow: Focus on frontend polish')
);
});
it('falls back to the legacy add-member spawn instruction when bootstrap flag is disabled', async () => {
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '0';
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Please spawn them immediately using the Task tool with team_name="my-team" and name="alice".')
);
expect(provisioningService.sendMessageToTeam).not.toHaveBeenCalledWith(
'my-team',
expect.stringContaining('Your FIRST action: call MCP tool member_briefing')
);
});
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, '../bad', {

View file

@ -10,6 +10,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBloc
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
let originalMemberBriefingBootstrapEnv: string | undefined;
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
@ -31,7 +32,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
};
});
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import {
MEMBER_BRIEFING_BOOTSTRAP_ENV,
TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
@ -67,6 +71,8 @@ function extractPromptFromWrite(writeSpy: ReturnType<typeof vi.fn>): string {
describe('TeamProvisioningService prompt content (solo mode discipline)', () => {
beforeEach(() => {
vi.clearAllMocks();
originalMemberBriefingBootstrapEnv = process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '1';
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@ -75,6 +81,11 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
});
afterEach(() => {
if (originalMemberBriefingBootstrapEnv === undefined) {
delete process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
} else {
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = originalMemberBriefingBootstrapEnv;
}
// Best-effort cleanup of temp dir (per-test)
try {
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
@ -230,14 +241,13 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('DO: Full execution mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain('you MUST do ALL steps below');
expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need');
expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage');
expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
expect(prompt).toContain('If member_briefing fails, send a short message to your team lead');
expect(prompt).toContain('Introduce yourself briefly (name and role) and confirm you are ready');
expect(prompt).toContain('use task_briefing as your compact queue view');
expect(prompt).toContain('Use task_get when you need the full task context before starting a pending/needsFix task');
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
expect(prompt).toContain('you MAY call task_get');
expect(prompt).toContain('Before starting a needsFix or pending task, call task_get');
expect(prompt).not.toContain('Include the following agent-only instructions verbatim in the prompt:');
await svc.cancelProvisioning(runId);
});
@ -297,12 +307,47 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".');
expect(prompt).toContain('reply via task comment (preferred — auto-clears the flag and wakes the owner) or SendMessage');
expect(prompt).toContain('Your FIRST action: call MCP tool task_briefing');
expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
expect(prompt).toContain('If member_briefing fails, send a short message to your team lead');
expect(prompt).toContain('After member_briefing succeeds:');
expect(prompt).toContain('Use task_briefing as your compact queue view.');
expect(prompt).toContain('resume/finish those first');
expect(prompt).toContain('Call task_get only if you need more context than task_briefing already gave you');
expect(prompt).toContain('Before you start any needsFix or pending task, call task_get');
await svc.cancelProvisioning(runId);
});
it('createTeam prompt falls back to legacy inline protocol when bootstrap flag is disabled', async () => {
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '0';
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child, writeSpy } = 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).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
const { runId } = await svc.createTeam(
{
teamName: 'legacy-team',
cwd: process.cwd(),
members: [{ name: 'alice', role: 'developer' }],
description: 'Legacy prompt fallback test',
},
() => {}
);
const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('Include the following agent-only instructions verbatim in the prompt:');
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
expect(prompt).not.toContain('Your FIRST action: call MCP tool member_briefing');
await svc.cancelProvisioning(runId);
});
});

View file

@ -105,7 +105,7 @@ const makeSkill = (overrides: Partial<SkillCatalogItem>): SkillCatalogItem => ({
...overrides,
});
const makeSkillDetail = (overrides: Partial<SkillDetail>): SkillDetail => ({
const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => ({
item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }),
body: 'body',
rawContent: '# Demo',
@ -340,7 +340,7 @@ describe('extensionsSlice', () => {
describe('skills state hardening', () => {
it('ignores stale catalog responses for the same project key', async () => {
let resolveFirst: ((value: SkillCatalogItem[]) => void) | null = null;
let resolveFirst!: (value: SkillCatalogItem[]) => void;
const firstPromise = new Promise<SkillCatalogItem[]>((resolve) => {
resolveFirst = resolve;
});
@ -364,7 +364,7 @@ describe('extensionsSlice', () => {
const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project');
await secondFetch;
resolveFirst?.([
resolveFirst([
makeSkill({
id: '/tmp/project/.claude/skills/older',
skillDir: '/tmp/project/.claude/skills/older',