feat: enhance task management with improved notification and briefing features
- Refactored task creation logic to support explicit immediate start and maintain default pending status. - Implemented notification system for task owners upon assignment, including detailed task instructions. - Enhanced task briefing output to provide a compact view of assigned tasks, differentiating between in-progress, pending, and completed tasks. - Added utility functions for better task management and streamlined task reference handling. - Updated tests to cover new task assignment notifications and briefing functionalities, ensuring accurate task status representation.
This commit is contained in:
parent
b369b779cc
commit
9c63959a08
11 changed files with 430 additions and 114 deletions
|
|
@ -86,7 +86,7 @@ function getPaths(flags, teamName) {
|
|||
}
|
||||
|
||||
function inferLeadName(paths) {
|
||||
const config = readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||
const config = readTeamConfig(paths);
|
||||
if (!config || !Array.isArray(config.members)) {
|
||||
return 'team-lead';
|
||||
}
|
||||
|
|
@ -99,6 +99,17 @@ function inferLeadName(paths) {
|
|||
return config.members[0] ? String(config.members[0].name) : 'team-lead';
|
||||
}
|
||||
|
||||
function readTeamConfig(paths) {
|
||||
return readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||
}
|
||||
|
||||
function resolveLeadSessionId(paths) {
|
||||
const config = readTeamConfig(paths);
|
||||
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||
? config.leadSessionId.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
|
@ -291,5 +302,7 @@ module.exports = {
|
|||
getPaths,
|
||||
inferLeadName,
|
||||
isProcessAlive,
|
||||
readTeamConfig,
|
||||
resolveLeadSessionId,
|
||||
saveTaskAttachmentFile,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function listTasks(paths, options = {}) {
|
|||
}
|
||||
|
||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||
const normalizedRef = String(taskRef || '').trim();
|
||||
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
||||
if (!normalizedRef) {
|
||||
throw new Error('Missing taskId');
|
||||
}
|
||||
|
|
@ -168,7 +168,8 @@ function computeInitialStatus(paths, input, owner, blockedByIds) {
|
|||
const explicit = normalizeStatus(input.status);
|
||||
if (explicit) return explicit;
|
||||
if (blockedByIds.length > 0) return 'pending';
|
||||
return owner ? 'in_progress' : 'pending';
|
||||
if (owner && input.startImmediately === true) return 'in_progress';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function pickTaskId(input) {
|
||||
|
|
@ -577,6 +578,45 @@ function buildTaskReference(task) {
|
|||
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
|
||||
}
|
||||
|
||||
function compareTasksForBriefing(a, b) {
|
||||
const order = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
};
|
||||
const byStatus = (order[a.status] ?? 99) - (order[b.status] ?? 99);
|
||||
if (byStatus !== 0) return byStatus;
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(kanbanEntry, task) {
|
||||
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
|
||||
return normalizeTaskReviewState(task.reviewState);
|
||||
}
|
||||
return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
|
||||
}
|
||||
|
||||
function formatBriefTaskLine(task, reviewState) {
|
||||
const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : '';
|
||||
return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`;
|
||||
}
|
||||
|
||||
function formatCommentLine(comment) {
|
||||
const author =
|
||||
typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown';
|
||||
const text = typeof comment.text === 'string' ? comment.text.trim() : '';
|
||||
return ` - ${author}: ${text || '(empty comment)'}`;
|
||||
}
|
||||
|
||||
function formatTaskBriefing(paths, teamName, memberName) {
|
||||
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
|
||||
teamName,
|
||||
|
|
@ -585,57 +625,51 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
});
|
||||
const activeTasks = listTasks(paths)
|
||||
.filter((task) => task.owner === memberName && task.status !== 'deleted')
|
||||
.sort((a, b) => String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}));
|
||||
.sort(compareTasksForBriefing);
|
||||
|
||||
if (activeTasks.length === 0) {
|
||||
return `No pending tasks for ${memberName}.`;
|
||||
return `No assigned tasks for ${memberName}.`;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
for (const task of activeTasks) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
const reviewState = kanbanEntry && kanbanEntry.column ? `, review=${kanbanEntry.column}` : '';
|
||||
const effectiveReviewState =
|
||||
normalizeTaskReviewState(task.reviewState) !== 'none'
|
||||
? normalizeTaskReviewState(task.reviewState)
|
||||
: reviewState
|
||||
? String(kanbanEntry.column)
|
||||
: 'none';
|
||||
lines.push(
|
||||
`${buildTaskReference(task)} [status=${task.status}${effectiveReviewState !== 'none' ? `, review=${effectiveReviewState}` : ''}] ${task.subject}`
|
||||
);
|
||||
if (task.description) lines.push(` Description: ${task.description}`);
|
||||
if (task.blockedBy && task.blockedBy.length > 0) {
|
||||
const blockedLabels = task.blockedBy
|
||||
.map((depId) => {
|
||||
try {
|
||||
return buildTaskReference(readTask(paths, depId, { includeDeleted: true }));
|
||||
} catch {
|
||||
return depId;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
lines.push(` Blocked by: ${blockedLabels}`);
|
||||
}
|
||||
if (task.related && task.related.length > 0) {
|
||||
const relatedLabels = task.related
|
||||
.map((relatedId) => {
|
||||
try {
|
||||
return buildTaskReference(readTask(paths, relatedId, { includeDeleted: true }));
|
||||
} catch {
|
||||
return relatedId;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
lines.push(` Related: ${relatedLabels}`);
|
||||
}
|
||||
if (Array.isArray(task.comments) && task.comments.length > 0) {
|
||||
for (const comment of task.comments.slice(-3)) {
|
||||
lines.push(` Comment by ${comment.author}: ${comment.text}`);
|
||||
const groups = {
|
||||
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||
pending: activeTasks.filter((task) => task.status === 'pending'),
|
||||
completed: activeTasks.filter((task) => task.status === 'completed'),
|
||||
};
|
||||
|
||||
const lines = [`Task briefing for ${memberName}:`];
|
||||
|
||||
if (groups.in_progress.length > 0) {
|
||||
lines.push('', 'In progress:');
|
||||
for (const task of groups.in_progress) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
const reviewState = getEffectiveReviewState(kanbanEntry, task);
|
||||
lines.push(formatBriefTaskLine(task, reviewState));
|
||||
if (task.description) {
|
||||
lines.push(` Description: ${task.description}`);
|
||||
}
|
||||
if (Array.isArray(task.comments) && task.comments.length > 0) {
|
||||
lines.push(' Comments:');
|
||||
for (const comment of task.comments) {
|
||||
lines.push(formatCommentLine(comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.pending.length > 0) {
|
||||
lines.push('', 'Pending:');
|
||||
for (const task of groups.pending) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.completed.length > 0) {
|
||||
lines.push('', 'Completed:');
|
||||
for (const task of groups.completed) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,84 @@
|
|||
const taskStore = require('./taskStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const messages = require('./messages.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
||||
function normalizeActorName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isSameMember(left, right) {
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim()
|
||||
? options.description.trim()
|
||||
: typeof task.description === 'string' && task.description.trim()
|
||||
? task.description.trim()
|
||||
: '';
|
||||
const prompt =
|
||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`];
|
||||
|
||||
if (description) {
|
||||
lines.push(``, `Description:`, description);
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
lines.push(``, `Instructions:`, prompt);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
1. Check the latest full context before starting:
|
||||
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
2. When you actually begin work, mark it started:
|
||||
task_start { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
3. When the work is done, mark it completed:
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const sender = normalizeActorName(options.from) || leadName;
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
return taskStore.createTask(context.paths, input);
|
||||
const task = taskStore.createTask(context.paths, input);
|
||||
if (input && input.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
from: input.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function getTask(context, taskId) {
|
||||
|
|
@ -44,7 +120,20 @@ function restoreTask(context, taskId, actor) {
|
|||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
return taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
|
||||
if (
|
||||
owner != null &&
|
||||
normalizeActorName(updatedTask.owner) &&
|
||||
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||
) {
|
||||
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
function updateTaskFields(context, taskId, fields) {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,60 @@ describe('agent-teams-controller API', () => {
|
|||
expect(rows[1].id).toBe(registered.id);
|
||||
});
|
||||
|
||||
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and keeps briefing compact', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Queued task',
|
||||
description: 'Do this later',
|
||||
owner: 'bob',
|
||||
prompt: 'Check the migration plan first.',
|
||||
});
|
||||
const activeTask = controller.tasks.createTask({
|
||||
subject: 'Active task',
|
||||
description: 'Resume immediately',
|
||||
owner: 'bob',
|
||||
startImmediately: true,
|
||||
});
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Already done',
|
||||
description: 'Completed task description should stay out of compact rows',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' });
|
||||
controller.tasks.setTaskOwner(reassignedTask.id, 'bob');
|
||||
|
||||
expect(pendingTask.status).toBe('pending');
|
||||
expect(activeTask.status).toBe('in_progress');
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox).toHaveLength(4);
|
||||
expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
|
||||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(briefing).toContain('In progress:');
|
||||
expect(briefing).toContain(`#${activeTask.displayId}`);
|
||||
expect(briefing).toContain('Description: Resume immediately');
|
||||
expect(briefing).toContain('Resumed work with latest context.');
|
||||
expect(briefing).toContain('Pending:');
|
||||
expect(briefing).toContain(`#${pendingTask.displayId}`);
|
||||
expect(briefing).not.toContain('Description: Do this later');
|
||||
expect(briefing).toContain('Completed:');
|
||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefing).not.toContain(
|
||||
'Completed task description should stay out of compact rows'
|
||||
);
|
||||
});
|
||||
|
||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
||||
...(related?.length ? { related: related.join(',') } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(startImmediately === false && owner ? { status: 'pending' } : {}),
|
||||
...(startImmediately !== undefined ? { startImmediately } : {}),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
expect(createdTask.status).toBe('pending');
|
||||
|
||||
const listedTasks = parseJsonToolResult(
|
||||
await getTool('task_list').execute({
|
||||
|
|
@ -276,6 +277,108 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
const queuedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Queued work',
|
||||
description: 'Pending description should stay out of briefing details',
|
||||
owner: 'alice',
|
||||
prompt: 'Read the plan before starting.',
|
||||
})
|
||||
);
|
||||
expect(queuedTask.status).toBe('pending');
|
||||
|
||||
const activeTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Active work',
|
||||
description: 'This one is already in progress',
|
||||
owner: 'alice',
|
||||
startImmediately: true,
|
||||
})
|
||||
);
|
||||
expect(activeTask.status).toBe('in_progress');
|
||||
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: activeTask.id,
|
||||
text: 'Investigating the active task now.',
|
||||
from: 'alice',
|
||||
});
|
||||
|
||||
const completedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Done work',
|
||||
description: 'Completed description should also stay compact',
|
||||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: completedTask.id,
|
||||
actor: 'alice',
|
||||
});
|
||||
|
||||
const unassignedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Assign later',
|
||||
})
|
||||
);
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: unassignedTask.id,
|
||||
owner: 'alice',
|
||||
});
|
||||
|
||||
const queuedByHashRef = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: `#${queuedTask.displayId}`,
|
||||
})
|
||||
);
|
||||
expect(queuedByHashRef.id).toBe(queuedTask.id);
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox).toHaveLength(4);
|
||||
expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`);
|
||||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||
|
||||
const briefing = (await getTool('task_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
})) as { content: Array<{ text: string }> };
|
||||
const briefingText = briefing.content[0]?.text ?? '';
|
||||
expect(briefingText).toContain('In progress:');
|
||||
expect(briefingText).toContain(`#${activeTask.displayId}`);
|
||||
expect(briefingText).toContain('Description: This one is already in progress');
|
||||
expect(briefingText).toContain('Investigating the active task now.');
|
||||
expect(briefingText).toContain('Pending:');
|
||||
expect(briefingText).toContain(`#${queuedTask.displayId}`);
|
||||
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
|
||||
expect(briefingText).toContain('Completed:');
|
||||
expect(briefingText).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefingText).not.toContain('Completed description should also stay compact');
|
||||
});
|
||||
|
||||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
|
|
|
|||
|
|
@ -742,7 +742,7 @@ export class TeamDataService {
|
|||
/* best-effort */
|
||||
}
|
||||
|
||||
const shouldStart = request.owner && request.startImmediately !== false;
|
||||
const shouldStart = request.owner && request.startImmediately === true;
|
||||
const task = controller.tasks.createTask({
|
||||
subject: request.subject,
|
||||
...(request.description?.trim() ? { description: request.description.trim() } : {}),
|
||||
|
|
@ -751,48 +751,10 @@ export class TeamDataService {
|
|||
...(related.length > 0 ? { related } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
createdBy: 'user',
|
||||
...(shouldStart ? { status: 'in_progress' } : { status: 'pending' }),
|
||||
...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}),
|
||||
...(shouldStart ? { startImmediately: true } : {}),
|
||||
}) as TeamTask;
|
||||
|
||||
if (shouldStart && request.owner) {
|
||||
try {
|
||||
const leadName = await this.resolveLeadName(teamName);
|
||||
|
||||
// Skip inbox notification when lead assigns a task to themselves (solo teams)
|
||||
if (!this.isLeadOwner(request.owner, leadName)) {
|
||||
// Build notification with full context — inbox is the primary delivery
|
||||
// channel to agents (Claude Code monitors inbox via fs.watch)
|
||||
const parts = [`New task assigned to you: ${this.getTaskLabel(task)} "${task.subject}".`];
|
||||
|
||||
if (request.description?.trim()) {
|
||||
parts.push(`\nDescription:\n${request.description.trim()}`);
|
||||
}
|
||||
|
||||
if (request.prompt?.trim()) {
|
||||
parts.push(`\nInstructions:\n${request.prompt.trim()}`);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Update task status using the board MCP tools:`,
|
||||
`task_start { teamName: "${teamName}", taskId: "${task.id}" }`,
|
||||
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
||||
AGENT_BLOCK_CLOSE
|
||||
);
|
||||
|
||||
await this.sendMessage(teamName, {
|
||||
member: request.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
summary: `New task ${this.getTaskLabel(task)} assigned`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort notification — don't fail task creation if message fails
|
||||
}
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ function buildMemberSpawnPrompt(
|
|||
${getAgentLanguageInstruction()}
|
||||
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, then call task_get for the specific task you are about to resume or start.
|
||||
${buildTeammateAgentBlockReminder()}
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
|
|
@ -363,8 +364,9 @@ ${processRegistration}`;
|
|||
function buildTaskStatusProtocol(teamName: string): string {
|
||||
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
0. IMPORTANT ID RULE:
|
||||
- In MCP tool calls, ALWAYS use the exact canonical taskId value shown in the board/task snapshot.
|
||||
- Human-facing summaries may use the short display label like #abcd1234 for readability.
|
||||
- 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. 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.
|
||||
|
|
@ -404,6 +406,11 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
12. 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.
|
||||
13. 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; pending/completed entries may be minimal on purpose.
|
||||
- Before resuming any in_progress task, call task_get for that task to refresh full context.
|
||||
- When you become free, call task_briefing again, inspect pending tasks, then call task_get for the specific pending task you are about to start before task_start.
|
||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||
}
|
||||
|
||||
|
|
@ -627,7 +634,7 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
|
|||
: '';
|
||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}] ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
|
||||
return `\nYour active tasks from last session (resume in_progress first, then pending):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
/** Build a full task board snapshot for the lead. */
|
||||
|
|
@ -649,7 +656,7 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
: '';
|
||||
return ` - ${formatTaskDisplayLabel(t)} (taskId: ${t.id}) [${t.status}]${owner} ${t.subject}${deps}${desc}`;
|
||||
});
|
||||
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
|
||||
return `\nCurrent task board (in_progress/pending):\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||
|
|
@ -803,13 +810,15 @@ function buildLaunchPrompt(
|
|||
|
||||
${languageInstruction}
|
||||
The team has been reconnected after a restart.
|
||||
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
|
||||
${hasTasks ? `You may have in_progress or pending tasks from the previous session.` : 'You have no assigned tasks currently.'}
|
||||
${buildTeammateAgentBlockReminder()}
|
||||
|
||||
Your FIRST action: call MCP tool task_briefing with:
|
||||
{ teamName: "${request.teamName}", memberName: "${m.name}" }
|
||||
Then resume in_progress tasks first, then pending tasks.
|
||||
If you have no tasks, wait for new assignments.`;
|
||||
Then:
|
||||
- If task_briefing shows any in_progress task, call task_get for each in_progress task first and resume/finish those before touching pending tasks.
|
||||
- Only after there are no active in_progress tasks, inspect pending tasks from task_briefing, call task_get for the specific pending task you choose, and only then run task_start when you truly begin.
|
||||
- If you have no tasks, wait for new assignments.`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
|
|
|
|||
|
|
@ -179,17 +179,6 @@ export const MessageComposer = ({
|
|||
}
|
||||
}, [sending, sendError, draft]);
|
||||
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const { addFiles: draftAddFiles } = draft;
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -280,7 +269,6 @@ export const MessageComposer = ({
|
|||
<div
|
||||
className="relative mb-3 p-3"
|
||||
role="group"
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
|
||||
it('creates task with status pending when startImmediately is false', async () => {
|
||||
const createTaskMock = vi.fn((task) => task);
|
||||
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
|
|
@ -274,8 +274,66 @@ describe('TeamDataService', () => {
|
|||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
|
||||
expect.objectContaining({ owner: 'alice', createdBy: 'user' })
|
||||
);
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true }));
|
||||
});
|
||||
|
||||
it('creates task with explicit immediate start only when startImmediately is true', async () => {
|
||||
const createTaskMock = vi.fn((task) => ({ ...task, status: 'in_progress' }));
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
||||
} as never,
|
||||
{
|
||||
getNextTaskId: vi.fn(async () => '2'),
|
||||
getTasks: vi.fn(async () => []),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{
|
||||
createTask: createTaskMock,
|
||||
addBlocksEntry: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(_teamName: string) =>
|
||||
({
|
||||
tasks: {
|
||||
createTask: createTaskMock,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
const result = await service.createTask('my-team', {
|
||||
subject: 'Start now',
|
||||
owner: 'alice',
|
||||
startImmediately: true,
|
||||
prompt: 'Begin immediately.',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('in_progress');
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
owner: 'alice',
|
||||
createdBy: 'user',
|
||||
startImmediately: true,
|
||||
prompt: 'Begin immediately.',
|
||||
})
|
||||
);
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' }));
|
||||
});
|
||||
|
||||
it('persists explicit related task links when creating a task', async () => {
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ 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('use task_briefing as your compact queue view');
|
||||
expect(prompt).toContain('then call task_get for the specific task you are about to resume or start');
|
||||
expect(prompt).toContain('Use task_briefing as a compact queue view of your assigned tasks.');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
|
@ -273,6 +276,9 @@ 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('Your FIRST action: call MCP tool task_briefing');
|
||||
expect(prompt).toContain('call task_get for each in_progress task first');
|
||||
expect(prompt).toContain('call task_get for the specific pending task you choose');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue