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:
parent
ba083c317b
commit
afcb0fcc1a
14 changed files with 1021 additions and 88 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
1
mcp-server/src/agent-teams-controller.d.ts
vendored
1
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue