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 TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; 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() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@ -46,6 +52,39 @@ function assertSafePathSegment(label, value) {
return normalized; 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() { function getHomeDir() {
if (process.env.HOME) return process.env.HOME; if (process.env.HOME) return process.env.HOME;
if (process.env.USERPROFILE) return process.env.USERPROFILE; if (process.env.USERPROFILE) return process.env.USERPROFILE;
@ -86,23 +125,136 @@ function getPaths(flags, teamName) {
} }
function inferLeadName(paths) { function inferLeadName(paths) {
const config = readTeamConfig(paths); const resolved = resolveTeamMembers(paths);
if (!config || !Array.isArray(config.members)) { const lead = resolved.members.find(
return 'team-lead'; (member) =>
} member &&
const lead = config.members.find( ((typeof member.agentType === 'string' && member.agentType === 'team-lead') ||
(member) => member && member.role && String(member.role).toLowerCase().includes('lead') (typeof member.role === 'string' && member.role.toLowerCase().includes('lead')) ||
member.name === 'team-lead')
); );
if (lead) { if (lead) {
return String(lead.name); 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) { function readTeamConfig(paths) {
return readJson(path.join(paths.teamDir, 'config.json'), null); 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) { function resolveLeadSessionId(paths) {
const config = readTeamConfig(paths); const config = readTeamConfig(paths);
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim() return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
@ -302,7 +454,10 @@ module.exports = {
getPaths, getPaths,
inferLeadName, inferLeadName,
isProcessAlive, isProcessAlive,
listInboxMemberNames,
readMembersMeta,
readTeamConfig, readTeamConfig,
resolveTeamMembers,
resolveLeadSessionId, resolveLeadSessionId,
saveTaskAttachmentFile, saveTaskAttachmentFile,
}; };

View file

@ -1,6 +1,7 @@
const taskStore = require('./taskStore.js'); const taskStore = require('./taskStore.js');
const runtimeHelpers = require('./runtimeHelpers.js'); const runtimeHelpers = require('./runtimeHelpers.js');
const messages = require('./messages.js'); const messages = require('./messages.js');
const processStore = require('./processStore.js');
const { wrapAgentBlock } = require('./agentBlocks.js'); const { wrapAgentBlock } = require('./agentBlocks.js');
function normalizeActorName(value) { function normalizeActorName(value) {
@ -299,6 +300,250 @@ async function taskBriefing(context, memberName) {
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(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 = { module.exports = {
addTaskAttachmentMeta, addTaskAttachmentMeta,
addTaskComment, addTaskComment,
@ -319,6 +564,7 @@ module.exports = {
setTaskStatus, setTaskStatus,
softDeleteTask, softDeleteTask,
startTask, startTask,
memberBriefing,
taskBriefing, taskBriefing,
unlinkTask, unlinkTask,
updateTask: (context, taskRef, updater) => updateTask: (context, taskRef, updater) =>

View file

@ -123,6 +123,130 @@ describe('agent-teams-controller API', () => {
expect(typeof stopped.stoppedAt).toBe('string'); 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', () => { it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir }); 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; setNeedsClarification(taskId: string, value: string | null): unknown;
linkTask(taskId: string, targetId: string, linkType: string): unknown; linkTask(taskId: string, targetId: string, linkType: string): unknown;
unlinkTask(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>; 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({ server.addTool({
name: 'task_briefing', name: 'task_briefing',
description: 'Get formatted task briefing for a member', description: 'Get formatted task briefing for a member',

View file

@ -40,6 +40,7 @@ describe('agent-teams-mcp tools', () => {
'kanban_list_reviewers', 'kanban_list_reviewers',
'kanban_remove_reviewer', 'kanban_remove_reviewer',
'kanban_set_column', 'kanban_set_column',
'member_briefing',
'message_send', 'message_send',
'process_list', 'process_list',
'process_register', 'process_register',
@ -76,6 +77,33 @@ describe('agent-teams-mcp tools', () => {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-')); 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( async function startControlServer(
handler: (request: { handler: (request: {
method?: string; method?: string;
@ -269,6 +297,13 @@ describe('agent-teams-mcp tools', () => {
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => { it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
const teamName = 'alpha'; 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'); const attachmentPath = path.join(claudeDir, 'note.txt');
fs.writeFileSync(attachmentPath, 'ship it'); 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( expect((briefing as { content: Array<{ text: string }> }).content[0]?.text).toContain(
'Review MCP adapter' '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 () => { 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 claudeDir = makeClaudeDir();
const teamName = 'gamma'; 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( const queuedTask = parseJsonToolResult(
await getTool('task_create').execute({ await getTool('task_create').execute({
@ -578,6 +632,59 @@ describe('agent-teams-mcp tools', () => {
expect(briefingText).toContain('Completed:'); expect(briefingText).toContain('Completed:');
expect(briefingText).toContain(`#${completedTask.displayId}`); expect(briefingText).toContain(`#${completedTask.displayId}`);
expect(briefingText).not.toContain('Completed description should also stay compact'); 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 () => { it('covers review_request_changes and full process lifecycle tools', async () => {

View file

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

View file

@ -78,6 +78,12 @@ import type {
ToolCallMeta, ToolCallMeta,
} from '@shared/types'; } 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 logger = createLogger('Service:TeamProvisioning');
const { createController } = agentTeamsControllerModule; const { createController } = agentTeamsControllerModule;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
@ -387,7 +393,7 @@ function buildTeammateAgentBlockReminder(): string {
].join('\n'); ].join('\n');
} }
function buildMemberSpawnPrompt( function buildLegacyMemberSpawnPrompt(
member: TeamCreateRequest['members'][number], member: TeamCreateRequest['members'][number],
displayName: string, displayName: string,
teamName: string, teamName: string,
@ -414,6 +420,172 @@ ${taskProtocol}
${processRegistration}`; ${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 { function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
0. IMPORTANT ID RULE: 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. 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. 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"). 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. - 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). - 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. - 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: - Typical flow:
a) Owner finishes work on #X -> task_complete #X a) Owner finishes work on #X -> task_complete #X
b) Reviewer accepts -> review_approve #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: 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: a) STEP 1 FIRST, set the clarification flag with MCP tool task_set_clarification:
{ teamName: "${teamName}", taskId: "<taskId>", value: "lead" } { 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: If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
{ teamName: "${teamName}", taskId: "<taskId>", value: "clear" } { teamName: "${teamName}", taskId: "<taskId>", value: "clear" }
e) Do NOT set clarification to "user" yourself only the team lead escalates to the user. 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 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. 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. - 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. - 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. - 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) 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: : `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 // IMPORTANT: Use the exact prompt shown for each member.
// below, even though the text is identical across members. This duplicates ~4K chars per member // With member_briefing bootstrap enabled, the teammate will fetch durable task/process rules after spawn.
// 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.
${request.members ${request.members
.map( .map(
(m) => ` For “${m.name}”: (m) => ` For “${m.name}”:
- prompt: - prompt:
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration) ${buildMemberSpawnPrompt(
m,
displayName,
request.teamName,
leadName,
taskProtocol,
processRegistration
)
.split('\n') .split('\n')
.map((line) => ` ${line}`) .map((line) => ` ${line}`)
.join('\n')}` .join('\n')}`
@ -860,9 +1036,7 @@ function buildLaunchPrompt(
const userPromptBlock = request.prompt?.trim() const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
const taskProtocol = buildTaskStatusProtocol(request.teamName); const bootstrapEnabled = isMemberBriefingBootstrapEnabled();
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; 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); 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 const memberSpawnInstructions = members
.map((m) => { .map((m) => {
const taskBlock = memberTaskBlocks.get(m.name) || ''; const taskBlock = memberTaskBlocks.get(m.name) || '';
const hasTasks = Boolean(taskBlock); const hasTasks = Boolean(taskBlock);
const workflowBlock = m.workflow?.trim() return buildReconnectMemberSpawnPrompt(m, request.teamName, leadName, hasTasks);
? `\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.`;
}) })
.join('\n\n'); .join('\n\n');
@ -935,12 +1086,12 @@ ${actionModeProtocol}
- team_name: "${request.teamName}" - team_name: "${request.teamName}"
- name: the member's name - name: the member's name
- subagent_type: "general-purpose" - subagent_type: "general-purpose"
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately. - IMPORTANT: Use the exact prompt shown for each member.
Include the following agent-only instructions verbatim in each teammate's prompt: ${
bootstrapEnabled
${taskProtocol} ? 'With member_briefing bootstrap enabled, the teammate will fetch durable rules after spawn.'
: 'This prompt includes the full durable teammate rules directly.'
${processRegistration} }
Per-member spawn instructions: Per-member spawn instructions:
${memberSpawnInstructions} ${memberSpawnInstructions}

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import * as os from 'os'; 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'; import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team';
vi.mock('electron', () => ({ vi.mock('electron', () => ({
@ -82,6 +82,7 @@ import {
registerTeamHandlers, registerTeamHandlers,
removeTeamHandlers, removeTeamHandlers,
} from '../../../src/main/ipc/teams'; } from '../../../src/main/ipc/teams';
import { MEMBER_BRIEFING_BOOTSTRAP_ENV } from '../../../src/main/services/team/TeamProvisioningService';
describe('ipc teams handlers', () => { describe('ipc teams handlers', () => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>(); const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>();
@ -93,6 +94,7 @@ describe('ipc teams handlers', () => {
handlers.delete(channel); handlers.delete(channel);
}), }),
}; };
let originalMemberBriefingBootstrapEnv: string | undefined;
const service = { const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]), listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
@ -108,6 +110,7 @@ describe('ipc teams handlers', () => {
reconcileTeamArtifacts: vi.fn(async () => undefined), reconcileTeamArtifacts: vi.fn(async () => undefined),
deleteTeam: vi.fn(async () => undefined), deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'), getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })), sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'm1' })),
sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })), sendDirectToLead: vi.fn(async () => ({ deliveredToInbox: false, messageId: 'direct-1' })),
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })), createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
@ -167,10 +170,20 @@ describe('ipc teams handlers', () => {
beforeEach(() => { beforeEach(() => {
handlers.clear(); handlers.clear();
vi.clearAllMocks(); vi.clearAllMocks();
originalMemberBriefingBootstrapEnv = process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV];
process.env[MEMBER_BRIEFING_BOOTSTRAP_ENV] = '1';
initializeTeamHandlers(service as never, provisioningService as never); initializeTeamHandlers(service as never, provisioningService as never);
registerTeamHandlers(ipcMain 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', () => { it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true);
@ -254,6 +267,7 @@ describe('ipc teams handlers', () => {
'team-lead', 'team-lead',
'Can you review the approach?', 'Can you review the approach?',
undefined, undefined,
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 () => { it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!; const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, '../bad', { 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 tempClaudeRoot = '';
let tempTeamsBase = ''; let tempTeamsBase = '';
let tempTasksBase = ''; let tempTasksBase = '';
let originalMemberBriefingBootstrapEnv: string | undefined;
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() }, 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 { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess'; 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)', () => { describe('TeamProvisioningService prompt content (solo mode discipline)', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); 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-')); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prompts-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks'); tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@ -75,6 +81,11 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
}); });
afterEach(() => { 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) // Best-effort cleanup of temp dir (per-test)
try { try {
fs.rmSync(tempClaudeRoot, { recursive: true, force: true }); 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('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
expect(prompt).toContain('DO: Full execution mode.'); expect(prompt).toContain('DO: Full execution mode.');
expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.'); expect(prompt).toContain('DELEGATE: Strict orchestration mode for leads.');
expect(prompt).toContain('you MUST do ALL steps below'); expect(prompt).toContain('Your FIRST action: call MCP tool member_briefing');
expect(prompt).toContain('STEP 2 — THEN, add a task comment describing exactly what you need'); expect(prompt).toContain('Do NOT start work, claim tasks, or improvise workflow/task/process rules');
expect(prompt).toContain('STEP 3 — THEN, send a message to your team lead via SendMessage'); 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_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_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).not.toContain('Include the following agent-only instructions verbatim in the prompt:');
expect(prompt).toContain('you MAY call task_get');
expect(prompt).toContain('Before starting a needsFix or pending task, call task_get');
await svc.cancelProvisioning(runId); 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_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);
expect(prompt).toContain('NEVER use agent-only blocks in messages to "user".'); 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 member_briefing');
expect(prompt).toContain('Your FIRST action: call MCP tool task_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('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('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'); expect(prompt).toContain('Before you start any needsFix or pending task, call task_get');
await svc.cancelProvisioning(runId); 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, ...overrides,
}); });
const makeSkillDetail = (overrides: Partial<SkillDetail>): SkillDetail => ({ const makeSkillDetail = (overrides: Partial<SkillDetail> = {}): SkillDetail => ({
item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }), item: makeSkill({ id: '/tmp/skills/demo', skillDir: '/tmp/skills/demo' }),
body: 'body', body: 'body',
rawContent: '# Demo', rawContent: '# Demo',
@ -340,7 +340,7 @@ describe('extensionsSlice', () => {
describe('skills state hardening', () => { describe('skills state hardening', () => {
it('ignores stale catalog responses for the same project key', async () => { 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) => { const firstPromise = new Promise<SkillCatalogItem[]>((resolve) => {
resolveFirst = resolve; resolveFirst = resolve;
}); });
@ -364,7 +364,7 @@ describe('extensionsSlice', () => {
const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project'); const secondFetch = store.getState().fetchSkillsCatalog('/tmp/project');
await secondFetch; await secondFetch;
resolveFirst?.([ resolveFirst([
makeSkill({ makeSkill({
id: '/tmp/project/.claude/skills/older', id: '/tmp/project/.claude/skills/older',
skillDir: '/tmp/project/.claude/skills/older', skillDir: '/tmp/project/.claude/skills/older',