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 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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
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;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue