diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 3f9e88ad..78d19b90 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -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, }; diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 47445b80..29b7021b 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -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: "", owner: "" } + - 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: "" } + - 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: "" } +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: "", note?: "", notifyOwner: true } +5. If review fails and changes are needed, use MCP tool review_request_changes: + { teamName: "${teamName}", taskId: "", comment: "" } +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: "", text: "", from: "" } +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: "", text: "", from: "" } + 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 # 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: "", value: "lead" } + b) STEP 2 — THEN, add a task comment describing exactly what you need: + { teamName: "${teamName}", taskId: "", text: "question / blocker / missing info", from: "" } + 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: "", 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: , label: "", from: "", port?: , url?: "http://localhost:", 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: } +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: + + ... hidden instructions only ... + +- 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) => diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 3d2c1d1b..30219aea 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -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 }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index bccd8f76..f87f31c0 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -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; taskBriefing(memberName: string): Promise; } diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 7d719197..fc61924d 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -278,6 +278,23 @@ export function registerTaskTools(server: Pick) { ), }); + 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', diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 52100099..ae497016 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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>; + } + ) { + 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 () => { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 9fe99e27..ab113ac9 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7ffd317a..7a859b2a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1151,6 +1151,16 @@ export class TeamDataService { } } + async getTeamDisplayName(teamName: string): Promise { + 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 { const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2833963e..5b16cbc3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 +): 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 # 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: "", 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: "", 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} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 2aa57fc3..90e1320e 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -193,13 +193,11 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map { const getProvisioningStatus = vi.fn<(runId: string) => Promise>(); const stopTeam = vi.fn<(teamName: string) => void>(); const getAliveTeams = vi.fn<() => string[]>(); + const teamProvisioningService = { + launchTeam, + getRuntimeState, + getProvisioningStatus, + stopTeam, + getAliveTeams, + } as Pick< + NonNullable, + '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 { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index f79cd66d..4a4ee3d8 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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 Promise>(); @@ -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', { diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index f4b93616..247a8a79 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -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): 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); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 54c9ec78..4955c0ea 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -105,7 +105,7 @@ const makeSkill = (overrides: Partial): SkillCatalogItem => ({ ...overrides, }); -const makeSkillDetail = (overrides: Partial): SkillDetail => ({ +const makeSkillDetail = (overrides: Partial = {}): 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((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',